Skip to content

Architecture

This page provides an overview of tomato's internal architecture and how components interact during test execution.

High-level overview

tomato is designed around three core concepts:

  1. Containers - Docker containers managed via Testcontainers
  2. Resources - Abstractions over external services (databases, APIs, message queues)
  3. Handlers - Step definitions that interact with resources

What is in the repository

tomato is a Go program with a web UI, a documentation site, and two things it ships besides the binary. The root reads more clearly once you know which is which, and which of them CI actually checks.

Path What it is Checked by CI
main.go, command/, internal/ The Go program. main.go is the entry point, command/ is one file per CLI command, internal/ is everything else make test, make coverage
ui/ The web UI tomato ui serves: a React app built by Vite with pnpm. pnpm build writes the bundle into command/ui_assets/dist, which the binary embeds, so the bundle is committed too make ui-check fails on a stale bundle
tests/ tomato's integration suite: tomato testing itself against real containers. tests/tomato.yml plus tests/features/*.feature make coverage; every step of every resource must be used by a scenario
docs/ The documentation site, built with mkdocs.yml. docs/resources/ and docs/index.md are generated — the docs workflow overwrites them with tomato docs and README.md before deploying, so edit the generator or the README, never those files regenerated and deployed, not checked for drift
action.yml, install.sh The two shipped deliverables besides the binary: the GitHub Action and the install script no — neither is exercised by a workflow
scripts/ report-coverage.sh, which builds the PR coverage comment and enforces the gates in .coverage-min runs in make coverage
.tomato/, bin/, coverage/ Generated at runtime, all git-ignored —

Where the code lives

Package Responsibility
command/ One file per CLI command, plus the step-docs generator
internal/config tomato.yml parsing, defaults, validation, preset expansion
internal/container Container lifecycle via Testcontainers, wait strategies, logs
internal/apprunner Starting the application under test, app.env templating
internal/resource The contract every resource implements: Handler, step definitions, the variable store
internal/resource/<name> One package per resource type, with its step definitions and nothing else's
internal/registry Builds resources from config and is the only package that knows the full set
internal/jsonmatch Dotted-path lookup and tomato's @ matchers, shared by every resource that asserts on JSON
internal/runner godog wiring, hooks, tag filtering, reset between scenarios
internal/formatter Console, JUnit and Cucumber report output
internal/presets Files copied into preset containers (the MSK IAM jar)
internal/runlog Per-run log directory under .tomato/runs/

Why one package per resource

Each resource package imports internal/resource and nothing from any other resource, so a change to the Kafka steps cannot break the Postgres ones, and a contributor adding steps to one resource reads one directory. go build ./internal/resource/postgres/ compiles that resource alone.

A resource type is registered in exactly one place, handlerFactories in internal/registry/registry.go. That table and canonicalTypes beside it are the only lists of resources in the codebase: the commands ask the registry rather than naming resources themselves.

flowchart TB
    subgraph Config["tomato.yml"]
        C[Containers]
        R[Resources]
        F[Features]
    end

    subgraph Runtime["tomato Runtime"]
        TC[Testcontainers]
        H[Handlers]
        G[Gherkin Parser]
    end

    subgraph External["External Services"]
        DB[(PostgreSQL)]
        REDIS[(Redis)]
        KAFKA[(Kafka)]
        RMQ[(RabbitMQ)]
        S3[(S3)]
        HTTP[HTTP Server]
        GRPC[gRPC Server]
        WS[WebSocket]
    end

    C --> TC
    R --> H
    F --> G

    TC --> DB
    TC --> REDIS
    TC --> KAFKA
    TC --> RMQ
    TC --> S3

    H --> DB
    H --> REDIS
    H --> KAFKA
    H --> RMQ
    H --> S3
    H --> HTTP
    H --> GRPC
    H --> WS

    G --> H

Test execution flow

When you run tomato run, the following sequence occurs:

sequenceDiagram
    participant User
    participant CLI
    participant Config
    participant TC as Testcontainers
    participant App as Application
    participant Handler
    participant Godog

    User->>CLI: tomato run
    CLI->>Config: Parse tomato.yml

    rect rgb(40, 40, 40)
        Note over TC: Container Setup
        Config->>TC: Start containers
        TC-->>TC: Wait for readiness
        TC-->>Config: Container endpoints
    end

    rect rgb(40, 40, 40)
        Note over Handler: Resources the app depends on
        Config->>Handler: Init app-env providers (aws STS)
        Handler-->>Config: Environment for the app
    end

    rect rgb(40, 40, 40)
        Note over App: App Setup (optional)
        Config->>App: Start application
        App-->>App: Wait for ready check
    end

    rect rgb(40, 40, 40)
        Note over Handler: Resource Setup
        Config->>Handler: Initialize remaining handlers
        Handler-->>Handler: Connect to services
    end

    rect rgb(40, 40, 40)
        Note over Godog: Test Execution
        CLI->>Godog: Run features
        loop Each Scenario
            Godog->>Handler: Reset resources
            loop Each Step
                Godog->>Handler: Execute step
                Handler-->>Godog: Result
            end
        end
    end

    Godog-->>CLI: Test results
    CLI->>App: Stop application
    CLI->>Handler: Close resources
    CLI->>TC: Stop containers
    CLI-->>User: Exit code

