← All guides

Tavern API testing with a real backend to hit

Tavern is a genuinely nice idea done well: API tests as declarative YAML, running on pytest, so a whole request/assert/save/chain flow is a readable file instead of a wall of requests boilerplate. We're not an alternative to it β€” Tavern is the client side of the test. This guide is about the other half: every Tavern suite needs a server to hit, and "spin up the backend first" is where tutorials go to die. Below is a complete suite that passes verbatim with zero setup, then the patterns that make Tavern shine against a backend you fully control: an ephemeral seeded project per run, deterministic retry tests, timeout budgets, and the strict-matching gotcha that will otherwise eat your first afternoon.

Everything on this page was run with pytest + Tavern 3.6.2 against production before publishing; the outputs shown are real.

1. A suite that passes in 60 seconds

pip install tavern
# save as test_demo.tavern.yaml, then:  python -m pytest test_demo.tavern.yaml
test_name: Demo products happy path

stages:
  - name: create a product and save its id
    request:
      url: https://mockbird.mockbird.workers.dev/m/demo/products
      method: POST
      json:
        name: "Tavern Test Product"
        price: 42.5
    response:
      status_code: 201
      json:
        name: "Tavern Test Product"
        price: 42.5
        id: !anyint
      save:
        json:
          product_id: id

  - name: read it back β€” the write persisted
    request:
      url: "https://mockbird.mockbird.workers.dev/m/demo/products/{product_id}"
      method: GET
      params:
        select: "id,name,price"
    response:
      status_code: 200
      json:
        id: !int "{product_id:d}"
        name: "Tavern Test Product"
        price: 42.5

  - name: clean up
    request:
      url: "https://mockbird.mockbird.workers.dev/m/demo/products/{product_id}"
      method: DELETE
    response:
      status_code: 200

1 passed. No server to start, no fixtures, no Docker β€” demo is a public seeded project with real persistence, so save:-then-chain (the thing Tavern is for) works against real writes. Note the !int "{product_id:d}" dance: saved variables format into strings, so asserting the numeric id back requires the explicit conversion β€” that's standard Tavern, not us.

2. An ephemeral seeded backend, created by the suite itself

The demo project is shared and reseeds daily. For a suite you run in CI you want your own backend with a known dataset β€” and Tavern stages can do the whole lifecycle, no conftest.py required:

test_name: Ephemeral seeded backend per test run

stages:
  - name: create a throwaway seeded project
    request:
      url: https://mockbird.mockbird.workers.dev/api/projects
      method: POST
      json:
        preset: ecommerce
    response:
      status_code: 201
      save:
        json:
          project_id: id
          admin_key: adminKey

  - name: seeded data is there
    request:
      url: "https://mockbird.mockbird.workers.dev/m/{project_id}/products"
      method: GET
      params:
        limit: 5
        select: "id,name,price"
    response:
      status_code: 200
      verify_response_with:
        function: testing_utils:check_five_products

  - name: write, then read your write
    request:
      url: "https://mockbird.mockbird.workers.dev/m/{project_id}/products"
      method: POST
      json:
        name: "Written by Tavern"
        price: 9.99
    response:
      status_code: 201
      save:
        json:
          new_id: id

  - name: the write persisted
    request:
      url: "https://mockbird.mockbird.workers.dev/m/{project_id}/products/{new_id}"
      method: GET
      params:
        select: "id,name,price"
    response:
      status_code: 200
      json:
        id: !int "{new_id:d}"
        name: "Written by Tavern"
        price: 9.99

  - name: tear the project down
    request:
      url: "https://mockbird.mockbird.workers.dev/api/projects/{project_id}"
      method: DELETE
      headers:
        x-admin-key: "{admin_key}"
    response:
      status_code: 200

The create response is flat ({"id": …, "adminKey": …, …}), so both values save in one stage. verify_response_with hands the raw response to a plain Python function next to the YAML β€” the pytest escape hatch for anything YAML can't express:

# testing_utils.py
def check_five_products(response):
    items = response.json()
    assert len(items) == 5
    assert all(set(i) == {"id", "name", "price"} for i in items)

Anonymous project creation is capped at 30 per IP per day β€” fine for CI runs, but if your suite runs every save, create one project by hand and keep its id in an env var instead. Presets: ecommerce, blog, saas, or define your own schema.

3. The strict-matching gotcha (you will hit this)

Tavern's json: block is strict by default: every key in the response must appear in your assertion. Assert three fields of a seeded product and you get:

KeyMismatchError: Structure of returned data was different than expected
  - Extra keys in response: {'rating', 'image', 'description', 'category', 'inStock'}

