bddkit 0.1.1

Gherkin acceptance testing for backend services: one binary drives the HTTP API and the resources behind it
bddkit-0.1.1 is not a library.

bddkit

Acceptance testing for backend services — the API and everything behind it — written in Gherkin and run by a single Rust binary.

A backend test rarely ends at the HTTP response. The interesting question is usually what happened behind it: did the row change, did the balance move, did the state settle a second later. bddkit treats every system a scenario touches as a named resource — declared once in the config, reachable from any scenario — so seeding a row, calling the API, and asserting the row changed are three steps in one vocabulary instead of three tools with glue between them.

Scenario: registering a company charges the account
  Given I have "accounts" with "email: <<unique(email)>>, balance: 100"
  And the request body is:
    """
    {"account_id": "<<last_insert_id_accounts>>", "name": "Acme"}
    """
  When I request "/api/v1/companies" using HTTP POST
  Then the response code is 201
  And I expect the next assertion to pass within "10" seconds
  And I should have "accounts" with "balance: 75"

What it solves

Extending the vocabulary shouldn't require a rebuild. Domain steps like I login as user "..." are declared in YAML and loaded at startup, so whoever writes the scenarios can also extend the language they're written in.

A typo shouldn't cost you three minutes of run time. Unknown steps, ambiguous patterns, macro cycles, undeclared connections — all found in one pass before the first request. Exit code 2 means "run not started", so CI can tell a broken suite from a failing one. Pattern collisions fail loudly instead of silently shadowing each other.

Checking the database shouldn't mean leaving the feature file. DB steps introspect the real schema: primary keys fill themselves the way the column declares them, values coerce to real column types, and a missing NOT NULL column fails by name at the step rather than as a driver error. Hawk signing, SRP-6a, and AES are steps too, so a login handshake stays declarative.

Async systems shouldn't need sleep loops. Prefix any assertion with I expect the next assertion to pass within "10" seconds and it retries — re-sending the request or re-running the query — until it holds.

Test data shouldn't collide, and should be removable afterwards. Every <<unique()>> value in a run shares one prefix drawn once per process, so uniqueness is guaranteed rather than probable, and cleanup afterwards is one DELETE FROM t WHERE col LIKE '%<run_id>%'.

A failure should explain itself. Every failed step prints the full last exchange — method, URL, headers, bodies, status — with no debug flag and no re-run. JSON mismatches point at the path that differs.

Quick start

docker compose up -d smocker   # local mock API the example talks to
cargo build --release
./target/release/bddkit --config examples/api.yaml
run m4k2p9x7q3b1
  ✓ examples/features/methods.feature — scenarios: 8
  ✓ examples/features/json_matchers.feature — scenarios: 4
  ✓ examples/features/variables.feature — scenarios: 5
  ✓ examples/features/macros.feature — scenarios: 6
  ✓ examples/features/content_types.feature — scenarios: 4
  ✓ examples/features/eventual.feature — scenarios: 1

run m4k2p9x7q3b1
files: 6, scenarios: 28, failed: 0

The example suite talks to a local Smocker instance seeded from examples/mocks/api-server.yaml, so it runs offline and its responses are fixed by a file in this repo. Smocker's web UI is on http://localhost:8081.

The database examples are a second suite with its own config and its own container:

docker compose up -d db
./target/release/bddkit --config examples/db.yaml

examples/README.md covers both suites, what each feature file demonstrates, and how to narrow a run to one file or one tag.

Flags: --config (required), positional paths to override the config's, --tag (repeatable), --env to pick a .env.<name> layer, --fail-fast. Exit codes: 0 passed, 1 a scenario failed, 2 the run never started.

Config

concurrency: 8              # files in flight; a file is one tokio task
macro_paths: [macros/]
paths: [features/]

resources:
  api:
    review:
      base_url: http://review.local
      default_headers: { X-Client: bddkit }
  db:
    default:
      dsn: postgres://${DB_USER}:${DB_PASS}@db:5432/review
      search_path: [app, public]

options:
  polling: { timeout_secs: 5, interval_ms: 100 }   # defaults for eventual assertions

