Skip to content

Getting started

This guide takes you from nothing to a passing suite in seven steps.

Once it passes, read Writing a Suite โ€” it covers the habits that make a suite worth trusting, in particular asserting the effects your application produces rather than only the response it returns.

Prerequisites

  • Docker โ€” tomato starts your test containers
  • Go 1.26+ โ€” only for the go install and from-source methods

1. Install tomato

curl -fsSL https://raw.githubusercontent.com/tomatool/tomato/main/install.sh | sh

Using Go

go install github.com/tomatool/tomato@latest

Using Homebrew

brew install tomatool/tap/tomato

Verify installation

tomato --version

2. Create a project

Create a new project with the init command:

mkdir my-project && cd my-project
tomato init

This creates: - tomato.yml - Main configuration file - features/ - Directory for your feature files

Project layout

my-project/
โ”œโ”€โ”€ tomato.yml           # Main configuration
โ”œโ”€โ”€ features/
โ”‚   โ””โ”€โ”€ example.feature  # Gherkin test files
โ””โ”€โ”€ fixtures/            # Optional: SQL scripts, test data

3. Describe your dependencies

tomato.yml says what your service needs and how the scenarios reach it. The examples on this page test an orders service that stores orders in Postgres and publishes them to Kafka:

version: 2

# Infrastructure tomato starts, named after the software
containers:
  postgres:
    image: postgres:16-alpine
    env: { POSTGRES_USER: test, POSTGRES_PASSWORD: test, POSTGRES_DB: orders }
    ports: ["5432/tcp"]
    wait_for: { type: exec, target: "pg_isready -h 127.0.0.1 -U test" }
  kafka:
    preset: kafka

# Your service
app:
  command: ./orders
  port: 8080
  ready: { type: http, path: /health }
  env:
    DATABASE_URL: "postgres://test:test@{{.postgres.host}}:{{.postgres.port.5432}}/orders"
    KAFKA_BROKERS: "{{.kafka.host}}:{{.kafka.port.9092}}"

# What the scenarios talk to, named <whose>-<role>
resources:
  orders-client:                # calls the orders service
    type: http
    base_url: http://localhost:8080
  orders-db:                    # the orders service's database
    type: postgres
    container: postgres
    database: orders
    options: { user: test, password: test }
  orders-events:                # the topics the orders service uses
    type: kafka
    container: kafka
    options: { topics: [orders.placed, payments] }

features:
  paths: [./features]

Name resources by whose they are and what they do

A step starts with a resource name, and a reader sees the step long before the config. So the name says whose it is and what role it plays:

Role Name Reads as
calls your service orders-client "orders-client" sends "POST" to "/orders"
your service's data orders-db, orders-cache "orders-db" table "orders" has "1" rows
a broker orders-events (Kafka), orders-queue (RabbitMQ) "orders-events" receives from "orders.placed"
stands in for a service you call stripe-stub, catalog-stub "stripe-stub" received "POST" "/v1/charges"
object storage invoices-storage "invoices-storage" object "โ€ฆ" should exist

Not api, db, mock or http: those say what kind of thing it is, not whose. Naming resources has the full convention.

4. Write scenarios

Features, scenarios and steps

# One feature file per capability of your service
Feature: Placing orders

  # The state every scenario below starts from
  Background:
    Given "orders-db" table "customers" has values:
      | id | email           |
      | 7  | ada@example.com |

  # One behaviour, named as a fact
  Scenario: A placed order is stored and announced
    # When: the one action
    When "orders-client" sends "POST" to "/orders" with json:
      """
      {"customer_id": 7, "sku": "TMT-01", "qty": 2}
      """
    # Then: what it caused
    Then "orders-client" response status is "201"
    And "orders-db" table "orders" contains:
      | customer_id | sku    | qty | status  |
      | 7           | TMT-01 | 2   | pending |
    And "orders-events" receives from "orders.placed" within "5s"
  • Feature: a file in features/, about one capability of your service.
  • Scenario: one behaviour of that capability, named after the outcome.
  • Background: the starting state every scenario in the file shares. It runs after tomato's reset, before each scenario.
  • Given sets up state, When is the one action, Then checks what it caused.

Principles

1. One scenario, one behaviour. One When. A scenario that also reads the order back and cancels it fails for three reasons, and tells you none of them.