Two clean fixes. The Tavern-side one relaxes matching for that stage:

    response:
      strict:
        - json:off
      status_code: 200
      json:
        id: 1
        price: !anyfloat

The server-side one keeps strict matching on and trims the response instead β€” pass ?select=id,name,price and the mock returns exactly the fields you assert (that's what the suites above do). We'd argue the second is better: strict matching is a feature β€” it catches fields that appear or vanish β€” and select lets you keep it without enumerating a 8-field seeded record in every stage.

4. Deterministic retry tests: max_retries meets mock_seq

Tavern stages take max_retries, which almost nobody uses in anger because making a backend fail exactly twice then recover is awkward. Hosted, it's a query param β€” mock_seq=500,500,200 serves exactly that status sequence, one per request:

test_name: Retry until the flaky endpoint recovers

stages:
  - name: fails twice then succeeds β€” retry absorbs it
    max_retries: 2
    request:
      url: https://mockbird.mockbird.workers.dev/m/demo/products/1
      method: GET
      params:
        mock_seq: "500,500,200"
        mock_seq_key: "{tavern.env_vars.SEQ_KEY}"
        select: "id,name,price"
    response:
      status_code: 200
      json:
        id: 1
        name: !anystr
        price: !anyfloat
SEQ_KEY=run-$RANDOM python -m pytest test_retry.tavern.yaml   # 1 passed

We verified the arithmetic honestly: with max_retries: 1 this same test fails (two 500s need two retries) β€” the pass isn't the mock being lenient.

Two traps we hit so you don't:

Every response carries x-mockbird-seq: pos/len so a confused test can be debugged from headers. For randomized resilience runs, ?mock_chaos=0.3 fails 30% of requests with realistic statuses β€” better in a nightly job than a PR gate.

5. Timeout budgets and validation errors

Tavern's timeout: (seconds) plus ?mock_delay=ms turns "we respond within budget" into an assertion β€” and makes slow-path handling testable:

  - name: 2s response must complete under 5s
    request:
      url: https://mockbird.mockbird.workers.dev/m/demo/products/1
      method: GET
      timeout: 5
      params:
        mock_delay: 2000

Drop timeout to 1 and the stage fails with a ReadTimeout β€” which is exactly what you want when the test is "our client gives up politely". And ?mock_validate=1 makes writes type-check against the resource schema, so your 422-handling path gets a real 422 with a real field map:

  - name: wrong types β†’ 422 with per-field errors
    request:
      url: https://mockbird.mockbird.workers.dev/m/demo/products
      method: POST
      params:
        mock_validate: 1
      json:
        name: 123
        price: "not a number"
    response:
      status_code: 422
      strict:
        - json:off
      json:
        error: !anystr

The actual body, if you want to assert the whole thing: {"error":"validation_failed","fields":{"name":"expected string, got number","price":"expected number, got string"}}.

6. Honest notes

Local stub (json-server, WireMock, …)Your real staging APIMockbird as the Tavern target
Setup before pytest runsstart process / container, wait for readynone (it exists)none, or one create stage
Works offline / airgappedβœ”βœ˜ usually✘ needs egress
Deterministic failure sequencesDIY middleware✘ please don'tmock_seq param
Isolated state per runβœ”βœ˜ shared, mutableβœ” project per run
Tests the real contract driftβœ˜βœ” its actual job✘ it's a mock
Latency~1 msnetworknetwork (tens of ms)

If your Tavern suite's job is contract testing your real API, point it at your real API β€” that's the tool doing its job, and no mock replaces it. The hosted-mock target earns its keep when the suite is about your client's behavior (retries, timeouts, error handling, pagination walking) or when the real backend doesn't exist yet. Projects have a 10,000 requests/day cap β€” a test suite barely dents it, but don't point a load test at us. Local stubs beat us on airgapped runners, full stop.

Try it now

pip install tavern
curl -s https://mockbird.mockbird.workers.dev/m/demo/products?limit=3

…then paste the first suite above into test_demo.tavern.yaml and run pytest. Or create your own project in one click. Free, no signup required. Full docs Β· machine-readable API index.

Related: the same patterns in Karate, the same ephemeral-project pattern in GitHub Actions (Tavern suites slot straight into that workflow), Python/requests wiring, deterministic test data with snapshots, and testing loading and error states.

Verification: every YAML file on this page was run verbatim with pytest + Tavern 3.6.2 (installed fresh from PyPI on 27 Aug 2026) against production, including the failure cases: the strict-matching KeyMismatchError text is a real excerpt, the mock_seq_reset retry loop genuinely never recovers, and max_retries: 1 genuinely fails where 2 passes. If a snippet here doesn't work, that's a bug: tell us.