Testing
WIPauthCreateTest() - a fluent builder with sensible defaults. Plus the adapter compliance suite for custom stores.
authCreateTest()
import { authCreateTest } from '@gentleduck/auth/test'
const auth = authCreateTest()
// Defaults: AuthMemoryAdapter, BearerTransport, AuthMemoryLimiter, AuthScryptHasher.
That's the entire setup for 90% of tests. Need to wire a real provider? Pass it in:
import { password } from '@gentleduck/auth/providers/password'
const auth = authCreateTest({
providers: [
password({
passwords: undefined, // filled in for you by authCreateTest
findIdentityByEmail: undefined, // filled in for you
}),
],
})
The factory wires the typed defaults so you don't repeat boilerplate.
End-to-end test pattern
import { describe, expect, it } from 'vitest'
import { authCreateTest } from '@gentleduck/auth/test'
import { password } from '@gentleduck/auth/providers/password'
describe('sign-in flow', () => {
it('signs in with a valid password', async () => {
const auth = authCreateTest({
providers: [password({})],
})
// Seed an identity
const identity = await auth.identities.create({
email: 'ada@example.com',
profile: { displayName: 'Ada' },
})
await auth.passwords.create({
identityId: identity.id,
secret: 'correct horse battery staple',
})
// Sign in
const intents = await auth.flows.signIn({
providerId: 'password',
input: { email: 'ada@example.com', password: 'correct horse battery staple' },
})
expect(intents).toContainEqual(
expect.objectContaining({ kind: 'startSession' }),
)
})
it('rate-limits after 5 failed attempts', async () => {
const auth = authCreateTest({
providers: [password({})],
limiter: { max: 5, windowMs: 60_000 },
})
for (let i = 0; i < 5; i++) {
await expect(auth.flows.signIn({
providerId: 'password',
input: { email: 'ada@example.com', password: 'wrong' },
})).rejects.toMatchObject({ code: 'AUTH/INVALID_CREDENTIALS' })
}
await expect(auth.flows.signIn({
providerId: 'password',
input: { email: 'ada@example.com', password: 'wrong' },
})).rejects.toMatchObject({ code: 'AUTH/RATE_LIMITED' })
})
})
Time travel
Tests that need to advance the clock (session expiry, magic-link TTL, rate-limit windows) can inject a clock:
const clock = { now: () => new Date('2026-05-27T10:00:00Z').getTime() }
const auth = authCreateTest({ clock })
// Mint a magic link
await auth.flows.beginPasswordReset({ email: 'ada@example.com' })
// Jump forward 11 minutes (past the 10-minute TTL)
clock.now = () => new Date('2026-05-27T10:11:00Z').getTime()
// Token is now expired
await expect(auth.flows.completePasswordReset({ token, newPassword: 'new' }))
.rejects.toMatchObject({ code: 'AUTH/RECOVERY_TOKEN_EXPIRED' })
Capturing outbound channels
Use AuthTestChannel to assert on what was sent:
import { AuthTestChannel } from '@gentleduck/auth/channels/console'
import { magicLink } from '@gentleduck/auth/providers/magic-link'
const channel = new AuthTestChannel({ kind: 'email' })
const auth = authCreateTest({
providers: [magicLink({ channels: { email: channel } })],
})
await auth.providers.beginProvider('magic-link', { email: 'ada@example.com' })
expect(channel.outbox).toHaveLength(1)
expect(channel.outbox[0]).toMatchObject({
templateId: 'magic-link',
vars: expect.objectContaining({ email: 'ada@example.com' }),
})
The outbox is a public array - assert on it directly.
Adapter compliance
If you write a custom adapter, run the compliance suite:
import {
authRunCredentialStoreCompliance,
authRunIdentityStoreCompliance,
authRunSessionStoreCompliance,
} from '@gentleduck/auth/adapters/__compliance__'
import { describe } from 'vitest'
import { MyCustomAdapter } from './my-adapter'
describe('MyCustomAdapter', () => {
authRunIdentityStoreCompliance(() => new MyCustomAdapter().identities)
authRunSessionStoreCompliance(() => new MyCustomAdapter().sessions)
authRunCredentialStoreCompliance(() => new MyCustomAdapter().credentials)
})
Each suite is 80-150 tests covering the subtle correctness requirements
that bite every adapter ever shipped. Every shipped adapter
(Memory, Redis, the Drizzle reference impls) passes the same matrix.
Storybook decorator
import { authWithStorybook } from '@gentleduck/auth/client/react/storybook'
export const SignIn = {
decorators: [authWithStorybook({ baseUrl: '/auth', initialState: null })],
render: () => <SignInForm />,
}
export const Authed = {
decorators: [authWithStorybook({
baseUrl: '/auth',
initialState: { session: ..., identity: ... },
})],
render: () => <UserMenu />,
}
The decorator wraps the story in an <AuthProvider> backed by a mock
client. Pass initialState to start in a signed-in state.
Mocking the network
For client-side tests that don't need a real server, point the client at a stub:
import { authCreateClient } from '@gentleduck/auth/client/vanilla'
const client = authCreateClient({
baseUrl: '',
fetch: async (url, init) => {
if (url === '/auth/signin') {
return Response.json({
session: { id: 's1', expiresAt: '...' },
identity: { id: 'i1', email: 'ada@example.com', profile: {} },
})
}
return new Response(null, { status: 404 })
},
})
await client.signIn({ providerId: 'password', input: { email: 'ada@example.com', password: 'x' } })
For React tests, MSW is the recommended path - intercept the JSON endpoints and assert against the mocked responses.