Testing
TestCase boots your app, provides HTTP helpers, and wraps each test in a rolled-back database transaction for full isolation.
Quick Start
import { describe, test, expect } from 'bun:test'
import { TestCase, Factory } from '@strav/testing'
import User from '../app/models/user'
const UserFactory = Factory.define(User, (seq) => ({
pid: crypto.randomUUID(),
name: `User $\{seq\}`,
email: `user-$\{seq\}@test.com`,
passwordHash: 'hashed',
}))
const t = await TestCase.boot({
auth: true,
domain: 'example.com',
routes: () => import('../start/api_routes'),
})
describe('Users API', () => {
test('list users', async () => {
const user = await UserFactory.create()
await t.actingAs(user)
const res = await t.get('/api/users')
expect(res.status).toBe(200)
})
// No cleanup needed — transaction auto-rollbacks after each test
})
TestCase
Boot
TestCase.boot() registers beforeEach, afterEach, and afterAll hooks automatically:
const t = await TestCase.boot({
routes: () => import('../start/api_routes'),
})
For manual control, use the instance methods directly:
const t = new TestCase()
beforeAll(() => t.setup())
afterAll(() => t.teardown())
beforeEach(() => t.beforeEach())
afterEach(() => t.afterEach())
Options
const t = await TestCase.boot({
// Load route files (called once during setup)
routes: () => import('../start/api_routes'),
// Boot Auth + SessionManager, create their tables (default: false)
auth: true,
// Set Auth.useResolver() for loading users by ID
userResolver: async (id) => User.find(id as string),
// Boot ViewEngine (default: false)
views: true,
// Wrap each test in a transaction (default: true)
transaction: true,
// Base domain for subdomain extraction (default: 'localhost')
domain: 'example.com',
})
HTTP Helpers
All helpers callrouter.handle() directly — no HTTP server needed, no port conflicts:
const res = await t.get('/api/users')
const res = await t.post('/api/users', { name: 'Alice' })
const res = await t.put('/api/users/1', { name: 'Bob' })
const res = await t.patch('/api/users/1', { name: 'Charlie' })
const res = await t.delete('/api/users/1')
Bodies are automatically serialized as JSON. Custom headers can be passed as the last argument:
const res = await t.get('/api/users', { 'X-Custom': 'value' })
const res = await t.post('/api/users', body, { 'X-Custom': 'value' })
Authentication
// Authenticate as a user (creates a real AccessToken)
const user = await UserFactory.create()
await t.actingAs(user)
// All subsequent requests include the Bearer token
const res = await t.get('/api/profile') // Authenticated
// Clear authentication
t.withoutAuth()
const res = await t.get('/api/profile') // Unauthenticated
Auth state resets automatically after each test (via afterEach).
Custom Headers
t.withHeaders({ 'Accept-Language': 'fr' })
const res = await t.get('/api/content')
Headers reset after each test.
Subdomain Testing
Test subdomain-based APIs (e.g.,api.example.com) using the subdomain helpers:
// Test routes on api.example.com
await t.onSubdomain('api').get('/users')
await t.onSubdomain('api').post('/posts', { title: 'Hello' })
// Test tenant-specific routes (tenant.example.com)
await t.onSubdomain('acme').get('/dashboard')
// ctx.params.tenant === 'acme' in subdomain routes
// Clear subdomain for main domain requests
t.withoutSubdomain()
await t.get('/health') // example.com/health
Subdomain state resets automatically after each test.
Note: You must setdomain: 'example.com' in TestCase.boot() for subdomain routing to work properly.
Exposed Properties
t.db // Database instance — for direct SQL queries
t.router // Router instance — for custom assertions
t.config // Configuration instance — for reading config values
Transaction Isolation
By default, every test runs inside a database transaction that rolls back when the test completes. This means:
- Tests are fully isolated — data created in one test is invisible to the next
- No
DELETE FROMcleanup needed - Tests can run in any order
- Fast — rollback is cheaper than delete + re-insert
To disable transaction wrapping (e.g., for tests that don't touch the database):
const t = await TestCase.boot({ transaction: false })
Factory
Shared factory definitions
Define factories indatabase/factories/ so both tests and database operations can import them:
database/
factories/
user_factory.ts
post_factory.ts
index.ts # re-exports all factories
// database/factories/user_factory.ts
import { Factory } from '@strav/testing'
import User from '../../app/models/user'
export const UserFactory = Factory.define(User, (seq) => ({
pid: crypto.randomUUID(),
name: `User $\{seq\}`,
email: `user-$\{seq\}@test.com`,
passwordHash: 'hashed',
}))
Then import in tests:
import { UserFactory, PostFactory } from '../database/factories'
Define
import { Factory } from '@strav/testing'
import User from '../app/models/user'
import Post from '../app/models/post'
const UserFactory = Factory.define(User, (seq) => ({
pid: crypto.randomUUID(),
name: `User $\{seq\}`,
email: `user-$\{seq\}@test.com`,
passwordHash: 'hashed',
}))
const PostFactory = Factory.define(Post, (seq) => ({
title: `Post $\{seq\}`,
body: 'Lorem ipsum',
status: 'draft',
}))
The seq argument is an auto-incrementing number (1, 2, 3...) unique to each factory, useful for generating unique values.
Create
// Create and persist a single record
const user = await UserFactory.create()
// With overrides
const admin = await UserFactory.create({ name: 'Admin', role: 'admin' })
// Create multiple
const users = await UserFactory.createMany(5)
const editors = await UserFactory.createMany(3, { role: 'editor' })
Make (No Database)
Build an in-memory instance without persisting:
const user = UserFactory.make()
user._exists // false — not in the database
user.name // 'User 1'
const custom = UserFactory.make({ name: 'Override' })
Reset Sequences
Factory.resetSequences() // Resets all factory counters to 0
API Testing Patterns
Prefix vs Subdomain Routing
Strav supports two approaches for API organization:
1. Path Prefix (Traditional)
// Route registration
router.group({ prefix: '/api' }, (r) => {
r.get('/users', listUsers) // example.com/api/users
r.post('/posts', createPost) // example.com/api/posts
})
// Testing
const res = await t.get('/api/users')
2. Subdomain-based
// Route registration
router.setDomain('example.com')
router.subdomain('api', (r) => {
r.get('/users', listUsers) // api.example.com/users
r.post('/posts', createPost) // api.example.com/posts
})
// Testing
const t = await TestCase.boot({ domain: 'example.com' })
const res = await t.onSubdomain('api').get('/users')
Multi-tenant with Dynamic Subdomains
// Route registration
router.subdomain(':tenant', (r) => {
r.get('/dashboard', (ctx) => {
const tenant = ctx.params.tenant // 'acme', 'corp', etc.
return ctx.json({ tenant })
})
})
// Testing
await t.onSubdomain('acme').get('/dashboard') // acme.example.com/dashboard
await t.onSubdomain('corp').get('/dashboard') // corp.example.com/dashboard
Full Example
import { describe, test, expect } from 'bun:test'
import { TestCase, Factory } from '@strav/testing'
import User from '../app/models/user'
import Post from '../app/models/post'
const UserFactory = Factory.define(User, (seq) => ({
pid: crypto.randomUUID(),
name: `User $\{seq\}`,
email: `user-$\{seq\}@test.com`,
passwordHash: 'hashed',
}))
const PostFactory = Factory.define(Post, (seq) => ({
title: `Post $\{seq\}`,
body: `Content for post $\{seq\}`,
}))
const t = await TestCase.boot({
auth: true,
domain: 'example.com',
userResolver: async (id) => User.find(id as string),
routes: () => import('../start/api_routes'),
})
describe('Posts API', () => {
test('create a post', async () => {
const user = await UserFactory.create()
await t.actingAs(user)
const res = await t.post(`/api/users/$\{user.pid\}/posts`, {
title: 'My Post',
body: 'Hello world',
})
expect(res.status).toBe(201)
const data = await res.json() as any
expect(data.title).toBe('My Post')
})
test('list posts for a user', async () => {
const user = await UserFactory.create()
await t.actingAs(user)
// Create posts with the factory
await PostFactory.create({ userPid: user.pid })
await PostFactory.create({ userPid: user.pid })
const res = await t.get(`/api/users/$\{user.pid\}/posts`)
expect(res.status).toBe(200)
const data = await res.json() as any[]
expect(data).toHaveLength(2)
})
test('unauthenticated request returns 401', async () => {
const res = await t.get('/api/profile')
expect(res.status).toBe(401)
})
test('API on subdomain', async () => {
const user = await UserFactory.create()
await t.actingAs(user)
// Test API routes on api.example.com subdomain
await t.onSubdomain('api')
const res = await t.get('/users') // api.example.com/users
expect(res.status).toBe(200)
})
test('tenant subdomain with parameter', async () => {
const user = await UserFactory.create()
await t.actingAs(user)
// Test tenant routes: acme.example.com/dashboard
// Router should be configured with router.subdomain(':tenant', ...)
const res = await t.onSubdomain('acme').get('/dashboard')
expect(res.status).toBe(200)
// Route handler can access ctx.params.tenant === 'acme'
})
})