Skip to content

recordHooks

Category
Tags
Export size
min 4.40 kB · gzip 2.08 kB
See also

Record every call that passes through a service or an application, so a test can assert what was requested — and how often — without standing up a spy per method.

Pass an app to record all of its services, or a single service to record just that one; path, method and type narrow it further. The typical use is proving a cache, a debounce or a local-first store did not go to the server, which is awkward to assert on results alone.

The record is indexed by hook type and method — calls.before.get — and every entry is a plain array, so it composes with any predicate, isContext included. So do reset() and waitFor().

By default the live HookContext is recorded. That object keeps mutating as the call travels through the remaining hooks, so if you assert on data or params.query after the call resolved, pass snapshot: true.

ts
  import { 
recordHooks
} from 'feathers-utils/testing';

Examples

Example 1

ts
import { recordHooks } from 'feathers-utils/testing'

const calls = recordHooks(app)

await app.service('users').find({ query: { name: 'jane' } })

expect(calls.before.find).toHaveLength(1)
expect(calls.before.create).toHaveLength(0)
expect(calls.before.find[0].params.query).toEqual({ name: 'jane' })

Example 2

ts
import { isContext } from 'feathers-utils/predicates'

// one method of one service, or everything that service was asked to do
expect(calls.before.find.filter(isContext({ path: 'users' }))).toHaveLength(1)
expect(calls.all.filter(isContext({ path: 'users' }))).toHaveLength(1)

// and forget one service's calls without forgetting the rest
calls.reset({ path: 'users' })

Example 3

ts
// wait for a call instead of sleeping and then reading the record
const [context] = await calls.waitFor({ context: { method: 'create' } })
expect(context.data).toEqual({ name: 'jane' })

// prove nothing reaches the service at all, failing the moment one does
calls.reset()
await readThroughCache()
await calls.waitFor({ context: { path: 'users' }, count: 0 })

Example 4

ts
// "one request went out, prove no second one follows" — the baseline is
// taken here, so the first call stays in the record as evidence
await app.service('users').find({})
const quiet = calls.waitFor({
  context: { path: 'users' },
  count: 0,
  since: 'now',
})
await readThroughCache()
await quiet
expect(calls.before.find).toHaveLength(1)

Example 5

ts
// the same thing after the fact: let a debounce settle, then assert the
// exact number of calls — no `sleep`, and it returns them
await store.load()
await store.load()

const finds = await calls.waitFor({
  context: { path: 'users' },
  quietFor: 250,
})
expect(finds).toHaveLength(1)

Example 6

ts
// both sides of two methods of one service, with `data` as it came in
const calls = recordHooks(app, {
  path: 'users',
  method: ['create', 'patch'],
  type: ['before', 'after'],
  snapshot: true,
})

await app.service('users').create({ name: 'jane' })

expect(calls.before.create[0].data).toEqual({ name: 'jane' })
expect(calls.after.create[0].result).toMatchObject({ name: 'jane' })

Type declaration

Show Type Declarations
ts
/**
 * Anything that accepts a regular hook map: an `Application` (records every
 * service) or a single service (records only that one).
 */
export type RecordHooksTarget = {
  hooks: (map: any) => any
}
/**
 * What to record, by the same criteria as {@link isContext} — plus how to
 * record it. An omitted criterion does not narrow, and an array matches any of
 * its values.
 */
export type RecordHooksOptions = {
  /**
   * Which hook types to record in, which is also what gets registered. Each
   * call is recorded once per type, so by default a successful call shows up
   * in `before` and in `after`, and a failed one in `before` and in `error`.
   *
   * `around` is not recorded unless asked for: at the moment it records it
   * sees exactly what `before` sees, so it would only add another entry per
   * call. Narrow this to a single type when one entry per call matters.
   *
   * Recording in more than one type without `snapshot` records the *same*
   * context object more than once, because Feathers reuses one context per
   * call — each entry keeps the type it was recorded in, nothing else differs.
   * `waitFor` counts per call regardless; it is `all` and the per-type views
   * that show a call once per type.
   *
   * @default ['before', 'after', 'error']
   */
  type?: IsContextOptions["type"]
  /**
   * Only record these services, by path. Defaults to every service of the
   * target — which is a single service anyway, unless an app was passed.
   */
  path?: IsContextOptions["path"]
  /**
   * Only record these methods. Defaults to every method, custom ones included.
   */
  method?: IsContextOptions["method"]
  /**
   * Only record calls addressing this record — `null` for the multi variants
   * of `update`/`patch`/`remove`. Defaults to every call.
   */
  id?: IsContextOptions["id"]
  /**
   * Record a snapshot instead of the live context, so later hooks cannot
   * rewrite what was recorded. `data`, `result` and `params.query` are copied;
   * `app`, `service` and the rest of `params` stay by reference, because deep
   * copying those would clone the whole application.
   *
   * @default false
   */
  snapshot?: boolean
}
/**
 * The recorded contexts of one hook type, per method and in call order. A
 * method that was not recorded reads as `[]`, so `calls.before.get` needs no
 * guard; `Object.keys` lists only the methods actually recorded.
 */
