🐙Testfile

Services

Tests rarely run in a vacuum: they need the web server or app under test, a database in a specific version, sometimes a whole set of dependencies. A Testfile declares these as services. The runner

  1. starts them (as local processes or containers),
  2. waits until they are ready,
  3. runs the dependent tests,
  4. gracefully stops them — in reverse start order, and also when you interrupt the run with Ctrl+C.

Declaring services

Services declared at the top level live for the whole run. Services declared on a test start right before that test and stop when it — including all its nested tests — finished:

version: 0
ports:
  web: random
services:              # for the whole run
  web:
    command: npm start
    env:
      PORT: ${{ ports.web }}
    ready:
      http: http://localhost:${{ ports.web }}/healthz
test:
  name: integration
  services:            # only while this test runs
    postgres:
      container:
        image: docker.io/library/postgres:16
        ports:
          - "${{ ports.db }}:5432"
        env:
          POSTGRES_PASSWORD: test
      ready:
        log: database system is ready to accept connections
  command: npm run test:integration

A service runs either a local command/script or a container.

Service dependencies

Services in one map start concurrently. When one of them needs another to be up first — the app that talks to the database on boot — declare it with needs:

services:
  db:
    container:
      image: docker.io/library/postgres:16-alpine
      ports: ["${{ ports.db }}:5432"]
      env:
        POSTGRES_PASSWORD: test
    ready:
      log: database system is ready to accept connections
  app:
    # starts only once db passed its readiness check
    needs: [db]
    command: npm start
    env:
      DATABASE_URL: postgresql://postgres:test@127.0.0.1:${{ ports.db }}/postgres
    ready:
      http: http://127.0.0.1:${{ ports.app }}/healthz

needs is docker-compose’s depends_on, except it always waits for the readiness check, never just for the process or container to exist — which is the difference between a working setup and a retry loop in your app. Names must refer to services in the same map, and cycles are rejected when the Testfile is loaded. Services without needs still start immediately and in parallel, so only the actual chain is serialized. If a dependency never becomes ready, the services that need it are never started and the test fails with the dependency’s error.

Steps between services

Some things have to happen after a service is up and before the tests run: migrations, a fixture load, a bucket that has to exist. once: true makes an entry a step rather than a server — it runs once, and exiting with code 0 is what makes it ready:

ports:
  db: random
services:
  postgres:
    container:
      image: docker.io/library/postgres:16
      ports: ["${{ ports.db }}:5432"]
      env:
        POSTGRES_PASSWORD: test
    ready:
      exec: pg_isready -h 127.0.0.1 -p 5432
  seed:
    once: true             # a step, not a server
    needs: [postgres]      # runs when postgres is ready
    command: ./scripts/seed.sh
    timeout: 2m
    env:
      DATABASE_URL: postgresql://postgres:test@127.0.0.1:${{ ports.db }}/postgres
test:
  command: yarn e2e        # starts after seed finished

The point is that the step is in the graph. It waits for the database itself — no polling loop of your own, no second copy of the readiness check inside seed.sh. And anything that names it in needs waits for it to have finished, not merely to have started, so a chain reads in the order it happens:

services:
  postgres: { container: { image: docker.io/library/postgres:16 }, ready: { ... } }
  migrate:  { once: true, needs: [postgres], command: npm run migrate }
  seed:     { once: true, needs: [migrate],  command: npm run seed }
  app:      { needs: [seed], command: npm start, ready: { http: ... } }

A step that exits non-zero fails the run: nothing that needed it starts, and the error names the step. Its output is kept like any service’s log, which is usually where the reason is. Because it is expected to end, a step has no ready check (the exit code is the signal) and no stop (nothing is left running) — both are rejected when the Testfile loads. timeout is the one field only a step has; without it a wedged step waits forever.

Everything else about services still applies: a step can be a command, a script or a container (image: postgres:16 with command: [psql, ...] needs no client installed locally), and shared: true runs it once for all the tests that share it.

Do I need a step, or a setup hook?

A setup hook runs after all of a test’s services are ready and belongs to that one test. Reach for a step when the work belongs to the service rather than to a test — when another service has to wait for it (a setup hook cannot sit between two services), when several tests need it done once, or when you want it in the service log with its own name and duration. Otherwise a setup hook is the simpler thing.

Sharing services

