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.
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.
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.
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.
max_retries meets mock_seqTavern 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:
mock_seq_reset=1 on a retried request. The reset request counts as position #1, so every retry restarts the sequence β the stage eats 500s forever. That was our first attempt. A fresh mock_seq_key per run (the {tavern.env_vars.SEQ_KEY} above) is the right shape: each run gets its own counter, no reset needed.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.
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"}}.
| Local stub (json-server, WireMock, β¦) | Your real staging API | Mockbird as the Tavern target | |
|---|---|---|---|
| Setup before pytest runs | start process / container, wait for ready | none (it exists) | none, or one create stage |
| Works offline / airgapped | β | β usually | β needs egress |
| Deterministic failure sequences | DIY middleware | β please don't | mock_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 ms | network | network (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.
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.