export type RecordedMethods = Record<MethodName, HookContext[]>
/**
 * What to match a recorded call against: the same criteria as {@link isContext}
 * — `path`, `method`, `type`, `id` — or any predicate, for the conditions
 * criteria cannot express.
 */
export type RecordedHooksMatch = IsContextOptions | PredicateContextSync
export type RecordedHooksWaitOptions = {
  /**
   * Which calls to wait for. Omitted, every recorded call counts.
   */
  context?: RecordedHooksMatch
  /**
   * How many matching calls to wait for — a lower bound: the wait resolves as
   * soon as that many are there, and says nothing about further ones. For an
   * exact assertion, add `quietFor` and assert on what it resolves with.
   *
   * Calls, not recorded entries: a call the recorder saw in `before` and again
   * in `after` counts once, and the wait resolves with one context per call —
   * the first one recorded for it. Pin `type` in `context` to choose which
   * side that is; `calls.all` is the entry-level view.
   *
   * `0` inverts the wait: it resolves once the window has passed *without* a
   * matching call, and rejects as soon as one is recorded.
   *
   * @default 1
   */
  count?: number
  /**
   * What `count` counts: every matching call in the record (`'record'`), or
   * only the ones recorded from this `waitFor` call onwards (`'now'`).
   *
   * `'now'` is how "no *further* call" is expressed — `{ count: 0, since:
   * 'now' }` ignores what is already recorded instead of rejecting on it, and
   * unlike `resetBefore` it leaves that evidence in place for the assertions
   * that follow. `resetAfter` would forget that evidence again, so the two are
   * refused together.
   *
   * It is the *calls* already out that are ignored, not merely their entries:
   * one of them coming back while the wait runs is not a further call, so an
   * `after` to a `before` from before the baseline neither counts nor
   * rejects.
   *
   * Mind the order, as with `resetBefore`: the baseline is taken when
   * `waitFor` is called, so this belongs to "act, start the wait, act again,
   * then await it". To check after the fact instead, use `quietFor` and assert
   * on how many calls it resolves with — that counts the ones already
   * recorded.
   *
   * @default 'record'
   */
  since?: "record" | "now"
  /**
   * Resolve only after this many milliseconds without a new matching call, and
   * resolve with *every* match seen — the debounce-shaped wait: trigger
   * something, let it settle, then assert the exact number of calls.
   *
   * One context per call, as with `count`. The silence, though, is pushed out
   * by every matching entry, a call *coming back* included — so a call that
   * returns inside the window keeps the wait open. A call slower than the
   * window still resolves it while that call is in flight.
   *
   * The silence is only watched once `count` is reached, and `timeout` stays
   * the hard deadline: a stream of calls that never goes quiet rejects there.
   * It therefore has to be shorter than `timeout`.
   */
  quietFor?: number
  /**
   * Reject after this many milliseconds. Pass `false` to wait indefinitely.
   *
   * Waiting for calls resolves as soon as they arrive, so a generous window
   * costs a passing test nothing. `count: 0` is the other way round — it
   * always waits the window out — so it defaults to a short one, and `false`
   * is refused there because it could never settle.
   *
   * @default 5000 — with `count: 0`, 50
   */
  timeout?: number | false
  /**
   * Forget the matching calls before waiting, so that only what happens from
   * here on counts — for waiting on the *next* call while earlier ones are
   * still in the record. Like `reset`, it forgets only what the criteria
   * match.
   *
   * Mind the order: the window starts when `waitFor` is called, so this
   * belongs to "start the wait, act, then await it". In the other shape — act
   * first, then check the record — it would forget the very call in question,
   * and a plain `reset()` before acting is what you want.
   *
   * @default false
   */
  resetBefore?: boolean
  /**
   * Forget the matching calls once the wait has resolved — the contexts it
   * resolves with are yours either way, so a test can wait through several
   * phases without a `reset()` in between. A wait that *failed* keeps the
   * record, since that is the evidence for what went wrong.
   *
   * Forgetting goes by the same criteria, so it takes every entry of a
   * matching call, both sides of it included. That contradicts `since: 'now'`,
   * which keeps what is already recorded, and the two are refused together.
   *
   * @default false
   */
  resetAfter?: boolean
}
export type RecordedHooks = {
  /** What the `before` hook recorded, per method. */
  before: RecordedMethods
  /** What the `after` hook recorded, per method. */
  after: RecordedMethods
  /** What the `error` hook recorded, per method. */
  error: RecordedMethods
  /** What the `around` hook recorded, per method. */
  around: RecordedMethods
  /**
   * Every recorded context, in the order it was recorded, across all services,
   * methods and hook types — narrow it with any predicate, `isContext`
   * included.
   *
   * One *call* appears once per recorded hook type, so with the default types
   * a successful call is two entries here. `before`/`after`/`error` are the
   * per-call view, and `waitFor` counts calls rather than these entries.
   */
  all: HookContext[]
  /**
   * Forget the matching recorded calls — without criteria, everything
   * recorded so far. Recording continues either way.
   *
   * Takes what `waitFor` takes: `reset({ path: 'users' })`, or a predicate.
   */
  reset: (match?: RecordedHooksMatch) => void
  /**
   * Resolve with the matching recorded calls, as soon as there are `count` of
   * them — counting the ones already in the record. Rejects on timeout. This
   * is what replaces a `sleep` before reading the record:
   *
   * ```ts
   * const [context] = await calls.waitFor({ context: { method: 'create' } })
   * ```
   *
   * With `count: 0` it waits for the opposite and resolves with `[]`: nothing
   * matching within the window, rejecting the moment something does — so a
   * test that proves a call did *not* happen fails immediately instead of
   * sleeping and asserting afterwards. A match that is already in the record
   * rejects right away; `since: 'now'` is how to ignore it.
   *
   * A wait started *before* the action it watches can reject while that action
   * is still running, and node logs an unhandled rejection for the moment
   * between the rejection and your `await`. It is harmless — attach the
   * expectation before the action to keep the log quiet.
   */
  waitFor: (options?: RecordedHooksWaitOptions) => Promise<HookContext[]>
  /**
   * Stop recording. The hook stays registered — Feathers has no way to
   * unregister one — it just stops collecting, so what was recorded before
   * stays readable and `start()` picks it back up.
   */
  stop: () => void
  /**
   * Record again after `stop()` — for the calls a test makes to set itself up
   * and does not want in the record. Recording is on to begin with.
   */
  start: () => void
}
/**
 * Record every call that passes through a service or an application, so a test
 * can assert what was requested — and how often — without standing up a spy per
 * method.
 *
 * Pass an app to record all of its services, or a single service to record just
 * that one; `path`, `method` and `type` narrow it further. The typical use is
 * proving a cache, a debounce or a local-first store did *not* go to the
 * server, which is awkward to assert on results alone.
 *
 * The record is indexed by hook type and method — `calls.before.get` — and
 * every entry is a plain array, so it composes with any predicate, `isContext`
 * included. So do `reset()` and `waitFor()`.
 *
 * By default the live `HookContext` is recorded. That object keeps mutating as
 * the call travels through the remaining hooks, so if you assert on `data` or
 * `params.query` *after* the call resolved, pass `snapshot: true`.
 *
 * @example
 * ```ts
 *
 *
 * const calls = recordHooks(app)
 *
 * await app.service('users').find({ query: { name: 'jane' } })
 *
 * expect(calls.before.find).toHaveLength(1)
 * expect(calls.before.create).toHaveLength(0)
 * expect(calls.before.find[0].params.query).toEqual({ name: 'jane' })
 * ```
 *
 * @example
 * ```ts
 *
 *
 * // one method of one service, or everything that service was asked to do
 * expect(calls.before.find.filter(isContext({ path: 'users' }))).toHaveLength(1)
 * expect(calls.all.filter(isContext({ path: 'users' }))).toHaveLength(1)
 *
 * // and forget one service's calls without forgetting the rest
 * calls.reset({ path: 'users' })
 * ```
 *
 * @example
 * ```ts
 * // wait for a call instead of sleeping and then reading the record
 * const [context] = await calls.waitFor({ context: { method: 'create' } })
 * expect(context.data).toEqual({ name: 'jane' })
 *
 * // prove nothing reaches the service at all, failing the moment one does
 * calls.reset()
 * await readThroughCache()
 * await calls.waitFor({ context: { path: 'users' }, count: 0 })
 * ```
 *
 * @example
 * ```ts
 * // "one request went out, prove no second one follows" — the baseline is
 * // taken here, so the first call stays in the record as evidence
 * await app.service('users').find({})
 * const quiet = calls.waitFor({
 *   context: { path: 'users' },
 *   count: 0,
 *   since: 'now',
 * })
 * await readThroughCache()
 * await quiet
 * expect(calls.before.find).toHaveLength(1)
 * ```
 *
 * @example
 * ```ts
 * // the same thing after the fact: let a debounce settle, then assert the
 * // exact number of calls — no `sleep`, and it returns them
 * await store.load()
 * await store.load()
 *
 * const finds = await calls.waitFor({
 *   context: { path: 'users' },
 *   quietFor: 250,
 * })
 * expect(finds).toHaveLength(1)
 * ```
 *
 * @example
 * ```ts
 * // both sides of two methods of one service, with `data` as it came in
 * const calls = recordHooks(app, {
 *   path: 'users',
 *   method: ['create', 'patch'],
 *   type: ['before', 'after'],
 *   snapshot: true,
 * })
 *
 * await app.service('users').create({ name: 'jane' })
 *
 * expect(calls.before.create[0].data).toEqual({ name: 'jane' })
 * expect(calls.after.create[0].result).toMatchObject({ name: 'jane' })
 * ```
 *
 * @see https://utils.feathersjs.com/testing/record-hooks.html
 */
export declare function recordHooks(
  target: RecordHooksTarget,
  options?: RecordHooksOptions,
): RecordedHooks
ArgumentTypeDescription
targetRecordHooksTarget
optionsRecordHooksOptions

Released under the MIT License.