🐙Testfile

A copy of spec/TESTFILE.md, rendered from the file in the repository.

Testfile specification (v0)

Status: under review. This specification describes format version 0, a draft that may still change based on feedback. Version 1 — the first version with stability guarantees — is targeted for Q4 2026. Feedback on any part of the format is welcome via GitHub issues.

This document is the normative specification of the Testfile format — the input side: how a project describes its tests. The output side — how a recorded test run looks on disk (.testfile/runs/<id>/ with run.yaml, logs and junit.xml) — is specified in RESULTS.md, so that different tools can generate results and different tools can consume them. The machine-readable counterpart of this document is the JSON schema in ../schema/testfile.schema.json; if the two disagree, this document wins and the schema has a bug.

The execution semantics described here are additionally pinned by the runner-independent conformance suite: every change to this document must add or adjust a conformance case, and a runner implementation claims compatibility by passing the whole suite.

File

A Testfile is a YAML document. The runner looks for these file names in the current directory, in this order:

  1. Testfile
  2. testfile.yaml
  3. testfile.yml

Concepts

A Testfile describes a test suite. The root of the document contains exactly one test. Each test either

Groups nest arbitrarily deep. Throughout this document, a test’s nested tests are all tests contained below it, directly or transitively.

Any test can additionally be expanded by a matrix into one instance per variable combination.

Tests may depend on services — long-running processes the tests need, such as the web server or app under test, or databases in specific versions. Services run as local processes or as containers — podman/docker locally, or as pods on a Kubernetes cluster; which engine is the choice of whoever runs the suite, never of the document. The runner starts them before the dependent tests, waits until they are ready (HTTP check, TCP check, or a log pattern on stdout/stderr), and gracefully stops them afterwards — including when the user aborts the run with Ctrl+C.

Top level document

FieldTypeRequiredDescription
version0yesFormat version. Always 0 while the format is under review; see the versioning policy.
namestringnoDisplay name of the project/Testfile.
envmapnoEnvironment variables for everything in this file.
envFilestring/arraynoDotenv file(s), relative to the Testfile, loaded for the whole run. See Env files.
forwardEnvarraynoHost variables (names or * patterns) forwarded into the isolated test environment. See Environment.
secretsarraynoHost variables forwarded and masked in everything recorded. See Secrets.
portsmapnoNamed ports, see Ports.
servicesmapnoServices for the whole run, see Services.
testtestyesThe root test.

Unknown fields are an error everywhere in the document — this catches typos.

Tests

A test is an object with exactly one of the following variant fields:

FieldTypeDescription
commandstringA single shell command. The test passes iff it exits with code 0.
scriptstringA multi-line shell script, executed with sh -e. The test passes iff it exits with code 0.
sequencearray of testChildren run one after another. The first failure stops the sequence and fails it, unless the failing child sets continueOnError.
parallelarray of testChildren run concurrently. The group fails if any child fails.
foreachstring/objectGlob (relative to this Testfile, not templated) expanded into one test per matching path, generated from template, see Foreach.
includestringPath or glob (relative to this Testfile, not templated) of another Testfile — or a directory containing one — embedded here, see Includes.

Common fields available on every test:

FieldTypeDescription
namestringDisplay name. Matrix instances get their combination appended, e.g. integration (postgres=16).
descriptionstringFree-form description.
tagsarrayOptional labels made of letters and digits only ([A-Za-z0-9]+), e.g. fast, slow, flaky, nightly, aws, gcp. A tag applies to the test and all its nested tests. Runners use tags to execute a subset of tests.
ifstringCondition deciding whether the test (and its nested tests) runs, see Conditions. A false condition marks the test skipped without failing the parent.
envmapEnvironment variables, merged over the parent’s environment (child wins).
envFilestring/arrayDotenv file(s), relative to the test’s working directory, loaded for this test and its nested tests. See Env files.
forwardEnvarrayHost variables (names or * patterns) forwarded into this test and its nested tests. See Environment.
secretsarrayHost variables forwarded and masked for this test and its nested tests. See Secrets.
workdirstringWorking directory for this test and its nested tests, relative to the Testfile (or absolute).
timeoutdurationAbort and fail this test (and its children) after this time.
continueOnErrorbooleanThe failure of this test is reported but does not fail the parent group. Default false.
retryint/objectOnly on command/script tests: retry on failure. An integer is the number of additional attempts; the object form {count, delay} adds a wait between attempts. The test fails when the last attempt fails.
shellstringOnly on command/script tests: interpreter instead of sh, e.g. bash, bash -e, python3. Split on whitespace and invoked as <shell...> -c <source>, so the interpreter must accept -c. The default sh -e for scripts does not apply — pass flags like -e yourself.
portsmapNamed ports scoped to this test and its nested tests, resolved when the test starts and merged over inherited ports (this test wins), see Ports.
servicesmapServices scoped to this test and its nested tests, see Services.
matrixmapMatrix expansion, see Matrix.
maxParallelintegerOnly together with parallel: cap on concurrently running children. Default: unlimited.
needsarrayOnly on children of a parallel group: names of sibling tests that must finish first, turning the group into a DAG. The test starts once all named siblings passed, were skipped by their if condition, or were removed by filters. A sibling that failed — or was itself skipped because of its own needs — skips this test too, so skips cascade down a chain. References must name existing, unambiguous siblings and must not form cycles.
artifactsarrayGlob patterns, relative to the test’s working directory, of files the test produces (coverage, screenshots, reports). Runners copy matching files into the recorded run — also when the test failed.
inputsarrayOnly on command/script tests: glob patterns, relative to the test’s working directory, of the files the test depends on. Enables result caching.
setuphookRuns after the test’s services are ready and before its body. A failing setup skips the body and fails the test; teardown still runs. See Hooks.
teardownhookAlways runs after the test’s body — on success, failure and abort — before the test’s services stop. A failing teardown fails an otherwise passing test. See Hooks.
containerobjectRuns this test’s body (and the bodies of nested tests) inside a container image, see Test containers.
templatetestOnly together with foreach: the test generated per match, see Foreach.

Execution semantics

Conditions

if is evaluated after template resolution, with these rules:

The runner injects TESTFILE_OS (linux, darwin, win32) and TESTFILE_ARCH into the environment, so platform conditions are written as if: ${{ env.TESTFILE_OS }} == linux.

A false condition skips the test and its nested tests (status skipped). This does not fail the surrounding sequence/parallel group; a group whose active children all skipped is itself skipped. A fully skipped run exits with code 0.

Foreach

foreach generates one test per matching path. The string form is the glob; the object form adds folder (default true), file (default false) and ignore (glob patterns of matches to skip).

A test with foreach must have a template test and no other variant field. Runners expand it when loading the file:

Generated tests are ordinary tests and may themselves contain include, matrix or another foreach.

Includes

include embeds another Testfile as a nested suite, composing e.g. per-package Testfiles of a monorepo into one:

