Skip to content

Testing

Assert recorded application behavior with storage-backed helpers and the Japa plugin.

Updated View as Markdown

The @rikology/adonisjs-periscope/testing subpath provides storage-backed helpers for application and integration tests. They flush the recorder before reading, so assertions do not depend on the ambient batch rotation or an arbitrary sleep.

Resolve the recorder

The provider binds Recorder as a container singleton. The service subpath resolves that same binding after the application has booted:

import recorder from '@rikology/adonisjs-periscope/services/recorder'

Code that already has an AdonisJS application can resolve it directly:

import { Recorder } from '@rikology/adonisjs-periscope'

const recorder = await app.container.make(Recorder)

The provider must be registered in adonisrc.ts. Use a durable or memory store according to the scope of the test; the helpers operate through the configured PeriscopeStore contract.

Matchers

findEntries, assertRecorded, and assertNotRecorded accept a RecordedEntryMatcher:

type RecordedEntryMatcher = {
  type?: EntryType
  application?: string
  batchId?: string
  tags?: readonly string[]
  predicate?: (entry: StoredEntry) => boolean
}

All supplied fields use AND semantics. Every requested tag must be present. The optional predicate runs after the storage-backed fields have narrowed the result.

Helpers

flushAndWait

function flushAndWait(
  recorder: Recorder,
  predicate?: (entries: StoredEntry[]) => boolean,
  options?: { timeoutMs?: number; intervalMs?: number }
): Promise<StoredEntry[]>

flushAndWait flushes, reads all stored entries, and repeats until the predicate accepts them. The default predicate accepts the first non-empty result. The default timeout is 2,000 ms and the default polling interval is 10 ms. Re-flushing on every pass makes late request completion and ambient entries visible as soon as they arrive.

const entries = await flushAndWait(
  recorder,
  (current) => current.some((entry) => entry.type === EntryType.REQUEST),
  { timeoutMs: 3_000 }
)

findEntries

function findEntries(recorder: Recorder, matcher: RecordedEntryMatcher): Promise<StoredEntry[]>

findEntries pages through every matching entry rather than returning only the first storage page. It does not flush or wait.

const failedRequests = await findEntries(recorder, {
  type: EntryType.REQUEST,
  application: 'billing-api',
  tags: ['status:5xx'],
})

assertRecorded

function assertRecorded(
  recorder: Recorder,
  matcher: RecordedEntryMatcher,
  options?: { timeoutMs?: number; intervalMs?: number }
): Promise<StoredEntry[]>

assertRecorded waits until at least one matching entry has settled in storage and returns every match from the successful poll.

const [exception] = await assertRecorded(recorder, {
  type: EntryType.EXCEPTION,
  predicate: (entry) => entry.content.name === 'PaymentDeclinedError',
})

assertNotRecorded

function assertNotRecorded(
  recorder: Recorder,
  matcher: RecordedEntryMatcher,
  options?: { timeoutMs?: number; intervalMs?: number }
): Promise<void>

A negative assertion must account for work that settles late. assertNotRecorded therefore holds the complete timeout window, re-flushing and polling throughout, and fails immediately if a match appears. Reduce timeoutMs deliberately when a shorter observation window is sufficient.

await assertNotRecorded(
  recorder,
  { type: EntryType.QUERY, tags: ['connection:analytics'] },
  { timeoutMs: 500 }
)

clearRecorded

function clearRecorded(recorder: Recorder, application?: string): Promise<void>

Cleanup is muted so the store operation cannot record itself. Omit application to clear all recordings, or pass a stable application name when tests share a store:

await clearRecorded(recorder, 'billing-api')

Clearing recordings leaves monitored tags and flags intact because those rows represent operator intent rather than captured data.

Japa plugin

Add periscopePlugin to the plugin array in an AdonisJS test bootstrap. Importing the recorder service is the simplest way to supply the booted singleton:

// tests/bootstrap.ts
import app from '@adonisjs/core/services/app'
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
import type { Config } from '@japa/runner/types'
import recorder from '@rikology/adonisjs-periscope/services/recorder'
import { periscopePlugin } from '@rikology/adonisjs-periscope/testing'

export const plugins: Config['plugins'] = [
  pluginAdonisJS(app),
  periscopePlugin({
    recorder,
    autoClear: true,
    application: 'billing-api',
    timeoutMs: 2_000,
    intervalMs: 10,
  }),
]

The plugin decorates each Japa test context with recorder-bound periscope helpers. autoClear runs before every test. When application is set, automatic cleanup and periscope.clearRecorded() default to that application; passing an argument overrides it.

import { test } from '@japa/runner'
import { EntryType } from '@rikology/adonisjs-periscope'
import type { PeriscopeTestContext } from '@rikology/adonisjs-periscope/testing'

test('does not query the legacy application', async (context) => {
  const { periscope } = context as typeof context & PeriscopeTestContext

  await context.client.get('/invoices')
  await periscope.assertNotRecorded({
    type: EntryType.QUERY,
    application: 'legacy-api',
  })
})

Assert a correlated request batch

HTTP completion can be emitted after the API client receives its response. Poll for the request entry, then read its batch to prove that its child work shares the same correlation ID:

import { test } from '@japa/runner'
import { EntryType } from '@rikology/adonisjs-periscope'
import recorder from '@rikology/adonisjs-periscope/services/recorder'
import { flushAndWait } from '@rikology/adonisjs-periscope/testing'

test('records the invoice request and its query in one batch', async ({ client, assert }) => {
  const response = await client.post('/invoices').json({ customerId: 42 })
  response.assertStatus(201)

  const entries = await flushAndWait(recorder, (current) =>
    current.some(
      (entry) =>
        entry.type === EntryType.REQUEST &&
        entry.content.url === '/invoices' &&
        entry.content.status === 201
    )
  )
  const request = entries.find(
    (entry) => entry.type === EntryType.REQUEST && entry.content.url === '/invoices'
  )!
  const batch = await recorder.store.batch(request.batchId)

  assert.include(request.tags, 'status:201')
  assert.isTrue(batch.some((entry) => entry.type === EntryType.QUERY))
  assert.isTrue(batch.every((entry) => entry.batchId === request.batchId))
})
Navigation

Type to search…

↑↓ navigate↵ selectEsc close