Skip to content

How to write a Hook

A hook is a TypeScript function you own, anchored to the Transaction — or the set of Transactions — it applies to.

Terminal window
mkdir -p .thymian/sampler/hooks

Any file under hooks/ is scanned, at any depth, named anything you like:

  • Directory.thymian/sampler/hooks/
    • auth.ts
    • Directorylaunches/
      • seed.ts
      • assertions.ts

The layout carries no meaning — it is for your benefit. What decides where a hook applies is its target, in the code.

Hook files are evaluated once per scan, and so is every module they import: a module two hook files share is one instance, and a value one hook writes to it is the value another reads.

// .thymian/sampler/hooks/state.ts — one copy, shared by every hook file
export const fixtures: { userId?: number } = {};

So a beforeAll in auth.ts that writes fixtures.userId hands it to a beforeEach in seed.ts that reads it — provided the beforeAll has run, which the lifecycle guarantees before any request-scoped hook. What the plugin does not carry over is the process: thymian serve re-evaluates the hook files, and the modules they import, on every scan, so state is one run’s and never the next’s. Prefer utils.request for anything that has to exist on the server; keep module state for what only the hooks need.

Kind Runs Use it for
defineSample when the request is generated bodies, fixture files, the authorize flag
beforeEach before each request headers, path parameters, seeding
afterEach after each response assertions, observations
authorize before a request that wants credentials credentials, and nothing else
beforeAll once, before the first request run-wide setup
afterAll once, when the run closes run-wide teardown

Every hook returns nothing and mutates in place, and every registration is an export:

import { beforeEach } from '@thymian/hooks';
export const addTraceId = beforeEach(
'GET /launches -> 200 (application/json)',
(request) => {
request.headers['x-trace-id'] = 'abc';
},
);

Pass its Selector. This is the tightest and the most precisely typed option — inside the callback, the request and response types are that Transaction’s.

export const shape = beforeEach(
'POST /launches (application/json) -> 201 (application/json)',
(request) => {
request.body = { missionName: 'Artemis II' };
},
);

Pass a list of Selectors.

export const shared = beforeEach(
[
'GET /launches -> 200 (application/json)',
'GET /astronauts -> 200 (application/json)',
],
(request) => {
request.headers['x-shared'] = 'yes';
},
);

Pass a filter. Fields AND-combine, a list within a field OR-combines, and not excludes.

export const negativeCases = afterEach(
{ statusClass: '4XX', not: { status: 401 } },
(response, ctx, utils) => {
utils.info(`negative case responded ${response.statusCode}`);
},
);

Use a path glob. * is exactly one segment; a trailing ** is one or more.

export const admin = beforeEach({ path: '/admin/**' }, (request) => {
request.headers['x-admin'] = 'yes';
});

/admin/** covers everything under /admin, including paths added to your API description later — but not /admin itself.

See the filter reference for every field and the full glob grammar.

Terminal window
npx thymian sampler show 'GET /launches -> 200 (application/json)'

It prints the request that Transaction would send. Get the Selector slightly wrong and it prints the nearest matches, which is the fastest way to find the exact spelling.

Several hooks of the same kind on one Transaction all run, in registration order: file order on the outside, and source order within each file. defineSample is the exception — at most one per Transaction, and a second is reported as a conflict when the hooks load.

A Selector that names nothing, a glob that matches nothing, and a filter whose values intersect nothing all fail the run, naming the hook file and the export:

1 sampler hook does not resolve against the loaded API description.
Try this:
* seed.ts (export "seedAstronaut"): beforeEach targets the selector
"GET /astronauts/{id} -> 200 (application/json)", which names no transaction
in the loaded API description
* Did you mean one of these selectors?
* "GET /astronauts/{astronautId} -> 200 (application/json)"

A hook is never quietly re-bound to a nearby Transaction. That is the point of anchoring to a fully-qualified Selector.