The tenzir-test harness discovers and runs
integration tests for pipelines, fixtures, and custom runners. Use this page as
a reference for concepts, configuration, and CLI details. For step-by-step
walkthroughs, see the guides for running tests,
writing tests,
creating fixtures, and
adding custom runners.
Install
Section titled “Install”tenzir-test ships as a Python package that requires Python 3.12 or later.
Install it with uv (or pip) and verify the
console script:
uv add tenzir-testuvx tenzir-test --helpCore concepts
Section titled “Core concepts”- Project root – Directory passed to
--root; typically containsfixtures/,inputs/,runners/, andtests/. - Mode – Auto-detected as project or package. A
package.yamlin the current directory (or its parent when you run from<package>/tests) switches to package mode. - Library – A root that contains multiple packages (each with a
package.yamland its owntests/). The harness can discover all packages under such a library root and run their suites in one invocation. Use--package-dirsto load packages so their operators can cross-import. - Test – Any supported file under
tests/; frontmatter controls execution. - Runner – Named strategy that executes a test (
tenzir,python, custom entries). - Fixture – Reusable environment provider registered under
fixtures/and requested via frontmatter. - Suite – Directory-owned group of tests that share fixtures. Declare it
with
suite:in atest.yaml; all descendants join automatically. Members run sequentially by default, or concurrently whenmode: parallelis set. - Input – Data accessed with
TENZIR_INPUTS; defaults to<root>/inputsbut you can override it per directory or per test with aninputs:setting. The harness also supports inline inputs viaTENZIR_INPUTfor test-specific data files. - Stdin – Content piped to the test process via a
.stdinfile placed next to the test. The harness exposes the file path viaTENZIR_STDINand automatically pipes its content to the subprocess stdin. - Scratch directory – Ephemeral workspace exposed as
TENZIR_TMP_DIRduring each test run. - Artifact / Baseline – Runner output persisted next to the test; regenerate
with
--update. - Configuration sources – Frontmatter plus inherited
test.yamlfiles;tenzir.yamlstill configures the Tenzir binary.
A typical project layout looks like this:
project-root/├── fixtures/│ └── __init__.py├── inputs/│ └── sample.ndjson├── runners/│ └── __init__.py└── tests/ ├── alerts/ │ ├── sample.tql │ └── sample.txt ├── parsing/ │ ├── csv.input # Inline input for this test │ ├── csv.tql │ └── csv.txt ├── shell/ │ ├── echo.sh │ ├── echo.stdin # Stdin content piped to the test │ └── echo.txt └── python/ └── quick-check.pyFor a package layout (with package.yaml), the structure may look like:
my-package/├── package.yaml├── operators/│ └── custom-op.tql├── pipelines/│ └── smoke.tql└── tests/ ├── inputs/ │ └── sample.ndjson ├── fixtures/ │ └── __init__.py ├── runners/ │ └── __init__.py └── pipelines/ ├── custom-op.tql └── custom-op.txtExecution modes and packages
Section titled “Execution modes and packages”- Mode resolution:
--rootwithtests/→ project mode.--root(or its parent when namedtests) with apackage.yaml→ package mode.
- In package mode the harness exposes:
TENZIR_PACKAGE_ROOT– Absolute package directory.TENZIR_INPUTS–<package>/tests/inputs/unless a directorytest.yamlor the test frontmatter overrides it.--package-dirs=<package>– Passed automatically to thetenzirbinary.
- Without a manifest the harness stays in project mode, recursively
discovers tests under
tests/, and applies global fixtures, runners, and inputs.
CLI reference
Section titled “CLI reference”Run the tests from the project root:
uvx tenzir-testUseful options:
--update: Rewrite reference artifacts next to each test.--purge: Remove generated artifacts (diffs, text outputs) from previous runs.--jobs N: Control concurrency (4 * CPU coresby default).--coverageand--coverage-source-dir: Enable LLVM coverage.-k,--keep: Preserve per-test scratch directories instead of deleting them (same as settingTENZIR_KEEP_TMP_DIRS=1).--package-dirs <path>: Extra package directories for Tenzir binaries. Repeatable and accepts comma-separated lists. Entries merge with anypackage-dirs:declared in directorytest.yamlfiles, then get normalized, de-duplicated, and exported viaTENZIR_PACKAGE_DIRS.--debug: Emit framework-level diagnostics (fixture lifecycle, discovery notes, comparison targets, etc.) and automatically enable verbose output so you see all test results (pass/skip/fail) instead of only failures. The same mode is available viaTENZIR_TEST_DEBUG=1.--summary: Print the tabular breakdown and failure tree after each project.--diff/--no-diff: Toggle unified diff output for failed comparisons. Diffs are shown by default; disable them when you only need aggregated statistics.--diff-stat/--no-diff-stat: Show (or suppress) the per-file change counter, which summarises additions and deletions even when the diff body is hidden.-p,--passthrough: Stream raw stdout/stderr to the terminal instead of comparing against reference artifacts. The harness forces single-job execution (overriding--jobswhen necessary) and ignores--updatewhile passthrough is active. Passthrough mode automatically enables verbose output.-v,--verbose: Print individual test results as they complete. By default (quiet mode) the harness only shows failures and a compact summary, significantly reducing noise for large test suites. Verbose mode displays passing and skipped tests alongside failures. Use--summarytogether with--verboseto include the tree summary at the end of the run.--run-skipped: Run all skipped tests unconditionally. Both static skips (skip: reason) and conditional skips (skip: {on: fixture-unavailable}) are bypassed. When a fixture raisesFixtureUnavailableand--run-skippedis active, the exception propagates as a hard failure instead of being caught. This is the sledgehammer approach---use it when you want to force every skipped test to execute regardless of its skip reason.--run-skipped-reason: Selectively run skipped tests whose reason matches a substring or glob pattern. Bare strings match as substrings; patterns containing*,?, or[use fnmatch syntax---the same matching semantics as--match. Repeatable; a test runs if its skip reason matches any provided pattern. The match applies to the final displayed reason, including thefixture unavailable:prefix for conditional skips. When both--run-skippedand--run-skipped-reasonare provided,--run-skippedtakes precedence and all skipped tests run. When no skipped tests match the reason filters, the harness prints a diagnostic message.-a,--all-projects: Run the root project together with any satellites provided on the command line.-m,--match: Select tests whose relative path matches a substring or glob pattern. Bare strings (without*,?, or[) match as substrings, so-m mysqlselects any test with “mysql” anywhere in its path. Patterns containing glob metacharacters use fnmatch syntax. Repeatable; tests matching any pattern are selected. When combined with positional TEST paths, only tests matching both are run (intersection).--fixture: Activate fixtures in standalone foreground mode without running tests. Repeatable. Accepts bare names (--fixture mysql) or YAML mapping specs (--fixture 'kafka: {port: 9092}'). When provided, positional TEST arguments are not allowed. See the run fixtures guide.
Set TENZIR_TEST_DEBUG=1 in CI when you want the same diagnostics without
passing --debug on the command line.
Python API
Section titled “Python API”The harness is also available as a typed Python library. Import tenzir_test
and call execute() when you need to run scenarios from automation or another
tool without shelling out to the CLI:
from pathlib import Path
from tenzir_test import ExecutionResult, execute
result: ExecutionResult = execute(tests=[Path("tests/pipeline.tql")])if result.exit_code: # propagate non-zero status to your orchestration layer raise SystemExit(result.exit_code)for project in result.project_results: print(project.selection.root, project.summary.total)The helper mirrors the CLI options but returns an ExecutionResult with
aggregated Summary objects and metadata you can inspect or serialize. Errors
surface as HarnessError exceptions so callers can control reporting and retry
logic.
Selections
Section titled “Selections”A selection is the ordered list of positional paths you pass after the CLI flags. Each element can point to a single test file, a directory, or an entire project. The harness resolves every element relative to the current working directory first and then relative to the root project. How you shape the selection determines which projects run:
- No positional arguments → run every test in the root project.
- Paths inside the root project → run only those targets (plus any explicitly named satellites).
- Paths that resolve to satellite projects → run those satellites, skipping the root unless you also request it.
Use --all-projects when you want the root project to execute alongside a
selection that only names satellites. This keeps the CLI predictable: the
selection lists the exact satellites you care about, and the flag opts the root
back in without duplicating its path on the command line.
Suites
Section titled “Suites”Suites let you run several tests under one shared fixture lifecycle. Declare a
suite in a directory-level test.yaml; the definition applies to every test
under that directory, including nested subdirectories.
The suite key accepts a plain string or a mapping with name and mode:
# tests/http/test.yaml — sequential (default)suite: smoke-httpfixtures: [http]timeout: 45The mapping form adds a mode field:
# tests/pubsub/test.yaml — parallel executionsuite: name: parallel-pubsub mode: parallelfixtures: [node]timeout: 30The mode field controls how members execute:
sequential(default): members run one after another in lexicographic order.parallel: all members run concurrently on separate threads while sharing the same fixture lifecycle. Fixture assertions are serialized to maintain correctness. Use this when tests are independent and can safely share fixtures at the same time.
The plain string form (suite: smoke-http) is equivalent to
suite: {name: smoke-http, mode: sequential}.
Key rules:
- Suites are directory-owned. Once a
test.yamlsetssuite, all descendants belong to that suite. Put tests that should remain independent outside the suite directory or in a sibling directory with a different suite. - Per-test frontmatter may not declare
suite. - Suite members inherit the directory defaults and can still override most keys
on a per-file basis. The exceptions are
fixturesandretry, which must be defined at the directory level once a suite is active so every member agrees on the shared lifecycle. Outside suites you can still set those keys directly in frontmatter. - Each suite occupies a single worker. Different suites (and standalone tests)
can run in parallel when
--jobsallows it. - The CLI executes all suites before any remaining standalone tests so shared fixtures start and stop predictably.
- Run the directory that defines the suite (for example
tenzir-test tests/http) when you want to focus on it. Selecting an individual member now raises an error so every run exercises the full lifecycle and shared fixtures stay in sync.
Inputs
Section titled “Inputs”Tests access input data through the TENZIR_INPUTS environment variable. By
default this points to <root>/inputs or <package>/tests/inputs/ in package
mode. The harness supports two additional mechanisms for placing inputs closer
to the tests that use them.
Inline inputs
Section titled “Inline inputs”Place a .input file next to a test to provide test-specific input data. The
harness exposes the file path via TENZIR_INPUT (singular):
tests/parsing/ parse-csv.input # Input data for this test parse-csv.tql # Test file parse-csv.txt # Expected output baselineAccess the inline input in TQL:
from_file env("TENZIR_INPUT")read_csvOr in a shell script:
cat "$TENZIR_INPUT" | tenzir 'read_csv'The harness sets TENZIR_INPUT only when a matching .input file exists. Tests
can use both TENZIR_INPUT and TENZIR_INPUTS together when they need a
test-specific file plus access to shared data.
Local inputs directories
Section titled “Local inputs directories”Place an inputs/ directory at any level in the test hierarchy to provide
shared inputs for tests in that subtree. The harness walks up from each test
directory and uses the nearest inputs/ directory it finds:
tests/ network/ inputs/ # Shared inputs for network tests sample.pcap flows.ndjson tcp/ analysis.tql # env("TENZIR_INPUTS") → ../inputs/ analysis.txt udp/ stats.tql stats.txtinputs/ # Global inputs (fallback) common.jsonResolution hierarchy for TENZIR_INPUTS:
inputs:override in test frontmatter ortest.yaml(highest priority)- Nearest
inputs/directory walking up from the test directory - Package-level
tests/inputs/directory - Project-level
inputs/directory (fallback)
When multiple inputs/ directories exist in the hierarchy, the nearest one
shadows the others. This keeps the mental model simple: move inputs closer to
the tests that use them without worrying about inheritance.
Stdin inputs
Section titled “Stdin inputs”Place a .stdin file next to a test to provide content that the harness pipes
to the subprocess stdin. For TQL tests, this lets pipelines start with a parser
directly as an alternative to using .input files with from_file:
tests/parsing/ csv.stdin # CSV data piped to stdin csv.tql # Test file starting with read_csv csv.txt # Expected output baselineThe TQL test reads directly from stdin:
read_csvsort nameFor shell scripts, the same mechanism applies:
tests/shell/ echo.stdin # Content piped to stdin echo.sh # Test file echo.txt # Expected output baseline#!/bin/shcatThe harness pipes the contents of the .stdin file automatically. Tests can
combine .stdin with .input when they need both stdin content and a
test-specific input file:
tests/shell/ process.stdin # Content piped to stdin process.input # Input file accessible via TENZIR_INPUT process.sh # Test file process.txt # Expected output baselineA script using both mechanisms:
#!/bin/shecho "from stdin:"catecho "from TENZIR_INPUT:"cat "$TENZIR_INPUT"The harness sets TENZIR_STDIN only when a matching .stdin file exists. TQL
tests can also combine both mechanisms—start with a parser for stdin data while
using env("TENZIR_INPUT") to reference additional files.
Run a subset of tests
Section titled “Run a subset of tests”Pass individual files or directories to run specific tests:
uvx tenzir-test tests/alerts/high-severity.tqlYou can list multiple paths in a single invocation. tenzir-test wires every
argument into the same runner and fixture registry, so you can mix scenarios
from the project and external checkouts:
uvx tenzir-test tests/alerts ../contrib/plugins/*/testsFilter tests by pattern
Section titled “Filter tests by pattern”Use -m/--match to select tests by matching against their relative path.
Bare strings default to substring matching, so you can write short keywords
without glob syntax:
uvx tenzir-test -m mysql # runs every test with "mysql" in its pathuvx tenzir-test -m connect # runs every test with "connect" in its pathPatterns containing glob metacharacters (*, ?, [) still use fnmatch
syntax, which is fully backwards-compatible:
uvx tenzir-test -m '*mysql*' # equivalent to -m mysqluvx tenzir-test -m 'tests/*/connect.tql' # glob with wildcardRepeat the flag to match multiple patterns (logical OR):
uvx tenzir-test -m context -m create # tests matching either substringWhen you combine positional TEST paths with -m patterns, the harness runs
only tests that satisfy both constraints (intersection):
uvx tenzir-test tests/integrations/ -m mysql # only mysql tests under integrations/If a matched test belongs to a suite (configured via test.yaml), all tests in
that suite are included automatically so the shared fixture lifecycle stays
intact.
Run multiple projects with one command
Section titled “Run multiple projects with one command”Pass additional project directories after --root to execute several projects
in one go. Include --all-projects so the root executes next to its satellites.
The directory given to --root acts as the root project; all other
directories are treated as satellites:
uvx tenzir-test --root example-project --all-projects example-satelliteThe harness prints a project listing before execution that identifies each project with a marker and path:
i found 3 projectsi ■ testi □ ../contrib/plugins/context/testi □ ../contrib/plugins/packages/testMarker semantics:
- Filled markers indicate the root project; empty markers indicate satellites.
- Squares (■ □) represent regular projects; circles (● ○) represent packages or libraries.
Satellite paths display relative to the root project, making projects with identical directory names distinguishable.
Key rules:
- The root project provides the baseline configuration (fixtures, runners,
test.yamldefaults, inputs). Satellites layer their own fixtures and runners on top; duplicate names raise an error so conflicts surface early. - Paths printed in the CLI summary are relative to the working directory. The harness announces each project before running it and lists the runner mix per project for quick insight.
- You can target subsets inside each project with additional positional
arguments (
tenzir-test --root main --all-projects secondary tests/smoke). When you skip--rootentirely and only list satellite directories, the harness runs those satellites in isolation. - Satellites keep their own
tests/,inputs/,fixtures/, andrunners/folders. A root project can host shared assets that satellites reuse without duplication—for example, the example repository includes anexample-satellite/directory that consumes thexxdrunner exported by the root project while defining a satellite-specific fixture.
To regenerate baselines while targeting a specific binary and project root:
TENZIR_BINARY=/opt/tenzir/bin/tenzir \TENZIR_NODE_BINARY=/opt/tenzir/bin/tenzir-node \uvx tenzir-test --root tests --updateRunners
Section titled “Runners”| Runner | Command/behavior | Input extension | Artifact |
|---|---|---|---|
tenzir | tenzir -f <test> | .tql | .txt |
python | Execute with the active Python runtime | .py | .txt |
shell | sh -eu <test> via the harness helper | .sh | varies |
Selection flow:
- The harness chooses the first registered runner that claimed the file extension.
- Default suffix mapping applies when no runner explicitly claims an extension:
.tql → tenzir,.py → python,.sh → shell. - A
runner: <name>frontmatter entry overrides the automatic choice. - If no runner claims the extension and none is specified in frontmatter, the harness fails with an error instead of guessing.
Shell runner
Section titled “Shell runner”Place scripts (for example under tests/shell/) with the .sh suffix to run
them under bash -eu via the shell runner. The harness also prepends
<root>/_shell to PATH so project-specific helper binaries become
discoverable. The runner captures stdout and stderr (like 2>&1) and compares
the combined output with <test>.txt; run tenzir-test --update path/to/test.sh
when you need to refresh the baseline.
Register custom runners in runners/__init__.py via
tenzir_test.runners.register() or the @tenzir_test.runners.startup()
decorator. Use replace=True to override a bundled runner or
register_alias() to publish alternate names.
The runner guide contains a full example
(XxdRunner).
Passthrough-aware subprocesses
Section titled “Passthrough-aware subprocesses”When passthrough mode is active the harness streams stdout/stderr directly to
the terminal and skips reference comparisons. Runner implementations can
respect this automatically by spawning processes through
tenzir_test.run.run_subprocess(...). The helper captures output when the
harness needs it and inherits the parent descriptors otherwise. Pass
force_capture=True when your runner must collect stdout even in passthrough
mode. If you need to branch on the current behavior, call
tenzir_test.run.get_harness_mode() or tenzir_test.run.is_passthrough_enabled().
The harness cycles between three internal modes:
HarnessMode.COMPARE– default behavior; compare actual output with stored baselines.HarnessMode.UPDATE– engaged when you pass--update; runners should overwrite reference files.HarnessMode.PASSTHROUGH– enabled via-p/--passthrough; stream output directly without touching baselines.
get_harness_mode() returns the current enum value so custom runners can adapt logic if needed.
Configuration and frontmatter
Section titled “Configuration and frontmatter”tenzir-test merges configuration sources in this order (later wins):
- Project defaults (
test.yamlfiles, applied per directory). - Per-test frontmatter (YAML for
.tql/.xxd,# key: valuecomments for Python and shell scripts).
Common frontmatter keys:
| Key | Type | Default | Description |
|---|---|---|---|
runner | string | by suffix | Runner name (tenzir, python, shell, custom). |
fixtures | list | [] | Requested fixtures. Accepts bare names and structured options mappings. |
timeout | integer (s) | 30 | Command timeout. (--coverage multiplies it by five.) |
error | boolean | false | Expect a non-zero exit code. |
skip | string or dict | unset | Mark tests as skipped. See skip configuration. |
requires | mapping | unset | Capability requirements. See capability requirements. Directory-level only. |
inputs | string | project | Override TENZIR_INPUTS for this directory or test. |
assertions | mapping | {} | Post-test assertion payloads. See assertions. |
retry | integer | 1 | Total attempt budget for flaky tests (see below). |
package-dirs | list of strings | inherit | Directory-only; extra packages merged with CLI --package-dirs. |
test.yaml files accept the same keys and apply recursively to child
directories. A relative inputs: value resolves against the file that defines
it, so inputs: ../data inside tests/alerts/test.yaml points at
tests/data/. Frontmatter values follow the same rule and win over directory
defaults. Adjacent tenzir.yaml files still configure the Tenzir binary; the
harness appends --config=<file> automatically. The lookup keeps working even
when you point the CLI at extra directories on the command line.
retry represents the total number of attempts the harness should make before
declaring the test failed. Intermediate attempts stay quiet; the final outcome
line includes attempts=N/M whenever the budget exceeds one. Keep the value
small and treat it as a temporary guardrail while you fix the underlying
flakiness.
Skip configuration
Section titled “Skip configuration”The skip key supports two forms:
String form marks the test (or every test in the directory) as unconditionally skipped. The value is the reason:
skip: "pending upstream fix"Structured form conditionally skips tests when a runtime condition occurs. Use this when the suite depends on an external resource that may not be available in every environment:
skip: on: fixture-unavailableThe on field accepts the following values:
fixture-unavailable— skip when a fixture raisesFixtureUnavailableduring initialization. See Fixture unavailability.capability-unavailable— skip when a required capability (declared viarequires) is missing from the runtime environment.
You can combine multiple conditions by passing a list:
skip: on: - fixture-unavailable - capability-unavailableWhen the triggering condition occurs and the suite carries the matching on
value, all tests in the suite are marked as skipped with exit code 0. Without
the opt-in configuration the exception propagates normally and causes a test
failure.
The optional reason field provides additional context that is combined with
the condition message in the skip output.
Capability requirements
Section titled “Capability requirements”The requires key declares capabilities that must be present for a suite to
run. It is only allowed in directory-level test.yaml files. Currently, the
only supported category is operators:
suite: gcs-integrationrequires: operators: [from_gcs, to_gcs]skip: on: capability-unavailable reason: requires GCS operatorsBefore running the suite, the harness asks each runner to check the listed
requirements. If any are missing and the suite carries
skip: {on: capability-unavailable}, the tests are skipped. Without the skip
opt-in, missing capabilities cause a hard failure with a message listing the
missing entries.
Capability probes are runner-aware. Each runner implements its own
check_requirements method. For example, the built-in TQL runner checks
operators by querying plugins | where name == "<operator>". Custom runners
can override this method to probe their own environment. Runners that do not
implement capability probes report all requirement categories as unsupported,
which raises a hard error.
Tenzir configuration files
Section titled “Tenzir configuration files”- The harness inspects the directory that owns each test. If it finds
tenzir.yaml, it appends--config=<path>to every invocation of the bundledtenzir/tql/diffrunners. The path also seedsTENZIR_CONFIGunless you set that variable yourself. Custom runners that call the Tenzir binary should either userun.get_test_env_and_config_args(test)or honour the exported environment variables explicitly. - The built-in
nodefixture uses the same discovery process and startstenzir-nodefrom the directory that owns the test file, so relative paths insidetenzir-node.yamlresolve against the test location. See the built-in node fixture section for precedence rules. - This lets you keep one config for CLI-driven scenarios while passing a different config to the embedded node, for example to tweak endpoints or data directories independently.
Fixtures
Section titled “Fixtures”Declaring fixtures
Section titled “Declaring fixtures”-
List fixture names in frontmatter (
fixtures: [node, http]). Entries can be bare strings or single-key mappings with structured options:# Bare names (backward compatible)fixtures: [node, http]# With structured optionsfixtures:- node:tls: trueport: 8443- http# Mixed formfixtures:- node:tls: true- httpImporting the project
fixturespackage is enough to register custom fixtures thanks to the side effects infixtures/__init__.py. -
The harness encodes requests in
TENZIR_TEST_FIXTURESand exposes helper APIs intenzir_test.fixtures:fixtures()– Read-only view of active fixtures. Attribute access is supported, e.g.fixtures().nodereturnsTrueif the fixture was requested and raisesAttributeErrorotherwise.acquire_fixture("name")– Manual controller for the named fixture. Use it as a context manager for automaticstart()/stop()or call those methods explicitly to interleave lifecycle steps and optional hooks (for examplekill()orrestart()).require("name")– Assert that a fixture was requested.Executor()– Convenience wrapper that runs Tenzir commands with resolved binaries and timeout budget.
Example use from a Python helper:
from tenzir_test.fixtures import Executor
executor = Executor()result = executor.run("from_file 'inputs/events.ndjson' | where severity >= 5\n")assert result.returncode == 0Built-in node fixture
Section titled “Built-in node fixture”- Request the fixture with
fixtures: [node]; the harness will starttenzir-nodewith the binaries discovered for the current test. - Configuration precedence:
TENZIR_NODE_CONFIGin the environment.- A
tenzir-node.yamlplaced next to the test file (exported automatically). - The Tenzir defaults (no config file).
- The node process inherits the test directory as its current working
directory, letting
tenzir-node.yamlreference files with relative paths (for examplestate/orschemas/). - Each controller reuses its state and cache directories across
start()/stop()cycles. By default they live under the per-test scratch directory (TENZIR_TMP_DIR/tenzir-node-*) and are removed once the fixture context ends. Starting a fresh controller (for example in another test run) yields a brand-new workspace. - The fixture reuses other inherited arguments (for example
--package-dirs=…) but replaces any existing--config=flag so the node process always honours the chosen configuration file. - Tests can read
TENZIR_NODE_CLIENT_ENDPOINT,TENZIR_NODE_CLIENT_BINARY,TENZIR_NODE_CLIENT_TIMEOUT,TENZIR_NODE_STATE_DIRECTORY, andTENZIR_NODE_CACHE_DIRECTORYfrom the environment to connect to the spawned node and inspect its working tree. - Pipelines launched by the bundled Tenzir runners automatically receive
--endpoint=<value>when this fixture is active, so they talk to the transient node without additional wiring. - CLI and node configuration are independent: configure the CLI with
tenzir.yamland drop atenzir-node.yaml(or setTENZIR_NODE_CONFIG) only when the node needs custom settings. - When
tenzir-nodefails to start, the fixture reports the exit code and stderr output, making it easier to diagnose startup failures.
Built-in docker-compose fixture
Section titled “Built-in docker-compose fixture”- Request the fixture with
fixtures: [docker-compose]or pass structured options intest.yamlor frontmatter. The fixture manages Docker Compose services for the duration of the test or suite. - The fixture requires
docker compose(v2) on the host. When Docker Compose is not available, it raisesFixtureUnavailableso tests can skip gracefully viaskip: {on: fixture-unavailable}. - Configuration uses three nested dataclasses:
DockerComposeOptions(top level),DockerComposeWaitOptions(readiness polling), andDockerComposeDownOptions(teardown behavior).
Options reference
Section titled “Options reference”Top-level options (DockerComposeOptions):
| Field | Type | Default | Description |
|---|---|---|---|
file | string | "" | Path to the Compose file, resolved relative to the test directory. |
project_name | string | "" | Compose project name. Auto-generated from the test file name when empty. |
profiles | list of string | [] | Compose profiles to activate. |
services | list of string | [] | Services to start. When empty, all services in the Compose file start. |
env_file | string or null | null | Path to an env file passed to docker compose --env-file, resolved relative to the test. |
env | dict | {} | Extra environment variables injected into the Compose process. |
pull | string | "missing" | Image pull policy: missing, always, or never. |
build | boolean | false | Pass --build to docker compose up. |
wait | object | see below | Readiness polling settings. |
down | object | see below | Teardown settings. |
log_on_failure | boolean | true | Collect and attach Docker Compose logs when the fixture fails. |
Wait options (DockerComposeWaitOptions):
| Field | Type | Default | Description |
|---|---|---|---|
timeout_seconds | float | 120.0 | Maximum time to wait for all services to be ready. |
poll_interval_seconds | float | 1.0 | Interval between readiness checks. |
Down options (DockerComposeDownOptions):
| Field | Type | Default | Description |
|---|---|---|---|
volumes | boolean | true | Remove volumes on teardown (--volumes). |
remove_orphans | boolean | true | Remove orphan containers (--remove-orphans). |
timeout_seconds | integer | 20 | Timeout for docker compose down (--timeout). |
Example configuration
Section titled “Example configuration”Minimal configuration in test.yaml:
suite: compose-demofixtures: - docker-compose: file: compose.yamlskip: on: fixture-unavailable reason: requires docker composeFull configuration with all options:
fixtures: - docker-compose: file: compose.yaml project_name: my-project profiles: [integration] services: [redis, postgres] env_file: compose.env env: COMPOSE_PARALLEL_LIMIT: 8 pull: always build: true wait: timeout_seconds: 90 poll_interval_seconds: 2.0 down: volumes: true remove_orphans: true timeout_seconds: 30 log_on_failure: trueReadiness detection
Section titled “Readiness detection”The fixture polls each container until it is ready. If a container defines a
health check, the fixture waits for the healthy status. Otherwise, it falls
back to checking that the container is in the running state. The
wait.timeout_seconds setting controls how long the fixture waits before
raising an error.
Exported environment variables
Section titled “Exported environment variables”Once all services are running, the fixture exports the following variables to the test environment:
| Variable | Description |
|---|---|
DOCKER_COMPOSE_PROVIDER | Always docker. |
DOCKER_COMPOSE_PROJECT_NAME | The Compose project name (explicit or auto-generated). |
DOCKER_COMPOSE_FILE | Absolute path to the resolved Compose file. |
DOCKER_COMPOSE_SERVICE_<NAME>_CONTAINER_ID | Container ID for the service. <NAME> is the uppercased, normalized service name. |
DOCKER_COMPOSE_SERVICE_<NAME>_HOST | Always 127.0.0.1. |
DOCKER_COMPOSE_SERVICE_<NAME>_PORT_<PORT>_<PROTO> | Host-mapped port for a specific container port and protocol (for example _PORT_6379_TCP). |
DOCKER_COMPOSE_SERVICE_<NAME>_PORT | Shorthand for the single published port, set only when the service exposes exactly one port binding. |
Service names are normalized to uppercase with non-alphanumeric characters
replaced by underscores. For a service named redis, the prefix is
DOCKER_COMPOSE_SERVICE_REDIS_.
Teardown
Section titled “Teardown”When the test (or suite) completes, the fixture runs docker compose down with
the configured teardown options. Volumes are removed by default to avoid leaking
state between test runs. The fixture always attempts teardown even when the test
fails.
Registering fixtures
Section titled “Registering fixtures”Implement fixtures in fixtures/ and register them with @tenzir_test.fixture().
Decorate a generator function, yield the environment mapping, and handle cleanup
in a finally block:
from tenzir_test import fixture
@fixture()def http(): server = _start_server() try: yield {"HTTP_FIXTURE_URL": server.url} finally: server.stop()@fixture also accepts regular callables returning dictionaries, context
managers, or FixtureHandle instances for advanced scenarios.
The fixture guide demonstrates an HTTP echo
server that exposes HTTP_FIXTURE_URL and tears down cleanly.
Fixture options
Section titled “Fixture options”Fixtures can declare a frozen dataclass via options= on @fixture(). The
harness constructs a typed instance from frontmatter values:
@dataclass(frozen=True)class HttpOptions: port: int = 0
@fixture(options=HttpOptions)def http() -> Iterator[dict[str, str]]: opts = current_options("http") # HttpOptions instance ...fixtures: - http: port: 9090Bare names (fixtures: [http]) produce the dataclass defaults.
Fixture unavailability
Section titled “Fixture unavailability”Fixtures can signal that they cannot provide their service by raising
FixtureUnavailable during initialization (before yielding). This is useful
when a fixture depends on an external tool or runtime that may not be present
in every environment.
from tenzir_test.fixtures import FixtureUnavailable, fixture
@fixture()def mysql(): if not shutil.which("docker"): raise FixtureUnavailable("docker not found") # ... start container, yield env, cleanup ...By default the exception propagates and causes a test failure. To convert it
into a skip, add a structured skip entry to the suite’s test.yaml:
suite: mysql-integrationfixtures: [mysql]skip: on: fixture-unavailable reason: "needs container runtime"When the fixture raises FixtureUnavailable and the suite carries this
configuration, the harness marks every test in the suite as skipped (exit
code 0) and logs the combined reason. Without the opt-in configuration the
exception surfaces as a regular failure. See
skip configuration for the full syntax of the skip
key.
Container runtime helpers
Section titled “Container runtime helpers”The tenzir_test.fixtures.container_runtime module provides shared building
blocks for fixtures that manage containers directly (without Docker Compose):
runtime detection, detached container startup, readiness polling, and lifecycle
management. The built-in docker-compose fixture uses these helpers internally.
See the create fixtures guide for a step-by-step walkthrough.
Assertions
Section titled “Assertions”The assertions frontmatter key holds post-test validation payloads. Each
top-level key inside assertions identifies an assertion category. The harness
evaluates assertions after the test succeeds but before teardown, so resources
are still live when checks run. A raised exception fails the test.
Fixture assertions
Section titled “Fixture assertions”Fixture assertions let a fixture verify what happened during the test. Declare
payloads under assertions.fixtures.<name>:
assertions: fixtures: http: expected_request: count: 1 method: POST path: /status/not-found body: '{"foo":"bar"}'The harness constructs a typed instance from the fixture’s registered assertions
dataclass and invokes its assert_test hook. Omitting the block skips the hook.
For suite tests the hook runs after each member rather than once at the end.
The fixture guide walks through the full implementation.
Environment variables
Section titled “Environment variables”tenzir-test recognises the following environment variables:
TENZIR_TEST_ROOT– Default test root when--rootis omitted.TENZIR_BINARY/TENZIR_NODE_BINARY– Override binary auto-detection. Supports multi-part commands likeTENZIR_BINARY="uvx tenzir"orTENZIR_NODE_BINARY="docker exec container tenzir-node".TENZIR_INPUTS– Preferred data directory. Defaults to the nearestinputs/directory walking up from the test, falling back to the project-level inputs folder. Reflects anyinputs:override fromtest.yamlor frontmatter.TENZIR_INPUT– Path to the inline input file when a.inputfile exists next to the test. Not set otherwise.TENZIR_STDIN– Path to the stdin file when a.stdinfile exists next to the test. The harness pipes this file’s content to the subprocess stdin. Not set otherwise.TENZIR_KEEP_TMP_DIRS– Keep per-test scratch directories (equivalent to--keep).TENZIR_TEST_DEBUG– Enable debug logging and verbose output (equivalent to--debug).
Binary resolution
Section titled “Binary resolution”The harness automatically detects tenzir and tenzir-node binaries using this
precedence:
TENZIR_BINARY/TENZIR_NODE_BINARYenvironment variable (highest priority)- Local installation found via
PATHlookup (shutil.which) - Fallback to
uvx tenzir/uvx --from tenzir tenzir-nodewhenuvis installed
Most users can run tenzir-test without any configuration. When uv is
installed, the harness automatically uses uvx to fetch and run Tenzir on
demand.
Environment variables support multi-part commands, allowing invocations like
TENZIR_BINARY="uvx tenzir" or TENZIR_BINARY="docker exec node tenzir". The
harness splits these values into argument lists using shell tokenization rules.
Fixtures often publish additional variables (for example
TENZIR_NODE_CLIENT_*, TENZIR_NODE_STATE_DIRECTORY,
TENZIR_NODE_CACHE_DIRECTORY, HTTP_FIXTURE_URL).
During execution the harness also adds transient variables such as
TENZIR_TMP_DIR so tests and fixtures can create temporary artifacts without
polluting the repository. Combine it with --keep (or
TENZIR_KEEP_TMP_DIRS=1) when you need to inspect the generated files after a
run.
Baselines and artifacts
Section titled “Baselines and artifacts”Regenerate reference output whenever behavior changes intentionally:
uvx tenzir-test --update--purge removes stale artifacts (diffs, temporary files). Keep generated
.txt files under version control so future runs can diff against them.
Troubleshooting
Section titled “Troubleshooting”- Missing binaries – The harness auto-detects binaries on
PATHand falls back touvx tenzirwhenuvis installed. If neither is available, set theTENZIR_BINARYandTENZIR_NODE_BINARYenvironment variables to point at your installation. - Unexpected exits – Set
error: truein frontmatter when a non-zero exit is expected. - Skipped tests – Use
skip: reasonto document temporary skips; baseline files can stay empty. For fixture-dependent suites, useskip: {on: fixture-unavailable}so tests skip gracefully when a required tool is missing. For capability-dependent suites, combinerequireswithskip: {on: capability-unavailable}. - Noisy output – Use
--jobs 1to serialize worker logs, and enable--debug(or setTENZIR_TEST_DEBUG=1) when you need to trace comparisons and fixture activity. Note that--debugautomatically enables verbose output.