test:
  sequence:
    - name: packages
      include: packages/*/Testfile
    - include: ./app

Rules:

Hooks

setup and teardown are objects with exactly one of command or script (run with sh -c / sh -e), plus optional env, workdir and timeout. They run in the test’s environment (their own env merged on top) and write into the test’s log.

Order of one test: services start and become ready → setup → body (command/script/children) → teardown → services stop.

Matrix

matrix maps variable names to lists of scalar values. The test is expanded into one instance per entry of the cross product:

test:
  name: integration
  matrix:
    node: ["20", "22"]
    db: [postgres, mysql]
  command: npm run test:integration

expands into 4 instances. Two reserved keys refine the set:

Matrix instances of one test run like a parallel group of the expanded instances (bounded by maxParallel if set together with a group variant). Matrix values are available to the instance as ${{ matrix.NAME }} templates and as environment variables named TESTFILE_MATRIX_<NAME> (upper-cased).

Matrix variables are also substituted inside the instance’s services, so a matrix over database versions can start one container per version:

matrix:
  postgres: ["15", "16", "17"]
services:
  postgres:
    container:
      image: docker.io/library/postgres:${{ matrix.postgres }}

Ports

ports declares named ports for the run:

ports:
  web: random   # a free port, allocated when the run starts
  db: 5432      # a fixed port

random asks the runner to allocate a currently free TCP port. The resolved number is available everywhere as ${{ ports.NAME }}. Port names (like env variable names) match [A-Za-z_][A-Za-z0-9_]*; service names additionally allow -.

ports may also be declared on a test. Such ports are scoped to that test and its nested tests — the test’s own fields, its services and everything below it resolve them, siblings do not. They are resolved when the test starts and merge over inherited ports, with the test’s declaration winning on a name clash. A random port declared on a test is allocated per test instance: matrix instances of the test each get their own port.

Services

A service is an object with exactly one of:

FieldTypeDescription
commandstringRun a local process with this shell command.
scriptstringRun a local process with this shell script (sh -e).
containerobjectRun a container, see below.

plus the common fields description, shared, needs, once, timeout, env, workdir, ready and stop. needs names services in the same map that must be ready before this one starts (a health-gated depends_on); unknown names, self-references and cycles are rejected when the document loads.

Services declared at the top level start before the root test and stop after the whole run. Services declared on a test start before that test (after the parent’s services) and stop when the test finishes. Services of one services map are started concurrently; tests only start after all of their services (including inherited ones) reported ready. Services are stopped in reverse start order. Service output is recorded next to the run (see RESULTS.md), so viewers can show each service’s log by name.

A service with shared: true is started once per resolved configuration — the name plus every configuration field after template resolution (command/script, env, workdir, and for containers image, ports, env, volumes, entrypoint, command, network, pull, context and namespace) — and reference-counted: matrix instances or parallel tests whose resolved config is identical reuse the running instance, which stops after the last of them finished. Configs that differ — e.g. a matrix variable in the image or env — still get their own instance. A shared service sees the environment of the test that started it; if it dies unexpectedly, all tests depending on it are aborted.

If a service exits by itself while dependent tests are still running, the runner marks the service as failed and aborts the dependent tests.

Steps (once)

once: true makes the entry a step rather than a server: it is run one time, and exiting with code 0 is what makes it ready. Everything that waits for a service waits for a step to have finished — services that need it, and the tests the map belongs to. A non-zero exit fails the service, so nothing that needed it is started and the run fails, exactly as a readiness timeout does.

FieldTypeDescription
oncebooleanRun once and treat a zero exit as ready. Default false.
timeoutdurationOnly with once: fail the step when it has not finished by then. Unlimited by default.

A step has no ready (its exit code is the signal) and no stop (nothing is left running); both combinations are rejected, as is timeout on a service without once. Everything else is unchanged: it may be a command, a script or a container, it may need other services and be needed by them, shared: true runs it once for all tests that share it, and its output is recorded like any service’s. A step that exits before the run ends is not the “exited by itself” failure above — finishing is the point.

Containers

FieldTypeDescription
imagestring (req.)Image reference, e.g. docker.io/library/postgres:16.
portsarray of string"HOST:CONTAINER" mappings; the host part may be a template like "${{ ports.db }}:5432", the container part must be a literal port.
envmapEnvironment inside the container.
volumesarray of string"HOST:CONTAINER[:OPTIONS]" mounts.
pullenumalways, missing or never: when to pull the image. Unset defers to the engine’s own default — effectively missing, except that kubernetes always pulls :latest/untagged images.
networkstringAttach to this named container network, creating it if needed (networks are left in place after the run). The service name becomes a network alias, so services on the same network reach each other by name. The engine’s own modes — host, none, bridge — are joined as such instead: nothing is created and no alias is set (only user-defined networks have aliases); host is how a service and the machine reach each other on localhost.
entrypointarray of stringOverride the image entrypoint.
commandarray of stringOverride the image command.
context, namespacestringOnly read when the run’s engine is kubernetes: which kubeconfig context / namespace to run in (the namespace must exist). Defaults: kubectl’s own.

A container definition never names an engine — a Testfile describes what runs, and the machine running it decides how.

Nothing of the project is mounted into a service container: a volumes entry is the only way in. Its source is a path on the machine whose engine runs the container, which need not be the machine the runner is on — a remote daemon or a virtualised engine resolves it against that filesystem. The kubernetes engine rejects volumes outright rather than mounting nothing (see Kubernetes).

A test’s own body can also run in a container (container: on a test, applying to it and everything nested below it). A test container shares image, env, volumes, pull and network with the table above, but differs where mounting the project matters: workdir is the mount point of the project inside the container (default /workspace), network defaults to host so services stay reachable on localhost, options passes extra engine flags (e.g. --user 1000:1000) — and there are no ports, entrypoint, command, context or namespace. The mount source is the directory of the Testfile that declares the test — the whole project, not only the test’s workdir, so paths reaching outside it keep resolving — and the container’s working directory is the test’s working directory translated into the mount. Because that mount cannot be provided by a cluster, test containers always run on a local engine (see Engine selection).

Engine selection

Which engine runs the containers is decided per run, in this order:

  1. an explicit CLI choice (testfile start --engine <name>),
  2. the TESTFILE_ENGINE environment variable (read from the runner’s own environment, not the isolated test environment),
  3. otherwise the first of podman, docker, kubernetes — in this order — that responds: podman and docker via their info command, kubernetes only when kubectl reaches a cluster. Merely being installed is not enough, and the first responding engine is cached for the rest of the run.

An explicit choice that is not one of the three names fails the run rather than falling back. When no engine responds and the suite starts containers, the affected services and tests fail with a message naming everything that was tried.

Test bodies (container: on a test) are the exception: they mount the project and therefore always run on a local engine. A run whose engine is kubernetes (chosen or detected) runs test bodies on the first local engine that responds, and fails them with a clear message when neither podman nor docker does.

Kubernetes

When the run’s engine is kubernetes the service runs as a Pod on whatever cluster the kubeconfig points at (overridable per service with context and namespace; the namespace must exist). The pod is created with restartPolicy: Never — the runner owns the lifecycle, and a service that dies must be reported as failed, not restarted behind the runner’s back.

Two directions of reachability, provided differently:

Requirements a runner must meet:

volumes and network are errors with this engine: host paths mean nothing on a cluster, and services in one namespace already reach each other by name. Test bodies (container: on a test) cannot use it either — they need the project mounted and run locally. Concurrent runs sharing a namespace would race on the DNS Service name; give them separate namespaces.

Readiness (ready)

At least one of the checks (http, tcp, log, exec) must be set. Every set check is evaluated in every round, and the service is ready only when all of them pass in the same round: no check’s earlier success is remembered, and no ordering between them can be expressed. Rounds are polled every interval (default 1s), starting after delay, until they pass or timeout (default 30s) expires — an expired timeout fails the service and aborts the dependent tests, naming the checks that were still failing.

delay, interval and timeout apply to the group, not to individual checks, and a round lasts as long as its slowest check — the per-attempt caps below are what bound it.

FieldTypeDescription
httpstring or objectReady when the URL answers. String form: URL, any 2xx passes. Object form: url (required), method (default GET), status (default: any 2xx). A single attempt is capped at 5s.
tcpvalue or objectReady when a TCP connect succeeds. Plain form: a port on localhost (number or template string). Object form: host (default localhost), port. A single connect attempt is capped at 2s.
logstring or objectReady when the service output matches a regular expression. String form: pattern, matched on both streams. Object form: pattern (required), stream (stdout, stderr, any; default any).
execstring or objectReady when the shell command exits with code 0 (e.g. pg_isready, redis-cli ping). String form: the command. Object form: command (required), host (default false). A single attempt is capped at 10s. Where it runs is defined below.
delaydurationWait before the first check.
intervaldurationPoll interval. Default 1s.
timeoutdurationOverall deadline. Default 30s.

An exec check on a service with container runs inside that container (the engine’s own exec), because the probe a service ships with lives in its image rather than on the machine running the tests. Such a command therefore addresses the container’s ports, not the published ones. On a service without container there is no inside, and the command runs in a shell on the machine running the tests, in the service’s environment and working directory.

host: true forces the second form for a container service as well, for probes that belong outside — a tool installed on the machine, or a port reachable only through the published mapping. It has no effect on a service without a container.

Either way the command is a shell line (sh -c), so an image without a shell can only be probed with host: true or a check that does not enter it. A non-zero exit means not ready and is retried; only an expired timeout fails the service. The command’s own output is not part of the service’s recorded log.

Stopping (stop)

FieldTypeDescription
signalstringSignal sent to the process (group), SIG* names only. Default SIGTERM.
timeoutdurationGrace period before escalating to SIGKILL. Default 10s. Containers and pods are stopped via the engine with the same timeout, rounded up to whole seconds (minimum 1s).
commandstringRun this shell command to stop the service. For a local process it replaces the signal (escalation to SIGKILL still happens if the service outlives the grace period); a container or pod is additionally stopped through its engine afterwards.

Graceful shutdown is guaranteed on normal completion, on failure, and when the user interrupts the runner (first Ctrl+C: graceful stop of tests and services; second Ctrl+C: force kill).

Environment and templates

Tests and services run in an isolated environment: variables of the host (the user’s shell, the CI job) do not leak in. The base environment consists of

Further host variables must be forwarded explicitly with forwardEnv: a list of variable names or patterns where * matches any run of characters — GITHUB_*, MY_TOKEN, or just * for everything. It is available at the top level (applies to the whole run) and on any test (applies to its nested tests). Forwarded values override the runner-provided defaults, so forwarding CI restores the host’s value.

Two host prefixes need no declaration in the document at all, so a suite can be given a value it does not mention: a host variable named TESTFILE_ENV_<NAME> enters the base environment as <NAME>, and TESTFILE_SECRET_<NAME> does the same and additionally registers its value as a secret. The prefix is stripped; the bare prefix with no name is ignored, and an empty value is passed on but never registered for masking. Both are applied after forwardEnv, so they win over a pattern that also matched them, and before the document’s own env. A name carried by both prefixes takes the secret’s value.

On top of that base, the environment of a test/service is built by merging, child over parent:

  1. the base environment described above (plus top-level forwarded vars),
  2. the top level env,
  3. env and forwarded variables of each ancestor test down to the test itself,
  4. the test’s own env.

String values anywhere in the document may contain templates of the form ${{ scope.name }} with these scopes:

ScopeExampleMeaning
env${{ env.HOME }}Value from the merged environment at that test.
ports${{ ports.web }}A resolved named port.
matrix${{ matrix.node }}A matrix variable of the closest expanded ancestor (or the test itself).

A template may carry a default after ||, used when the reference is undefined or empty: ${{ env.PORT || 3000 }}. The default is plain text (optionally single- or double-quoted, quotes are stripped) and may not contain }. Referencing an undefined name without a default is an error at run start. duration values are either plain integers (seconds) or strings like 500ms, 30s, 5m, 1h.

Overrides from the environment

A host variable named TESTFILE_CONFIG_<path> replaces one value in the document, for that run only. The path addresses the document from its root; __ separates the segments, because a single _ occurs in the keys themselves. A segment names a map key or, inside a sequence, a zero-based index that must already exist. Missing map keys along the path are created, so an override can add a block the document does not have.

A segment matches a key exactly, else ignoring case, else with _ in place of -: an environment variable name cannot contain -, and some platforms upper-case the name.

The value is the string it is, unless it is a bare true, false, null or a number without leading zeros, or it starts with [, {, " or ', in which case it is parsed as YAML — which is also how a string that would otherwise be read as one of those is written.

A runner may offer the same overriding through its own interface — the reference runner’s testfile start -c <path>=<value>, where the path is written with . because a command line can hold one. Such an override takes precedence over a variable naming the same path, and both take precedence over the document.

Overrides apply after include and foreach are expanded — variables in the order their names sort, then any the runner was given directly — and the resulting document must still be valid: an override that violates the schema or these rules fails the run. Runners must report which paths were overridden, because the run no longer matches the file as committed; a path set twice is reported once, as whichever won.

Env files

envFile loads dotenv-format files: KEY=VALUE lines, blank lines and # comments, an optional export prefix, single/double quoted values, and ${{ ... }} templates in values. Multiple files load in order; later files win. A missing file is an error.

Precedence, lowest to highest: inherited environment < forwarded host variables < env file(s) < explicit env of the same level. Top-level envFile paths resolve relative to the Testfile; test-level paths resolve relative to the test’s working directory.

Secrets

secrets (top level and per test) names environment variables that hold secrets. Runners must

Values loaded from env files are treated as secrets: runners must mask them in recorded logs (and never write them into run records). Note that the live terminal output is not masked.

Result caching

A test that declares inputs states that its outcome depends only on the matched files and its own configuration. Runners may then skip the test and reuse its previous result, under these rules:

Cache storage is runner-specific (the reference runner uses .testfile/cache.json) and local to one machine: reuse only happens when the same suite runs again on the same working copy. Distributing or restoring a cache across machines (e.g. onto CI runners) is outside this spec; runners that support it must still apply the reuse rules above. Caching is optional runner behavior: a runner that never caches — like one executing the conformance suite — is fully conforming.

Runners may additionally use the inputs declarations for change-based test selection — e.g. the reference runner’s --changed runs only tests whose inputs match a file that differs from a git base branch. That is a selection feature, not result reuse: deselected tests are simply not part of the run, and no cached result is reported for them. Runners that record runs should state why an inputs test ran or was reused (the reference runner’s reason field, see the result format).

Exit code

The runner exits with 0 when the root test passed, 1 when any test failed or a service could not start, and 130 when the run was interrupted.