How-to › Test and mock integrations

How to test stateful endpoints with Dredd hooks#

Make Dredd pass for reads and deletes that need a record first, without loosening an assertion, and fail loudly when a hook names a transaction that has gone.

Audience
API producer
Level
advanced
Topic
Run contract and spec tests
Languages
JavaScript and Python
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hand-written pytest suiteFixtures and teardown belong to the test, and the spec is one input among severalNothing ties it to the document, so a new operation is untested until someone writes a testThe document is the contract and must be exercised as written
Dredd hooksYou run Dredd already and its example-driven checks suit the APIHooks address transactions by name, in a separate file, on a tool with no release since 2021You are choosing a tool from scratch
Schemathesis stateful testingThe document has links, or ids Schemathesis can infer, between create and readGenerated data finds bugs examples never reach, and runs take seconds rather than millisecondsYou 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.

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

  1. 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 multipleOf to minProperties, and links on a response takes another. A document can therefore state a maximum that no Dredd run will check. ↩︎ Back to text

  2. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.