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

Source: https://voxgig.com/howto/stateful-api-tests-with-dredd-hooks

- Audience: api-producer
- Level: advanced
- Languages: javascript, python
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://registry.npmjs.org/dredd) 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](https://github.com/apiaryio/dredd) 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](https://docs.pytest.org/en/stable/how-to/fixtures.html) | 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](https://dredd.org/en/latest/hooks/js.html) | 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](https://schemathesis.readthedocs.io/en/stable/guides/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.

```bash
node dredd-summary.mjs openapi.yaml | head -8
```

```text output
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](https://dredd.org/en/latest/hooks/index.html#transaction-names) 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`](https://dredd.org/en/latest/usage-cli.html), 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.

```js title="hooks.cjs"
// 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.

```js title="hooks.cjs"
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`](https://dredd.org/en/latest/usage-cli.html) 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.

```bash
node twice.mjs
```

```text output
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:

```bash
node stale-name.mjs
```

```text output
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:

```bash
node dredd-summary.mjs openapi.yaml --hookfiles async-pitfall.cjs | tail -1
```

```text output
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:

```bash
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
```

```text output
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.

```bash
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

- [Validate responses against the OpenAPI document at runtime](/howto/validate-responses-against-the-openapi-document)

- [Choose consumer-driven or spec-driven contract tests](/howto/choose-consumer-driven-or-spec-driven-contract-tests)

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

[^1]: The word "experimental" in the [README](https://github.com/apiaryio/dredd#supported-api-description-formats)
links to the OpenAPI 3 parser's [status page](https://github.com/apiaryio/api-elements.js/blob/master/packages/openapi3-parser/STATUS.md),
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.

[^2]: The rule is one line in Dredd's
[transaction runner](https://github.com/apiaryio/dredd/blob/master/packages/dredd/lib/TransactionRunner.js):
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."