Handler architecture

Handlers are responsible for translating Gherkin steps into actions against resources:

flowchart LR
    subgraph Steps["Gherkin Steps"]
        S1["Given 'db' table 'users' has values:"]
        S2["When 'api' sends 'GET' to '/users/1'"]
        S3["Then 'api' response status is '200'"]
    end

    subgraph Handlers["Handler Registry"]
        PG[PostgreSQL Handler]
        HTTP[HTTP Client Handler]
        REDIS[Redis Handler]
        KAFKA[Kafka Handler]
        WS[WebSocket Handler]
        SHELL[Shell Handler]
    end

    subgraph Resources["Resources"]
        DB[(Database)]
        API[HTTP API]
        CACHE[(Cache)]
        MQ[(Message Queue)]
        WSS[WebSocket Server]
        CMD[Shell]
    end

    S1 --> PG
    S2 --> HTTP
    S3 --> HTTP

    PG --> DB
    HTTP --> API
    REDIS --> CACHE
    KAFKA --> MQ
    WS --> WSS
    SHELL --> CMD

Resource lifecycle

Each resource follows a consistent lifecycle:

stateDiagram-v2
    [*] --> Configured: Parse config
    Configured --> Initialized: Initialize handler
    Initialized --> Connected: Connect to service
    Connected --> Ready: Ready for tests

    Ready --> Reset: Before scenario
    Reset --> Ready: Clean state

    Ready --> Closed: Tests complete
    Closed --> [*]

Container orchestration

tomato uses Testcontainers to manage Docker containers:

flowchart TB
    subgraph Config["Configuration"]
        YAML["tomato.yml"]
    end

    subgraph Testcontainers["Testcontainers Runtime"]
        CM[Container Manager]
        NW[Network]

        subgraph Containers
            C1[postgres:15]
            C2[redis:7]
            C3[apache/kafka:3.9.1]
        end
    end

    subgraph WaitStrategies["Wait Strategies"]
        PORT[Port Check]
        HTTP_CHECK[HTTP Check]
        LOG[Log Pattern]
    end

    YAML --> CM
    CM --> NW
    CM --> C1
    CM --> C2
    CM --> C3

    C1 --> PORT
    C2 --> PORT
    C3 --> LOG

Data flow example

Here's how a typical database test scenario flows through the system:

sequenceDiagram
    participant Feature as Feature File
    participant Godog
    participant PG as PostgreSQL Handler
    participant DB as PostgreSQL

    Note over Feature: Scenario: Create user

    Feature->>Godog: Given "db" table "users" has values:
    Godog->>PG: SetTableValues(table, data)
    PG->>DB: INSERT INTO users...
    DB-->>PG: OK
    PG-->>Godog: Success

    Feature->>Godog: When "db" executes:
    Godog->>PG: ExecuteQuery(query)
    PG->>DB: SELECT * FROM users
    DB-->>PG: Results
    PG-->>Godog: Store results

    Feature->>Godog: Then "db" query "..." returns:
    Godog->>PG: AssertQueryResult(expected)
    PG-->>Godog: Match/No match

HTTP request/response flow

sequenceDiagram
    participant Feature as Feature File
    participant HTTP as HTTP Handler
    participant API as Target API

    Feature->>HTTP: Given "api" header "X-Token" is "abc"
    HTTP-->>HTTP: Store header

    Feature->>HTTP: When "api" sends "POST" to "/users" with json:
    HTTP->>API: POST /users
    API-->>HTTP: 201 Created + JSON body
    HTTP-->>HTTP: Store response

    Feature->>HTTP: Then "api" response status is "201"
    HTTP-->>HTTP: Assert status

    Feature->>HTTP: And "api" response json "id" exists
    HTTP-->>HTTP: Assert JSON path

Configuration structure

flowchart TB
    subgraph tomato.yml
        direction TB
        V[version: 2]

        subgraph containers
            C1[name: postgres<br/>image: postgres:15<br/>env: ...<br/>wait_for: ...]
            C2[name: redis<br/>image: redis:7]
        end

        subgraph resources
            R1[name: db<br/>type: postgres<br/>container: postgres]
            R2[name: cache<br/>type: redis<br/>container: redis]
            R3[name: api<br/>type: http<br/>base_url: ...]
        end

        subgraph app
            A[command: go run ./cmd/server<br/>port: 8080<br/>ready: http /health<br/>env: ...]
        end

        subgraph features
            F[paths:<br/>  - ./features]
        end
    end

Error handling

When a step fails, tomato provides detailed error information:

flowchart TB
    Step[Step Execution] --> Check{Success?}
    Check -->|Yes| Next[Next Step]
    Check -->|No| Error[Error Details]

    Error --> Type{Error Type}
    Type -->|Connection| Conn[Connection failed to resource]
    Type -->|Assertion| Assert[Expected vs Actual mismatch]
    Type -->|Timeout| Timeout[Operation timed out]
    Type -->|Syntax| Syntax[Invalid step syntax]

    Conn --> Report[Report to User]
    Assert --> Report
    Timeout --> Report
    Syntax --> Report

    Report --> Cleanup[Cleanup Resources]
    Cleanup --> Exit[Exit with error code]