Dredd sends the example from your OpenAPI document, so the read goes to /invoices/inv_42 on a
fresh database and gets a 404. The delete fails the same way. The tempting fix is to accept 404
for those operations, which turns the suite into one that also passes for an API with no data
at all.
What you get
You will end up with Dredd hooks that create the record a read needs and run the transactions in a declared order. They clean up after themselves and fail the run when a hook goes stale. This is for you if your spec-driven tests fail on any operation that needs data to exist first.
Short answer
Write Dredd hooks that create the record before the read, keep the returned id, and rewrite the
path of the read and the delete to use it. Sort the transactions in beforeAll into the order the
hooks depend on, delete what the run created in afterAll, and fail every transaction when a
hook names one the document no longer has. Run the suite twice against one fresh server.
You will need
Node 22 or later, an OpenAPI 3 document with request examples, and Python 3.10 or later for the comparison suite. Verified 2026-10-15 against Node 22.22.0, Dredd 14.1.0 and pytest 9.1.1. Running Schemathesis against a staging API in CI is a separate how-to.
Know what you are adopting. Dredd’s last release on the npm registry is 14.1.0, published in November 2021, and npm reported 25 known vulnerabilities in its dependency tree when it was installed for this page. Its repository was archived as read-only in November 2024. It installs and runs on Node 22, with two runtime warnings from its dependencies. Its own README lists OpenAPI 3 support as experimental.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A hand-written pytest suite | Fixtures and teardown belong to the test, and the spec is one input among several | Nothing ties it to the document, so a new operation is untested until someone writes a test | The document is the contract and must be exercised as written |
| Dredd hooks | You run Dredd already and its example-driven checks suit the API | Hooks address transactions by name, in a separate file, on a tool with no release since 2021 | You are choosing a tool from scratch |
| Schemathesis stateful testing | The document has links, or ids Schemathesis can infer, between create and read | Generated data finds bugs examples never reach, and runs take seconds rather than milliseconds | You need one exact sequence with fixed data |
Schemathesis builds its own sequences from the document’s links and needs no hooks for this case, at the price of a Python toolchain and nondeterministic runs you pin with a seed. The pytest suite owns every fixture outright, which makes it the most predictable and the easiest to let drift from the document. Dredd sits between them: the document drives the requests, and the hooks you write supply the state.
See what the document alone produces
Run Dredd with no hooks against a fresh server. dredd-summary.mjs starts server.mjs on a free
port, passes Dredd the document, that address, and any other arguments, and prints only the
verdict lines, without colors, timings, or proxy warnings.
node dredd-summary.mjs openapi.yaml | head -8
pass: GET (200) /invoices
pass: POST (201) /invoices
fail: POST (400) /invoices
pass: POST (409) /invoices
fail: GET (200) /invoices/inv_42
pass: GET (404) /invoices/inv_42
fail: DELETE (204) /invoices/inv_42
pass: DELETE (404) /invoices/inv_42
The run has three failures and one pass that depends on luck. The 409 passes only because the 201
ran first and took the invoice number, so the document’s order is already part of the result. The
400 fails because the document has one request example, and it is valid. Dredd names each case
with a transaction name built
from the path, the operation summary, the status and the media type; list them with --names.
The script does not use Dredd’s own --server flag. That flag starts a command, waits a fixed
--server-wait, and then tests whatever answers at
the address. Run here with another process already on the port, the --server run tested that
process and printed a different set of failures.
Create the record and rewrite the path
Hook the read, create an invoice over HTTP, and point the request at the new id.
// Create the record the read needs, keep its id, and point the request at it.
hooks.before(GET, (transaction, done) => {
fetch(`${base(transaction)}/invoices`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ number: `fixture-${Date.now()}`, customer: 'cus_fixture', amount: 500 }),
})
.then(async (res) => {
if (res.status !== 201) throw new Error(`fixture create answered ${res.status}`)
const { id } = await res.json()
state.invoiceId = id
state.created.push(id)
pointAt(transaction, id)
})
.catch((err) => {
transaction.fail = `could not create the fixture: ${err.message}`
})
.finally(done)
})
The hook takes done and calls it last. Dredd waits for a hook only when it declares that second
argument; an async function with one argument is called and not awaited.2 pointAt replaces
the example id at the end of fullPath, and fails the transaction if the example has changed
rather than sending the request somewhere else. The delete hook reuses state.invoiceId, and
afterAll deletes every id still in state.created.
Set the order and refuse stale names
Sort the transactions before any request goes out, and check every name the hooks use against the names the document produces.
hooks.beforeAll((transactions) => {
const names = new Set(transactions.map((t) => t.name))
const used = new Set([
...ORDER,
...Object.keys(hooks.beforeHooks),
...Object.keys(hooks.beforeValidationHooks),
...Object.keys(hooks.afterHooks),
])
const missing = [...used].filter((name) => !names.has(name))
if (missing.length > 0) {
for (const t of transactions) t.fail = `hooks name transactions that do not exist: ${missing.join(' | ')}`
return
}
const rank = (t) => (ORDER.includes(t.name) ? ORDER.indexOf(t.name) : ORDER.length)
transactions.sort((a, b) => rank(a) - rank(b))
})
Dredd runs the array beforeAll receives, so sorting it in place sets the order. The
--sorted flag orders by HTTP method instead, POST
before GET before DELETE, which cannot express “this conflict after that create.”
Check it worked
Run the suite twice in a row against one freshly started server. twice.mjs does that with the
hooks, then again without them.
node twice.mjs
with hooks.cjs, one fresh store
run 1: complete: 8 passing, 0 failing, 0 errors, 0 skipped, 8 total
run 2: complete: 8 passing, 0 failing, 0 errors, 0 skipped, 8 total
invoices left in the store: 0
without hooks, one fresh store
run 1: complete: 5 passing, 3 failing, 0 errors, 0 skipped, 8 total
run 2: complete: 4 passing, 4 failing, 0 errors, 0 skipped, 8 total
invoices left in the store: 1
Identical runs and an empty store are the result to look for. Without the hooks the second run
differs from the first, because the create left invoice 2026-0001 behind and the second create
got a 409.
When it goes wrong
A hook stops applying after a documentation edit. Renaming a summary renames the transaction,
and Dredd looks hooks up by name, so a hook registered under the old name never runs. The
beforeAll check turns that into a failure on every transaction:
node stale-name.mjs
summary renamed: summary: Get an invoice -> summary: Retrieve an invoice
complete: 0 passing, 8 failing, 0 errors, 0 skipped, 8 total, exit code 1
fail: Failed in before hook: hooks name transactions that do not exist: /invoices/{invoiceId} > Get an invoice > 200 > application/json
path example changed: example: inv_42 -> example: inv_7
complete: 6 passing, 2 failing, 0 errors, 0 skipped, 8 total, exit code 1
fail: Failed in before hook: expected the example id inv_42 at the end of /invoices/inv_7
The hooks run, but the requests still go to inv_42. The hook is an async function, so Dredd
sent the request before the record existed. async-pitfall.cjs holds that version of the read
hook, and the run ends where the run with no hooks did:
node dredd-summary.mjs openapi.yaml --hookfiles async-pitfall.cjs | tail -1
complete: 5 passing, 3 failing, 0 errors, 0 skipped, 8 total
Take done instead, and call it after the last await.
The document says more than Dredd checks. Its OpenAPI 3 parser warned that required on the
request body, links, minimum and maximum are unsupported keys, so none of them is tested.
Compare the other two
The pytest suite starts the server in a session fixture, creates each record in a function fixture, and asserts at teardown that the store is empty:
for run in 1 2; do PYTHONPATH=vendor python3 -m pytest -q -p no:cacheprovider test_invoices.py | tail -1 | sed -E 's/ in [0-9.]+s$//'; done
6 passed
6 passed
Schemathesis 4.31.1 ran here, outside the page’s CI, with the command below. It follows the document’s links from the create to the read and the delete, and inferred three more on its own.
schemathesis run openapi.yaml --url http://127.0.0.1:3913 --phases stateful --seed 1 -n 30
Against the first draft of this sample it reported failures the Dredd run had passed. A POST
with no body was allowed, because the request body was not marked required, and the server’s
400 and 409 were undocumented. An amount larger than 2^53 came back as 2.2558400350595024e+21,
which Schemathesis rejected as not an integer. The sample fixes all of them, and the same command
then generated 996 cases and passed.
When not to do this
Do not start a project on Dredd. Its last release is from 2021 and its OpenAPI 3 support never left experimental, so every hook you write is code you will port. Use Schemathesis for generated sequences, or a test suite that owns its fixtures.
Do not loosen an expected status to make a stateful case pass. A suite that accepts 404 for a read passes for an API that stores nothing.
Do not let hooks depend on the order of the document. Declare the order in the hook file, next to the hooks that need it, so the sequence is reviewed in the same diff as the code that needs it.
Do not skip cleanup because the database is fresh in CI. Developers run the suite against a long-lived local server, and the second run there is the one that fails.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, Dredd 14.1.0 and pytest 9.1.1. Every output block is what the command before it printed. Schemathesis 4.31.1 ran in this container against the same server, and its results are described in prose rather than shown as captured output.
Footnotes
-
The word “experimental” in the README links to the OpenAPI 3 parser’s status page, which grades every field with a tick, a tilde or a cross. The Schema Object’s bounds take thirteen crosses in a row, from
multipleOftominProperties, andlinkson a response takes another. A document can therefore state a maximum that no Dredd run will check. ↩︎ Back to text -
The rule is one line in Dredd’s transaction runner: a hook with one parameter is called and the run moves on, and a hook with two gets a callback. The callback it passes ignores its argument, so
done(error)reports nothing either. Earlier in the same file, the code that catches errors thrown by hooks carries a comment warning that “all the flow can be executed twice.” ↩︎ Back to text