By default every test (and every matrix instance) starts its own copy of the services it declares. With shared: true a single instance is reused by all tests whose resolved configuration is identical:

test:
  matrix:
    node: ["20", "22"]
  services:
    postgres:
      shared: true              # one postgres for both node versions
      container:
        image: docker.io/library/postgres:16
        ports: ["${{ ports.db }}:5432"]

The service starts with the first test that needs it and stops after the last one finished. If the configuration depends on a matrix variable (say, image: postgres:${{ matrix.postgres }}), each distinct configuration still gets its own instance — sharing only kicks in where it is safe.

Containers

container:
  image: docker.io/library/postgres:16
  pull: missing           # always | missing | never
  network: testnet        # created if needed; service name = network alias
  ports:
    - "${{ ports.db }}:5432"
  env:
    POSTGRES_PASSWORD: test
  volumes:
    - ./fixtures:/docker-entrypoint-initdb.d:ro
  entrypoint: [/bin/sh, -c]   # optional overrides
  command: ["./start.sh"]

Note what is not here: an engine. The Testfile describes what runs; which engine runs it — podman, docker or kubernetes — is decided by whoever runs the tests:

  1. testfile start --engine podman (or docker, or kubernetes),
  2. else the TESTFILE_ENGINE environment variable,
  3. else the first of podman, docker, kubernetes that actually responds — a docker CLI whose daemon is down, or a kubectl without a reachable cluster, is skipped, not picked.

The same file runs under podman on one laptop, docker in CI and a cluster in staging, without an edit. testfile doctor checks all three engines and says which one a run on this machine would use.

With network, service containers can talk to each other directly: each container joins the named network with its service name as alias, so an app container reaches its database at db:5432 instead of going through host ports. The network is created on first use and left in place after the run. The engine’s own modes — host, none, bridge — are joined as such instead, with nothing created and no alias set: network: host puts a service and the machine on the same localhost, which is how a container can reach a service running outside the engine.

Services on a Kubernetes cluster

When the run’s engine is kubernetes — --engine kubernetes, TESTFILE_ENGINE=kubernetes, or nothing local responding while kubectl reaches a cluster — services run as pods on whatever cluster your kubeconfig points at, a remote one or a local kind/minikube. Nothing about the Testfile changes:

services:
  db:
    container:
      image: docker.io/library/postgres:16
      namespace: ci          # optional; must exist (kubectl's default otherwise)
      context: staging       # optional kubeconfig context
      ports:
        - "${{ ports.db }}:5432"
      env:
        POSTGRES_PASSWORD: test
    ready:
      tcp: ${{ ports.db }}
      timeout: 120s

The declared ports are forwarded from 127.0.0.1 into the pod, so your tests and readiness checks connect to localhost exactly as they would with published container ports — DATABASE_URL=postgres://…@localhost:${{ ports.db }}/… keeps working unchanged.

Between services the wiring is better than the forward suggests: each service with ports also gets a Kubernetes Service carrying its name, so sibling services in the same namespace reach each other by name over cluster DNS (db:5432), the same way the network alias works for container networks. needs between services behaves as always.

What the runner does for stability, since two clusters hops are involved:

Limits worth knowing: volumes and network are rejected (host paths mean nothing on a cluster — see below; DNS already covers service-to-service), a test body container still runs locally, and two concurrent runs sharing a namespace would fight over the DNS name — give them separate namespaces. testfile doctor checks that kubectl is installed and the cluster answers.

What containers can see of your project

Your project is not in a service container. It is in a test body container. And on a cluster it is in neither:

project mounted?other mounts
a service container: (podman/docker)noonly what volumes: lists
a service container: (kubernetes)nonone — volumes is rejected
a test body container:yes, at workdir (default /workspace)plus what volumes: lists

A service is a dependency, not a place your code runs, so nothing is mounted into it unless you say so:

services:
  postgres:
    container:
      image: docker.io/library/postgres:16
      volumes:
        - ./fixtures:/docker-entrypoint-initdb.d:ro   # you asked for this

A test body is the opposite: the whole project is mounted, not just the test’s workdir, so relative paths that reach outside it (a monorepo’s root config) still resolve, and the container’s working directory is wherever the test would have run on the host, translated into the mount.

On a cluster, nothing is mounted at all

