Testing¶
Test Organization¶
Tests are organized by component:
tests/api/— API server teststests/cli/— CLI command teststests/core/— Core module teststests/janitor/— Kubernetes janitor teststests/runner/— Runner teststests/test_e2e.py— End-to-end tests (requires Minikube)tests/test_smoke_diagnostics.py— Smoke-diagnostic unit teststests/smoke/— Smoke tests against live environmentsservices/modules/*/tests/— Lambda and Batch service tests
Running Tests¶
Run the pytest commands from hawk/. Run scripts/dev/smoke from the
repository root.
# Run all unit tests
pytest
# Run specific package tests (matches CI)
pytest tests/api -n auto -vv
pytest tests/cli -n auto -vv
pytest tests/core -n auto -vv
pytest tests/janitor -n auto -vv
pytest tests/runner -n auto -vv
# Run the smoke-diagnostic unit tests (the runner CI leg also runs these)
pytest tests/test_smoke_diagnostics.py -n auto -vv
# Run E2E tests (requires running Minikube)
pytest --e2e -m e2e tests/test_e2e.py -vv --timeout=600
Smoke Tests¶
Smoke tests validate a deployed environment by running real evals against real models.
Run them from the repository root after logging Pulumi into the deployment's S3 backend. The smoke wrapper resolves the selected stack's API URL, but login is interactive and must target that same API first:
export AWS_PROFILE="<profile-for-the-target-account>"
export PULUMI_FALLBACK_TO_STATE_SECRETS_MANAGER=true
pulumi login "s3://<state-bucket>?region=<region>&awssdk=v2"
STACK="<stack>"
API_URL="$(pulumi stack output api_url -s "$STACK")"
HAWK_API_URL="$API_URL" hawk login
scripts/dev/smoke --stack "$STACK" # warehouse included
scripts/dev/smoke --stack "$STACK" --skip-warehouse # exclude warehouse checks
scripts/dev/smoke --stack "$STACK" -k test_real_llm # filter tests by name
The selected AWS credentials must reach the stack's account. Use
hawk login --no-browser in a devcontainer, SSH session, or other headless
environment.
For validating a dependency bump with them, see Validating a dependency update.
E2E Tests¶
By default, E2E tests run entirely locally with Minikube and require no METR account, AWS credentials, registry login, or LLM API key. Run these commands inside the devcontainer, starting from the repository root:
cd hawk
uv sync --all-extras --all-groups --locked
source .venv/bin/activate
../scripts/dev/start-minikube.sh
set -a
source .env
set +a
pytest --e2e -m e2e tests/test_e2e.py -vv --timeout=600
The startup script creates hawk/.env from .env.example if needed, builds
the API and runner with the public python:3.13-slim-trixie base image, and
logs in through the bundled local Dex provider. Local Compose and Minikube
builds use this public base by default; production builds retain the Docker
Hardened Images base, whose registry requires a Docker login. Internet access
is still needed to download public images and Python packages.
The E2E evaluations explicitly request 2Gi of runner memory, so they fit a
small local cluster even when hawk/.env retains the general 16Gi default.
CI also uses 2Gi for its test environment.
CI uses the same trust decision for Batch/E2E images and the E2E model:
| Run | Python base | E2E model |
|---|---|---|
main pushes and PR branches in METR/hawk, except Dependabot |
dhi.io/python:3.13-dev |
openai/gpt-4o-mini |
| Fork and Dependabot PRs | python:3.13-slim-trixie |
mockllm/model |
| Local default | python:3.13-slim-trixie |
mockllm/model |
The Python image defaults are pinned to multi-architecture digests in CI, Compose, and the Minikube setup script. Update the matching pins together when upgrading the images; the tags above are shown without digests for readability.
Trusted CI keeps Docker Hub and DHI authentication, the private GHCR devcontainer cache, and the existing OpenAI API key. Fork and Dependabot runs need none of those secrets and build the devcontainer from public sources. Forks run the same tests after any required GitHub workflow approval.
To test DHI locally, run this from hawk/ with the virtual environment active:
docker login dhi.io
export PYTHON_BASE_IMAGE=dhi.io/python:3.13-dev@sha256:2e34fb87972c33e06222decf60d07646e6b874faaf6b1820f198cca70a5dc7b1
../scripts/dev/start-minikube.sh
The image override is independent of HAWK_E2E_MODEL; local tests still use
the mock unless you select the live model below.
The tests submit evals through the real CLI and API, run Kubernetes jobs, and
verify the evaluation logs in MinIO. They install the pinned public
inspect_evals task and, by default, use Inspect's deterministic mockllm/model
provider. Both model modes execute the task's solver and scorer.
The secret test passes dummy values and verifies they reach the job's
Kubernetes Secret; this test always uses mockllm/model. The eval task does
not require a separate task sandbox. Use the smoke suite for
broader coverage of real model providers against a deployed environment.
To run the real OpenAI eval locally, add your own key and the direct OpenAI
endpoint to hawk/.env:
Then, from hawk/ with the virtual environment active, recreate the API so
the runner receives the key and select the live model explicitly:
docker compose up -d --force-recreate api
set -a
source .env
set +a
HAWK_E2E_MODEL=openai/gpt-4o-mini pytest --e2e -m e2e tests/test_e2e.py -vv --timeout=600
HAWK_E2E_MODEL supports mockllm/model (the default) and
openai/gpt-4o-mini. The latter makes real, billable model requests.
The scan E2E test remains skipped because it hangs in CI; it is not part of the credential-free eval coverage.
Frontend Tests¶
hawk/www has two suites:
# From hawk/www:
pnpm test # jsdom, fast, the bulk of the coverage
pnpm test:browser # real Chromium, src/browser only
pnpm test:browser mounts our own components — currently the four ag-grid
lists (EvalSetList, ScanList, JobsList, SampleList) — with their data
hooks mocked, and asserts the contracts between the grid and our configuration:
custom cellRenderers, valueFormatters, getRowId, the object-form
rowSelection, defaultColDef.sortable, and the IDatasource that drives
SampleList's infinite row model.
A real browser is required because ag-grid only renders rows into a measured viewport; jsdom reports every element as 0×0, so the grid comes out empty and none of the above is exercised.
The goal is drift detection — catching the case where a library changed and
our usage did not. That is why the tests render real components rather than
synthetic grids: a synthetic grid only re-tests ag-grid's own behaviour, which is
upstream's job and tells us nothing about whether EvalSetList still works.
Make new tests earn their place
A test here is only worth its runtime if it fails when our code drifts.
Verify a new one by mutating the component it covers — change getRowId,
drop a valueFormatter, flip sortable — and confirm it goes red. If it
still passes, it is testing the library, not us.
Keep the suite small and the assertions shallow. Notes for adding to it:
- Render inside a desktop-sized wrapper (
width: 1400). The default test page is ~414px, narrow enough that ag-grid column-virtualises and trailing columns never reach the DOM. - Scope lookups to the grid (
.ag-header-cell-text,.ag-row) rather than page-widegetByText, which goes ambiguous against surrounding chrome.
Install the browser once:
Managed macOS fleets
Playwright's bundled Chromium is ad-hoc signed with no Team ID, and some
managed Macs kill it on launch — Playwright then reports
Target page, context or browser has been closed. Point it at a locally
installed, notarised Chrome instead:
Validating a dependency update¶
Which suite actually exercises a bump depends on the ecosystem, so "run the smoke tests" is not always the right answer.
| Ecosystem | What validates it |
|---|---|
uv (Python) |
Unit tests, then the smoke suite against a real deployment — this is the most thorough option available |
npm (hawk/www) |
pnpm test, pnpm typecheck, pnpm build, and pnpm test:browser. Smoke tests never load the browser bundle and add nothing |
docker |
Varies per image — see below. Do not assume E2E or smoke covers it |
github-actions |
Only CI running on the PR itself |
Docker bumps are not uniformly covered¶
Dependabot tracks eight Dockerfiles, and CI builds only some of them. Check which image actually changed before assuming a green PR means anything:
| Dockerfile | Built in CI by |
|---|---|
.devcontainer/ |
devcontainer.yml, and E2E runs inside the built image |
hawk/services/modules/<batch>/, .../docker_lambda/ |
python-test-batch / python-test-lambda, which build with target: test and run the tests inside |
middleman/, relay/ |
Lint only (infra-lint.yml) — not built |
hawk/, jumphost/, hawk/services/modules/viewer/ |
Nothing — first built at deploy time |
Smoke tests run whatever images are already deployed, and SMOKE_IMAGE_TAG
overrides only the runner. So for the last row, build the image locally and
deploy it to a dev stack, or the bump ships unbuilt.
For runner-only iteration without AWS, use the local Minikube workflow. For the more thorough validation of a Python bump against deployed services, build a runner image from the branch and point a dev stack's smoke run at it. This does not require deploying the whole branch. Complete the Smoke Tests setup above in the same shell, then authenticate Docker to the stack's ECR and run:
IMAGE_TAG=my-tag
ENVIRONMENT="$(pulumi stack output env -s "$STACK")"
AWS_REGION="$(pulumi stack output region -s "$STACK")"
(
cd hawk
PULUMI_STACK="$STACK" \
ENVIRONMENT="$ENVIRONMENT" AWS_REGION="$AWS_REGION" \
../scripts/dev/build-and-push-runner-image.sh "$IMAGE_TAG"
)
SMOKE_IMAGE_TAG="$IMAGE_TAG" scripts/dev/smoke --stack "$STACK" # all tests, no -k filter
Two limits worth knowing:
SMOKE_IMAGE_TAGonly replaces the runner image. A bump to anapi-group dependency is not exercised until the branch is deployed to a dev stack withpulumi up.- The
stg-smokeCI environment only permitsmain, sosmoke.ymlcannot be dispatched against a PR branch. Run locally against a dev stack instead.
For full smoke coverage, install skopeo (otherwise the requires_task_images
tests skip silently) and leave the warehouse enabled (for requires_warehouse).
Also check for over-broad mocks after any upgrade. Patching a library
constructor (aioboto3.Session.client) rather than our own factory
(my_module.aws_clients.get_s3_client) breaks whenever a dependency changes how
it reaches external services — a previously-passing test failing with
MagicMock can't be used in 'await' expression is the signature. See the mock
scoping guidance in hawk/tests/CLAUDE.md.
Testing Tools¶
| Tool | Purpose |
|---|---|
pytest-xdist |
Parallel test execution (-n auto) |
pytest-asyncio |
Async test support (auto mode) |
pytest-mock |
General mocking |
pyfakefs |
Filesystem mocking |
moto, pytest-aioboto3 |
AWS mocking |
testcontainers[postgres] |
PostgreSQL containers |
time-machine |
Time mocking |
Code Quality Checks¶
Must pass before completion. Run the repo's pre-commit hooks — the same checks CI's Lint job runs (see Code Quality for setup):