${VAR} expands docker-compose style (:-, :?, :+ and friends) from the real environment and a .env / .env.local / .env.<APP_ENV> layer stack next to the config. With one resource of a kind its default_* is inferred; with several it must be explicit. options set at the root cascades into every resource and can be overridden per resource.

Variables live for the whole feature file, so a later scenario can read what an earlier one produced. Request state and the current connection reset per scenario. Files run in parallel; @serial(name) chains files that contend, @priority(N) moves one up the queue.

Plugins

api, db and srp are the resource kinds built into the binary. Any other key under resources: is a group served by a plugin — a shared library bddkit loads at startup — so reaching an object store, a queue or a mailbox is the same move as reaching a second database:

resources:
  api:
    review: { base_url: http://review.local }
  s3:                       # served by a plugin, not by bddkit itself
    backups:
      bucket: acme-backups
      endpoint: http://minio:9000
    archive:
      bucket: acme-archive
default_s3: backups

The plugin brings its own steps, which read like any other step — a tester cannot tell a built-in from a plugin step, and a macro can call one. Instances are selected the same way as an API or a connection:

Given I use "archive" s3
When I upload file "report.pdf"

The selection resets to default_<group> at every scenario boundary, exactly like the current API and the current connection. With one instance in a group its default_<group> is inferred; with several it must be spelled out.

Which plugins are installed is machine state, not test config. It lives in .bddkit/plugins.yaml next to your config file — a list of {name, path} — and it does not belong in the repository with the suite: the config describes the system under test, a path to a .so describes one laptop or one CI runner.

# .bddkit/plugins.yaml
plugin:
  - name: s3
    path: /opt/bddkit/libbddkit_s3.so

A plugin runs inside the bddkit process with full privileges and there is no sandbox — installing one is the same trust decision as installing any other binary.

Writing one: docs/plugin-authoring.md is the complete contract, tests/fixtures/echo-plugin/ is a minimal plugin to copy, and bddkit-s3 is a real one to read — it serves the s3 group in the example above.

Where to look next

For Look at
Every step, authoritative BUILTIN_STEPS in src/steps/mod.rs
How to run the examples examples/README.md
A runnable HTTP example examples/api.yaml, examples/features/
Every HTTP method, 404 included examples/features/methods.feature
JSON matchers and paths examples/features/json_matchers.feature
Variables: set, extract, reuse examples/features/variables.feature
Macros, nesting, Scenario Outline examples/features/macros.feature, examples/macros/posts.yaml
Non-JSON responses, form login examples/features/content_types.feature
Polling an assertion until it passes examples/features/eventual.feature
The mock API behind all of it examples/mocks/api-server.yaml
Every DB step, worked through examples/db-features/db.feature
SRP handshake, Hawk signing tests/features/
Config schema src/config.rs
Writing a plugin docs/plugin-authoring.md, tests/fixtures/echo-plugin/

Design notes

Why not cucumber-rs. Three axes diverge: steps register at compile time via proc-macros (here they load from YAML at run time), World is recreated per scenario (here variables are file-scoped), and concurrency is a flat pool over scenarios (here it's chains of files). Working around all three leaves only its run loop while breaking its own step diagnostics — which is the part worth strengthening. The gherkin crate underneath it is reused directly.

Why no transactional rollback. The service under test reads over its own connection and would never see uncommitted rows, so rolling back per scenario would break any test where a step writes and the API reads. Isolation comes from unique data instead.

Where this is going. api, db, and srp are the resource kinds that ship, not the ceiling — any other key under resources: is a capability group a plugin serves, so reaching an object store or a mailbox is the same move as reaching a second database. The seams that made that possible were there from the start: options cascade per instance, I use "<name>" <kind> is one step shape, and dispatch returns passed | not yet | fatal so eventual assertions work without knowing what they retry. What is still missing is the bddkit plugin install side of it — today .bddkit/plugins.yaml is written by hand.

Development

cargo test

Some tests need PostgreSQL; docker-compose.yml brings one up and examples/db/init.sql creates the schema. The same file brings up Smocker as the HTTP example's mock API — it seeds examples/mocks/api-server.yaml at startup and serves it on localhost:8080 (web UI on localhost:8081).

License

Apache-2.0. See LICENSE.