Testing
Run unit tests inside a fake context store with runWithContext and enterContext from @adonis-agora/context/testing.
Code that reads Context.userRef() or Context.tenantId() needs an active context to read from. In production that context comes from the HTTP middleware (or a Context.run in a worker / command); in a unit test there is no request, so you build a fake one. The @adonis-agora/context/testing subpath gives you two helpers to do exactly that, with sensible defaults so you set only the fields your test cares about.
Install
The helpers ship inside the core package as the @adonis-agora/context/testing subpath — there is nothing extra to install:
npm i @adonis-agora/contextThe subpath re-uses the core Context under the hood, so the fake store it builds is the very same ALS store your production code reads from — there is no mock, just a real store with values you chose.
runWithContext(partial, fn)
runWithContext runs fn inside a fake store built from a partial. Any field you omit defaults sensibly, and traceId is auto-filled with a random W3C-shaped id when you do not provide one — so you only specify what the test is actually about:
import { runWithContext } from '@adonis-agora/context/testing'
import { Context } from '@adonis-agora/context'
import { test } from '@japa/runner'
test('reads the tenant from context', () => {
runWithContext({ tenantId: 't1', userRef: { type: 'user', id: 7 } }, () => {
assert.equal(Context.tenantId(), 't1')
assert.deepEqual(Context.userRef(), { type: 'user', id: 7 })
assert.isDefined(Context.traceId()) // auto-filled
})
})It mirrors Context.run: the fake store is active for the duration of fn and torn down when fn returns (Context.get() is undefined again afterwards). That callback boundary makes it the right choice for synchronous assertions and for tests where everything you care about happens inside the callback.
runWithContext returns whatever fn returns, so you can assert on the result of the code under test:
test('stamps the causer on an audit row', () => {
const row = runWithContext(
{ userRef: { type: 'user', id: 42 } },
() => auditService.buildRow({ change: 'updated' }),
)
assert.deepEqual(row.causer, { type: 'user', id: 42 })
})Because fn can be async, you can await it — but be aware that run-style scoping tears the store down when the callback returns, not when the awaited work settles. If the code under test reads the context after an await that escapes the callback, reach for enterContext instead.
enterContext(partial)
enterContext builds the same fake store but installs it with enterWith — so it survives past the call, with no callback to wrap. This is the helper for code that reads the context after the setup returns, typically across an await:
import { enterContext } from '@adonis-agora/context/testing'
import { Context } from '@adonis-agora/context'
import { test } from '@japa/runner'
test('keeps the context across an await', async () => {
enterContext({ tenantId: 't1', userRef: { type: 'user', id: 7 } })
// No callback wrapping the rest of the test — the context is simply active now.
await service.doSomethingAsync()
assert.equal(Context.tenantId(), 't1')
})The relationship between the two helpers mirrors the core API exactly: runWithContext is to Context.run what enterContext is to Context.enterWith. Use runWithContext when you have a clean callback to assert inside; use enterContext when you want the fake context to persist through the rest of the test body.
Both helpers accept a PartialContextStore — a Partial<ContextStore>, so every field including traceId is optional. Pass {} to run inside an otherwise-empty context whose only populated field is the auto-generated traceId. Augmented (Level 1) fields are accepted too, since the partial is typed against your augmented ContextStore.
Testing consumer code
Most of the time you are not testing the context itself — you are testing a service that reads it. Wrap the call in a helper and assert on its output:
import { runWithContext } from '@adonis-agora/context/testing'
import { test } from '@japa/runner'
test.group('OrdersService', () => {
test('scopes the query to the current tenant', () => {
const calls: any[] = []
repo.find = (q) => { calls.push(q); return [] }
runWithContext({ tenantId: 'acme' }, () => service.listOrders())
assert.deepInclude(calls[0], { tenantId: 'acme' })
})
test('degrades cleanly with no context', () => {
// Called outside any context: accessors return undefined, never throw.
assert.doesNotThrow(() => service.listOrders())
})
})That second test captures an important property: the accessors never throw outside a context, they return undefined. Testing the no-context path is as simple as not wrapping the call.
Resetting state between tests
A couple of pieces of @adonis-agora/context are process-global, so a test that touches them can leak into the next one. Reset them in a hook.
Carrier / serialize config
If your suite exercises the cross-process config — defineConfig({ carrier, serialize, deserialize, baggage, enrichers }) pushed via Context.configure — remember it is replaced wholesale on each configure, and a second differing config emits a warning (see Customization → cross-process carrier). Reset it with Context.resetConfig():
import { Context } from '@adonis-agora/context'
import { test } from '@japa/runner'
test.group('carrier config', (group) => {
group.each.teardown(() => {
Context.resetConfig() // back to default carrier behaviour
})
})The one-shot set warning
Context.set() outside a context warns once per process. If a test deliberately asserts that path (or you want a clean slate), re-arm the one-shot with Context.resetSetWarning():
import { Context } from '@adonis-agora/context'
group.each.setup(() => {
Context.resetSetWarning()
})You only need these resets in suites that actually change the config or assert the warnings. Tests that just build a fake store with runWithContext / enterContext do not touch the process-global state and need no reset.
Next steps
- Getting Started — the
runvsenterWithdistinction the helpers mirror - Cross-Process — what the carrier config you might be resetting actually does
- Customization — the five customization levels and
resetConfig()in context