With the kubernetes engine volumes is an error rather than a silent no-op — a host path names nothing on a cluster node, and a mount that quietly did nothing would be worse than a rejection. In practice:

This is what makes a step that seeds from repo files engine-specific. The container form works on podman and docker only:

  seed:
    once: true
    needs: [postgres]
    container:
      image: docker.io/library/postgres:16
      volumes: ["./fixtures:/fixtures"]      # rejected on kubernetes
      command: [psql, -f, /fixtures/base.sql]

The portable form is a plain step: it runs on your machine, in the project, and talks to the published or forwarded port like any test does.

  seed:
    once: true
    needs: [postgres]
    command: psql "$DATABASE_URL" -f fixtures/base.sql

That trade is the mirror image of the readiness probe one: portable across engines, but the client has to be installed on every machine that runs the suite. Bake the fixtures into an image instead if you would rather keep the tooling in the container.

Where a mount path is resolved

A volumes source — and the project mount of a test body — is a path on the machine whose engine runs the container, which is not always the machine you are sitting at:

running the containers./fixtures means
podman or docker on this machine (Linux)exactly what you think
Docker Desktop, podman machine (macOS, Windows)a path inside the engine’s VM. These share your home directory by default, so a project under $HOME works; one outside the shared set does not
a remote daemon (DOCKER_HOST, a remote docker context)a path on that machine, which almost certainly does not have your repository
kubernetesnothing — rejected before anything runs

The awkward part is the failure mode: an engine asked to mount a path it cannot see usually creates an empty directory instead of refusing. A test body that suddenly finds no source files, or a database whose init scripts never ran, is worth checking against this table before it is blamed on the Testfile. testfile doctor reports which engine a run would use, which is the first thing to establish.

Readiness checks

Starting a process is not the same as it being usable. ready describes how the runner knows the service is actually up; dependent tests only start after all their services are ready.

ready:
  http:
    url: http://localhost:${{ ports.web }}/healthz
    status: 200
  tcp: ${{ ports.db }}
  log:
    pattern: ready to accept connections
    stream: any
  exec: pg_isready -h localhost -p 5432
  delay: 1s        # wait before the first check
  interval: 500ms  # poll interval (default 1s)
  timeout: 60s     # give up after this (default 30s), failing the run

Set any combination of http, tcp, log and exec — all configured checks must pass. exec runs a probe command until it exits with code 0, which is ideal for tools that ship their own readiness probe like pg_isready or redis-cli ping. If the service exits before becoming ready (or dies later while tests still depend on it), the dependent tests are aborted and the run fails.

Combining checks

Every configured check is evaluated in every round, all of them at once, and the service is ready the first time they are all true together:

ready:
  tcp: ${{ ports.db }}       # the published port is forwarded, and
  exec: pg_isready -p 5432   # postgres itself accepts connections
  timeout: 60s

Two port numbers in one block is not a typo. tcp and http always connect from the machine running the tests, so they name the published port, while exec on a container service runs inside it and names the port the service listens on. One ready: block can watch from both sides at once.

What follows from “all at once, every round”:

When the deadline passes, the error names the checks that were still failing in the last round, so a combination stays debuggable — here the tcp leg had come up and the probe had not:

✘ service db failed: not ready after 1m00s (exec did not exit 0)

Where exec runs

Most services ship their own readiness probe, and it lives in the image — pg_isready in postgres, redis-cli in redis. So a container service is probed from the inside, and only a service that has no inside is probed on the machine running the tests:

the service is…the command runs…with…
a container:inside that container — podman exec / docker exec, or kubectl exec into the pod on the kubernetes enginethe image’s filesystem, user and WORKDIR; the image’s environment plus container.env; the ports the service really listens on
a container: with host: trueon the machine running the teststhe project’s files, the service’s workdir and env, and the published ports
a command: or script: (a plain process)on the machine running the teststhe same; host: is accepted but has nothing to opt out of

Both forms are a shell line handed to sh -c, so &&, pipes and quoting all work as usual — and on Windows a host-side probe needs an sh on PATH, the same one your tests use (testfile doctor checks for it).

A container probes itself

Nothing has to be installed locally, and the command names the port the service listens on inside the container — 5432, not the random published one:

ports:
  db: random