2. Check the effects, not only the response. A 201 says the service didn't crash. The row in the database, the message on the topic and the file in the bucket are what the rest of your system relies on, so assert them:

Then "orders-client" response status is "201"
And "orders-db" table "orders" has "1" rows
And "orders-events" receives from "orders.placed" within "5s"
# with an s3 resource for the invoices bucket
And "invoices-storage" object "invoices/inv-1.pdf" should exist

3. Split a flow where it crosses a boundary. When the service stores an order, publishes an event, and later consumes another event to mark the order paid, that's two behaviours, so two scenarios: one ends where the service publishes, the next starts where tomato publishes the event the service consumes.

orders-clienttomato sends orders APIyour service orders-dbchecks: pending orders-eventsreads ยท publishes consumeryour service orders-dbchecks: paid tomato drives or checks ityour service one scenario: four reasons to fail, and a wait for the consumer scenario 1: placing an order scenario 2: a payment marks it paid

Tempting: one scenario places the order, waits for the event, publishes the payment and checks the order is paid. It fails for any of four reasons, waits on asynchronous work, and the consumer can't be tested on its own.

Scenario 1 places an order and checks what the service did: the row, and the event on orders.placed. Scenario 2 seeds a pending order, has tomato publish the payment, and checks the service marks it paid. They meet at the topic, and each fails for one reason.

Scenario: Placing an order stores it and announces it
  When "orders-client" sends "POST" to "/orders" with json:
    """
    {"customer_id": 7, "sku": "TMT-01", "qty": 2}
    """
  Then "orders-client" response status is "201"
  And "orders-db" table "orders" has "1" rows
  And "orders-events" receives from "orders.placed" within "5s"

Scenario: A payment marks the order paid
  Given "orders-db" table "orders" has values:
    | id | customer_id | sku    | qty | status  |
    | 1  | 7           | TMT-01 | 2   | pending |
  When "orders-events" publishes json to "payments":
    """
    {"order_id": 1, "status": "succeeded"}
    """
  Then "orders-db" query "SELECT status FROM orders WHERE id = 1" returns within "10s":
    | status |
    | paid   |

4. Build the state you need. tomato empties every database and topic before every scenario. A scenario that needs an order seeds one in its Given or Background; it never relies on another scenario having run.

5. Variations are more scenarios, not a longer one. The out-of-stock case, the invalid request and the provider being down are behaviours too. Give each its own scenario, or a Scenario Outline when only the values change:

Scenario Outline: An invalid order is refused and nothing is stored
  When "orders-client" sends "POST" to "/orders" with json:
    """
    <body>
    """
  Then "orders-client" response status is "400"
  And "orders-db" table "orders" is empty

  Examples:
    | body                           |
    | {"sku": "TMT-01", "qty": 2}    |
    | {"customer_id": 7, "qty": 0}   |

Writing a Suite goes deeper into each of these.

5. Check it before you run it

Before running tests, validate your configuration and feature files:

tomato validate

This checks: - Configuration file syntax and structure - Resource type validity - Container references - Feature file parsing - Step definitions availability

Use --plain for CI environments without interactive output:

tomato validate --plain

6. Run it

Run all tests:

tomato run

Run with specific tags:

tomato run --tags "@smoke"

Run with verbose output:

tomato run -v

7. Watch it run

tomato ui opens a local web page that lists the feature files, shows each scenario's steps with the resource every step runs against, and runs scenarios on demand. It watches the feature files and reloads as they change.

tomato ui

It is a development aid, not part of the stable surface: see Stability.

Pointing tomato at your application

Tomato can also start your application and connect it to test containers:

# tomato.yml
app:
  command: go run ./cmd/server
  port: 8080
  ready:
    type: http
    path: /health
  wait: 5s
  env:
    DATABASE_URL: "postgres://test:test@{{.postgres.host}}:{{.postgres.port.5432}}/test"
    REDIS_URL: "redis://{{.redis.host}}:{{.redis.port.6379}}"

The {{.container.host}} and {{.container.port.<port>}} templates are replaced with the container's address and its mapped host port. The number is the port inside the container, so {{.postgres.port.5432}} resolves to whichever host port Docker mapped 5432 to. A bare {{.postgres.port}} is left untouched.

Where to go next