Configuration reference¶
Complete reference for tomato.yml configuration options.
File structure¶
version: 2 # Required: config version
settings: # Test execution settings
timeout: 5m
fail_fast: false
output: pretty
containers:
reuse: false # keep containers between runs instead of starting fresh
app: # Application under test (optional)
command: ./my-app
port: 8080
ready:
type: http
path: /health
wait: 5s
env:
KEY: value
containers: # Container definitions
name:
image: image:tag
command: [] # Optional startup command (list or string)
env: {}
ports: []
volumes: []
depends_on: []
wait_for: {}
resources: # Resource/handler definitions
name:
type: http|http-server|grpc|grpc-server|postgres|scylladb|cassandra|redis|kafka|rabbitmq|s3|websocket|websocket-server|shell|aws|lifecycle|browser|pdf|network
# aliases: postgresql, http-client, grpc-client, websocket-client, minio
container: container_name
options: {}
hooks: # Lifecycle hooks
before_all: []
after_all: []
before_scenario: []
after_scenario: []
features: # Feature file settings
paths:
- ./features
tags: ""
Settings¶
| Field | Type | Default | Description |
|---|---|---|---|
timeout |
duration | 5m |
Global test timeout |
fail_fast |
bool | false |
Stop on first failure |
output |
string | pretty |
Output formats, comma-separated; see Reports |
profile |
bool | true |
Measure the app and its containers per scenario; see Profile. Off when parallel is above 1 |
Reports¶
output takes one or more comma-separated formats. A format without a path
prints to the console; format:path writes that format to a file. Use this to
feed CI test reports and test-management tools.
| Format | Writes |
|---|---|
pretty |
Human-readable console output (default) |
progress |
One character per step |
junit |
JUnit XML, one testsuite per feature and one testcase per scenario |
cucumber |
Cucumber JSON, the input most Cucumber report tools import |
ctrf |
CTRF JSON, for the ctrf-io GitHub Actions and reporters: one test per scenario with its steps, the failed step's error, its screenshots as attachments and what it cost the app (the profile) in extra. File only |
tomato |
Structured events used by the GitHub Action's PR comment |
Every run also keeps its events in .tomato/runs/<run>/events.log, whatever
output says, so a report can be written for any past run:
tomato report # the last run, as CTRF JSON on stdout
tomato report 216b61f6 -f junit -o reports/junit.xml
tomato report writes ctrf or junit. Its JUnit carries more than the
junit output does: the failed step and its error in <failure>, the steps
in <system-out> with each screenshot as [[ATTACHMENT|path]], which
Jenkins and GitLab show, and the scenario's profile as <properties>. A run
that failed before its scenarios — a container that did not start, an app
that never became ready — is one failed test named after where it failed,
in both formats, so CI shows the run failed rather than zero tests. tomato
ui exports the same files from its Report tab.
Tomato creates the report directories if they don't exist. When --format is
passed on the command line (the GitHub Action passes --format tomato for PR
comments), it replaces the console format but file outputs from output are
still written.
The tomato event format¶
tomato writes one TOMATO_EVENT: line per event, followed by a JSON object
with a type: feature_start, scenario_start, step_start, step_end,
scenario_end, feature_end and summary. A step_end has the step's
status, stepIndex, durationMs and, when it failed, its error text.
Before the first scenario the run says how it is coming up: a phase event
as it enters containers, resources, app and tests; a container event
with the container's name as it is starting, then ready or failed
with durationMs; and an app event as the app is starting, listening
(with its url), ready after its health check, or failed, with
durationMs from when it was started. tomato ui plays these as the first
two acts of a run's film.
A run that fails before its scenarios start writes a run_error instead: its
phase (config, containers, resources or app), the error, and, when
the app failed to start or its health check failed, the last lines it logged in
logs. The GitHub Action shows it in the pull request comment and the job
summary, so a run that never got to a scenario doesn't read as 0 passed.
{"type": "run_error", "status": "failed", "phase": "app",
"error": "failed to start application: app not ready: app exited with exit status 1 300ms after it started, before it was ready",
"logs": ["{\"level\":\"fatal\",\"message\":\"missing config\"}"]}
When a step failed because a value it compared did not match (stdout is:,
response body is:, response json contains:, a Kafka, RabbitMQ or WebSocket
message), its step_end also has a mismatch object with both sides, which
tomato ui draws as a diff:
{"type": "step_end", "status": "failed", "error": "at status: expected created, got pending",
"mismatch": {"what": "response JSON", "mode": "subset",
"expected": "{\"status\": \"created\"}",
"actual": "{\"id\": 1, \"status\": \"pending\"}"}}
| Field | Meaning |
|---|---|
what |
What was compared, such as stdout, body, message or response JSON |
expected |
The expected value as text, after variables are replaced |
actual |
The actual value as text |
mode |
text: compared as text. json: compared as JSON, every key on both sides. subset: compared as JSON, keys only actual has are allowed |
error is the same text with or without mismatch. The field is left out when
expected and actual together are over 256 KB. The event format is not part
of the stable surface.
In the tomato format, a step_end event carries screenshots when the step
left images behind, as a list of {"resource": "alice-browser", "path": "…/after-apply.png"}:
the browser resource's saves screenshot, and the page when a
step failed. tomato ui reads it to put the picture on the step.
When the run is profiled, a step_end carries samples, the
app's and its containers' readings since the last event that carried them
({"at", "app": {"cpu", "rss", "threads", "fds"}, "containers": {"postgres": {"cpu", "memory"}}}),
and a scenario_end carries profile, what the scenario cost: its start,
end and durationMs, the app's cpuMs, peakRss, rssStart and
rssEnd, each container's cpuMs and peakMemory, the traffic each
network resource saw, and the errors and warnings the app logged.
App configuration¶
Configure your application under test to run with test containers.
app:
# Option 1: Run a command
command: go run ./cmd/server
workdir: ./
# Option 2: Build from Dockerfile
build:
dockerfile: Dockerfile
context: .
# Connection settings
port: 8080
ready:
type: http # http, tcp, or exec
path: /health # For HTTP checks
status: 200 # Expected status (default: 200)
timeout: 30s # Ready check timeout
# Wait after ready check passes
wait: 5s
# Environment variables (supports container templates)
env:
DATABASE_URL: "postgres://test:test@{{.postgres.host}}:{{.postgres.port.5432}}/test"
REDIS_URL: "redis://{{.redis.host}}:{{.redis.port.6379}}"
Port already in use
In command mode, tomato checks port before starting the app. If another
process is already listening there (a local make run, a leftover test
run), tomato fails instead of starting: otherwise the ready check would
pass against the other process and the tests would hit the wrong app.
In command mode the app inherits tomato's environment, plus env. Some
resources the app depends on while it starts are set up before it and add to
that environment too: the aws resource points the app's AWS SDK at its
STS and removes AWS_ACCESS_KEY_ID and friends inherited from your shell. The
app's own env still wins over anything a resource sets.
The app runs in its own process group, and stopping it stops the whole group,
so command: go run ./cmd/server (where go does not pass signals on) does not
leave the server running and holding the port.
Template variables¶
In app.env, you can use templates to inject container addresses:
| Template | Description |
|---|---|
{{.container_name.host}} |
Container hostname (localhost in command mode, the container's DNS name in container mode) |
{{.container_name.port.NNNN}} |
Host port that NNNN, the port inside the container, is mapped to |
{{.resource_name.url}} |
Base URL of an http-server resource |
NNNN is required. A bare {{.container_name.port}} is left in the value
verbatim, so the application receives the literal template text.
Example with PostgreSQL:
containers:
postgres:
image: postgres:15
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- "5432"
app:
command: ./my-app
env:
# These are resolved at runtime when containers start
DB_HOST: "{{.postgres.host}}"
DB_PORT: "{{.postgres.port.5432}}"
DATABASE_URL: "postgres://testuser:testpass@{{.postgres.host}}:{{.postgres.port.5432}}/testdb"
Note: The container field in resource definitions automatically handles host/port resolution - you don't need to specify connection strings manually for resources.
Containers¶
Define Docker containers for your test infrastructure.
containers:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports:
- "5432"
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
depends_on:
- other_container
wait_for:
type: port
target: "5432"
timeout: 30s
volumes are source:target[:mode]. A source path (./x, ../x, /x, ~/x)
is bound into the container, relative paths resolved against the directory of
tomato.yml; a bare name is a Docker volume. build (context, dockerfile)
builds the image instead of pulling one, with context relative to tomato.yml
too. The image is named tomato-<container> and kept, so the next run rebuilds
from cache into the same image. Each container's output is written to .tomato/runs/<run>/container-<name>.log
for as long as it runs, including when it fails to start.
Presets¶
A preset is a container tomato knows how to run. It fills in the image, env,
ports and wait strategy; anything the entry sets itself (an env key, image,
wait_for) wins.
| Preset | What it runs |
|---|---|
chromium |
A headless Chromium for browser resources. Pages it opens on localhost reach the app on your machine, through a tunnel to each port the suite uses there. See Browser. |
verapdf |
A veraPDF validation service for the pdf resource's conforms to step: PDF/UA and PDF/A, with the rules a document breaks in the failure. See PDF. |
kafka |
A single-node KRaft broker, reachable from the host and from other containers. With auth: aws_msk_iam, a second listener speaks SASL AWS_MSK_IAM like MSK's IAM port. See Kafka. |
redis |
A Redis server. With cluster: true it runs in cluster mode, one node owning every slot; with tls: true it speaks TLS only, with a certificate from a CA made for the run. Together, Redis the way ElastiCache runs it with cluster mode and in-transit encryption. See Redis. |
Resources tomato does not manage¶
tomato resets every resource before every scenario, and that is not
configurable. A resource with no container: is a system tomato did not
start, so resetting it destroys someone's real data. tomato refuses to load
such a config:
resource "app-db" (postgres) has no container, so it points at a system tomato
does not manage — and tomato wipes every resource before every scenario,
which would destroy its data.
Give it a container: so tomato runs it, or, if it genuinely is a throwaway
system, say so:
resources:
app-db:
type: postgres
allow_destructive_reset: true # yes, wipe it before every scenario
options:
host: scratch-db.internal
The acknowledgement stops the refusal, not the warning: tomato validate
still flags it, and every run prints which unmanaged resources it is about to
wipe. Only the stateful types are affected — postgres, redis, kafka,
rabbitmq, s3, scylladb and their aliases. An http-client pointed at a
remote URL holds no data, so it needs nothing.
Container naming and reuse¶
Every container and network tomato creates is named and labelled, so docker ps
says whose they are:
tomato-<project>-<run>-<container> e.g. tomato-9a987f0c-06e8b19f-postgres
tomato-<project>-<run>-net the network they share
<project> is derived from the config file's path, so two projects on one
Docker host never collide; <run> is the run id, so two runs of the same
project can go at once. Labels tomato.managed, tomato.scope, tomato.run
and tomato.role are set on all of them, which is enough to find leftovers:
| Setting | Default | Meaning |
|---|---|---|
settings.containers.reuse |
false |
Keep containers between runs instead of starting fresh ones |
With reuse: true the run id drops out of the names, so a rerun finds what the
last run left behind and skips container startup entirely. Resets still run
before every scenario, so scenarios stay isolated from each other. What you
give up: a container an earlier run corrupted is no longer thrown away, and two
runs of the same project can no longer run at the same time. tomato leaves the
containers running when the suite ends and prints the command to remove them.
Wait strategies¶
| Type | Description | Fields |
|---|---|---|
port |
Wait for port to be ready | target |
log |
Wait for log message | target (regex) |
http |
Wait for HTTP endpoint | path, target (port), method |
exec |
Run command in container | target (command) |
Resetting state¶
Resetting is configured per resource, not per container — see
options.reset_strategy under Resources and on each resource's
page. How often it runs is not configurable: every resource is reset before
every scenario. A scenario that needs a starting state builds it in a
Background, which runs after the reset.
The opt-outs v2.3.0 removed, settings.reset, a resource's reset and
reset_strategy: none, are refused at load with this explanation. A config
written for an earlier release fails to load rather than having a resource it
used to leave alone wiped before every scenario.
Resources¶
Define handlers for test steps. The key is the name steps use to refer to the
resource ("app-client" sends …), so it should say whose it is and what role
it plays: orders-client, stripe-stub, orders-db, not api, mock or
db. See Naming resources.
HTTP¶
| Option | Default | Description |
|---|---|---|
timeout |
30s |
Per-request timeout. For an event stream it bounds only the wait for the response headers; the stream stays open until the scenario closes it, and each receive step's within bounds its wait |
no_redirect |
false |
Return the redirect response instead of following it |
port |
8080 |
Container port the base URL is built from. Only with container |
scheme |
http |
Scheme for the base URL built from container |
health_path |
(none) | Path polled once at startup to check the service answers |
Request headers are set by steps ("app-client" header "Authorization" is "..."), not
here, and are cleared between scenarios.
PostgreSQL¶
resources:
app-db:
type: postgres
container: postgres
database: test
options:
user: test
password: test
# Reset every table in these schemas (if neither this nor tables is set,
# every table in public)
schemas:
- app
- "plugin_*" # any schema whose name matches
# Reset these tables too
tables:
- public.outbox
# Never truncate these tables (added to the defaults below)
exclude:
- countries # reference data seeded by a migration
Reset behavior¶
By default, PostgreSQL resources truncate all tables in the public schema before each scenario. You can control this behavior:
| Option | Description |
|---|---|
schemas |
Truncate every table in these schemas instead of public. A name containing * is a pattern |
tables |
Tables to truncate. Alone, only these are truncated; with schemas, these are truncated as well |
exclude |
Extra tables to never truncate, on top of the defaults: table in any schema, or schema.table |
schemas is for an app that keeps its tables in more than one schema, a schema
per module or per plugin, so the suite doesn't have to list every table and
keep that list in step with the migrations. The tables are looked up in the
catalog at every reset, so a table or a schema a migration adds is reset
without a change to tomato.yml.
A schema named in schemas has to exist when the first scenario starts, which
it does when the app creates it in its migrations at startup; otherwise the
reset fails and says which schema is missing, rather than leaving its tables to
leak state between scenarios. A pattern such as plugin_* may match no schema
at all, since the schemas it stands for come and go: one a scenario creates is
reset from the next scenario on. Quote a pattern in YAML.
Migration history tables are never truncated when the tables are found from a schema, in whichever schema they are, so migration tools still see their migrations as applied:
| Tool | Tables |
|---|---|
| golang-migrate, Rails, sqlx | schema_migrations |
| goose | goose_db_version |
| Flyway | flyway_schema_history |
| Liquibase | databasechangelog, databasechangeloglock |
Reference data that migrations insert (countries, roles, permissions) is
truncated like any other table. Add those tables to exclude so scenarios
don't have to re-seed them.
A table listed in tables is always truncated, even when exclude or the
list above names it. So when tables is set without schemas, exactly those
tables are truncated, and both exclude and the migration-table list are
ignored.
All the tables are truncated in one TRUNCATE ... RESTART IDENTITY CASCADE
statement, so a generated id starts again from 1 in every scenario.
The container field automatically provides the connection details - tomato resolves the container's host and port at runtime.
ScyllaDB / Cassandra¶
resources:
app-db:
type: scylladb # or: cassandra
container: scylla
options:
keyspace: app # created if missing, then used for unqualified tables
schema:
- ./fixtures/schema.cql
exclude:
- countries # reference data kept across scenarios
Every table in the keyspace is truncated before each scenario, except those in
exclude. See ScyllaDB Configuration for all options.
Redis¶
resources:
app-cache:
type: redis
container: redis
options:
db: 0
password: ""
reset_strategy: flush # flush or pattern
reset_pattern: "*" # For pattern strategy
Kafka¶
resources:
app-events:
type: kafka
container: kafka
options:
topics:
- events
- notifications
partitions: 1
replication_factor: 1
reset_strategy: delete_recreate
# Optional: Avro via Confluent Schema Registry
schema_registry:
container: schema-registry # or url: http://localhost:8081
| Field | Default | Description |
|---|---|---|
container |
— | The container tomato starts and connects to |
brokers |
— | Broker addresses, instead of container. A broker tomato does not start needs allow_destructive_reset: true, since its topics are recreated before every scenario |
See Kafka: Avro and Schema Registry.
WebSocket¶
resources:
app-ws-client:
type: websocket
url: ws://localhost:8080/ws
# Or use container
container: app
options:
port: "8080"
path: /ws
protocols:
- graphql-ws
headers:
Authorization: Bearer token
gRPC¶
resources:
app-grpc-client:
type: grpc
address: "localhost:9090" # or: container: myservice
options:
timeout: 30s
The server must register the gRPC reflection service. See gRPC Configuration.
gRPC server¶
resources:
users-stub:
type: grpc-server
options:
port: 9995 # fixed, so app.env can name it
descriptor_set: proto/users.binpb # buf build -o proto/users.binpb
app:
env:
USERS_ADDR: "{{.users-stub.address}}" # localhost:9995
Stubs the gRPC services the app calls. See gRPC Server.
S3¶
resources:
uploads-storage:
type: s3
container: minio
options:
buckets:
- uploads
reset_strategy: purge # purge or delete
See S3 Configuration for MinIO and LocalStack setup.
Shell¶
Hooks¶
Execute actions at different lifecycle points.
hooks:
before_all:
- sql_file: ./fixtures/schema.sql
resource: app-db
- shell: ./scripts/setup.sh
after_all:
- shell: ./scripts/cleanup.sh
before_scenario:
- sql: "DELETE FROM events"
resource: app-db
after_scenario:
- exec: redis-cli FLUSHDB
container: redis
Hook types¶
| Type | Description |
|---|---|
sql |
Execute SQL query |
sql_file |
Execute SQL file |
shell |
Run shell command |
exec |
Run command in container |
Features¶
Configure feature file discovery.
Tag expressions¶
| Expression | Description |
|---|---|
@smoke |
Scenarios tagged with @smoke |
@smoke and @api |
Both tags |
@smoke or @api |
Either tag |
not @slow |
Exclude slow tests |
@smoke and not @wip |
Smoke tests, excluding WIP |
(@smoke or @api) and not @slow |
Parentheses group terms |
not binds tighter than and, which binds tighter than or. godog's legacy
syntax (@smoke && ~@slow, , for or) is still accepted.
Environment variables¶
Use environment variables anywhere in the config:
Only $VAR and ${VAR} are expanded. Shell default syntax such as
${VAR:-15} is not supported: the whole VAR:-15 is read as the variable
name, so the value expands to an empty string. Set the variable, or keep the
default in tomato.yml.