services:
  postgres:
    container:
      image: docker.io/library/postgres:16
      ports:
        - "${{ ports.db }}:5432"
      env:
        POSTGRES_PASSWORD: test
    ready:
      # -h 127.0.0.1 checks the TCP listener the tests will use; without it
      # pg_isready asks the unix socket, which is ready a moment earlier
      exec: pg_isready -h 127.0.0.1 -p 5432 -U postgres
      timeout: 60s

The usual one-liners, all of which run in the image and need nothing on the machine running the tests:

imageprobe
postgrespg_isready -h 127.0.0.1 -p 5432
redisredis-cli ping
mysql, mariadbmysqladmin ping -h 127.0.0.1 --silent
mongomongosh --quiet --eval "db.runCommand({ ping: 1 }).ok"
rabbitmqrabbitmq-diagnostics -q ping
elasticsearchcurl -fsS localhost:9200/_cluster/health

These are what those images ship today, not a promise — a slimmed-down image may not have the client at all. tcp: asks nothing of the image and is the better check whenever “the port answers” is really what you mean.

Probing from the outside with host: true

Set host: true when the check belongs on this machine instead — a tool installed here, a client the image does not contain, or something that is only reachable through the published port:

ports:
  s3: random
services:
  minio:
    container:
      image: docker.io/minio/minio
      ports:
        - "${{ ports.s3 }}:9000"
    ready:
      exec:
        command: curl -fsS http://localhost:${{ ports.s3 }}/minio/health/live
        host: true

It is also the way out when the image has no shell: sh -c has to exist in there, so distroless and scratch-based images cannot be entered at all. Probe those from the outside, or use tcp/http, which never enter the container:

    ready:
      tcp: ${{ ports.api }} # no shell needed anywhere

With the kubernetes engine the difference is a machine, not just a namespace: the in-container probe executes in the cluster, while host: true runs next to your tests and reaches the pod only through the forwarded 127.0.0.1 port.

Templates resolve the same either way

${{ ports.x }}, ${{ env.x }} and ${{ matrix.x }} are substituted by the runner before the command is handed over, so they mean the same thing in both places — which is exactly the trap. ${{ ports.db }} is the published port on the host side, and inside the container nothing listens on it:

    ready:
      # ✗ the published port - inside the container nothing listens there
      exec: pg_isready -p ${{ ports.db }}
    ready:
      # ✓ the port the service itself listens on
      exec: pg_isready -p 5432

Environment variables are not shared between the two sides. Inside, the command sees what the container process sees: the image’s own variables plus container.env. Outside, it sees the run’s environment plus the service’s env: — the same one a command: service is started with.

What counts as ready

Exit code 0 means ready; anything else means not yet and is retried on the next interval — a failing probe is normal while a service starts and is never an error by itself. The run only fails when timeout expires — the clock starts after delay, so the longest wait is delay + timeout — or when the service dies first, which reports exited before becoming ready instead of naming a check.

A single attempt is capped at 10s. One that hangs is killed and retried, so a wedged probe costs an interval instead of the whole run.

The probe’s own output is discarded — neither stdout nor stderr reaches the service log, which stays the service’s. If a probe is not behaving, run the same line by hand (podman exec <container> sh -c '…') rather than looking for it in the log.

doctor follows, stop.command does not

testfile doctor looks up the executables a Testfile names, but only for probes that actually run here: an in-container probe is skipped, one with host: true is checked like any other command.

stop.command is not symmetrical — it always runs on the machine running the tests, because it usually operates on the container rather than in it.

Coming from docker-compose, or from an older Testfile

A compose healthcheck runs inside the container, so testfile init carries it over unchanged, container port and all.

Testfiles written before this rule probed containers through the published port and needed the tool installed locally. Either aim the command at the container port (usually shorter, and it stops depending on the machine), or add host: true to keep it exactly as it was.

Graceful shutdown

stop:
  signal: SIGINT   # default SIGTERM
  timeout: 5s      # grace period before SIGKILL (default 10s)

Processes receive signal (to their whole process group) and get timeout to exit before the runner escalates to SIGKILL. Containers are stopped with podman stop/docker stop using the same timeout. Alternatively, stop.command runs a custom shutdown command.

This shutdown path always runs: on success, on failure, and on Ctrl+C. A first Ctrl+C stops tests and shuts services down gracefully; a second one force-kills everything.