Testing Package
On this page 47
A comprehensive testing framework built on Bun's native test runner, providing assertions, database testing utilities, feature testing, factories, and mocking capabilities.
Installation
bun add @stacksjs/testing
Basic Usage
import { describe, it, expect, beforeAll, afterAll } from '@stacksjs/testing'
describe('User', () => {
it('should create a new user', async () => {
const user = await User.create({ name: 'John', email: 'john@test.com' })
expect(user.name).toBe('John')
expect(user.email).toBe('john@test.com')
})
})
Test Structure
Describe and It
import { describe, it, expect } from '@stacksjs/testing'
describe('Calculator', () => {
describe('add', () => {
it('should add two positive numbers', () => {
expect(add(2, 3)).toBe(5)
})
it('should handle negative numbers', () => {
expect(add(-1, 1)).toBe(0)
})
})
describe('divide', () => {
it('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5)
})
it('should throw on division by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero')
})
})
})
Lifecycle Hooks
import { describe, it, beforeAll, beforeEach, afterAll, afterEach } from '@stacksjs/testing'
describe('Database Tests', () => {
beforeAll(async () => {
// Run once before all tests in this describe block
await database.connect()
})
beforeEach(async () => {
// Run before each test
await database.beginTransaction()
})
afterEach(async () => {
// Run after each test
await database.rollback()
})
afterAll(async () => {
// Run once after all tests
await database.disconnect()
})
it('should insert record', async () => {
await User.create({ name: 'John' })
const count = await User.count()
expect(count).toBe(1)
})
})
Assertions
Basic Matchers
import { expect } from '@stacksjs/testing'
// Equality
expect(value).toBe(42) // Strict equality
expect(value).toEqual({ a: 1 }) // Deep equality
expect(value).not.toBe(0) // Negation
// Truthiness
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeUndefined()
expect(value).toBeDefined()
// Numbers
expect(value).toBeGreaterThan(5)
expect(value).toBeGreaterThanOrEqual(5)
expect(value).toBeLessThan(10)
expect(value).toBeLessThanOrEqual(10)
expect(value).toBeCloseTo(0.3, 2) // For floating point
// Strings
expect(str).toContain('hello')
expect(str).toMatch(/pattern/)
expect(str).toHaveLength(5)
// Arrays
expect(arr).toContain('item')
expect(arr).toHaveLength(3)
expect(arr).toEqual(['a', 'b', 'c'])
// Objects
expect(obj).toHaveProperty('name')
expect(obj).toHaveProperty('address.city', 'NYC')
expect(obj).toMatchObject({ name: 'John' })
Exceptions
// Expect function to throw
expect(() => throwingFunction()).toThrow()
expect(() => throwingFunction()).toThrow('error message')
expect(() => throwingFunction()).toThrow(CustomError)
expect(() => throwingFunction()).toThrow(/pattern/)
// Async throw
await expect(async () => await asyncThrow()).rejects.toThrow()
Async Assertions
// Promise resolves
await expect(promise).resolves.toBe(value)
await expect(promise).resolves.toEqual({ data: 'value' })
// Promise rejects
await expect(promise).rejects.toThrow('error')
await expect(promise).rejects.toBeInstanceOf(Error)
Type Assertions
expect(value).toBeInstanceOf(Date)
expect(value).toBeInstanceOf(CustomClass)
expect(typeof value).toBe('string')
expect(typeof value).toBe('number')
Database Testing
Using Test Database
import { describe, expect, it } from '@stacksjs/testing'
import { useTransaction } from '@stacksjs/testing/database'
describe('User Model', () => {
// Wrap each test in a transaction that rolls back
useTransaction()
it('should create user', async () => {
const user = await User.create({ name: 'Test User', email: 'test@test.com' })
expect(user.id).toBeDefined()
// Automatically rolled back after test
})
})
Database Assertions
import { assertDatabaseHas, assertDatabaseMissing } from '@stacksjs/testing/database'
it('should save user to database', async () => {
await User.create({ name: 'John', email: 'john@test.com' })
// Assert record exists
await assertDatabaseHas('users', {
email: 'john@test.com'
})
})
it('should delete user', async () => {
const user = await User.create({ name: 'John', email: 'john@test.com' })
await user.delete()
// Assert record doesn't exist
await assertDatabaseMissing('users', {
email: 'john@test.com'
})
})
Database Count Assertions
import { assertDatabaseCount } from '@stacksjs/testing/database'
it('should have correct number of users', async () => {
await User.create({ name: 'User 1' })
await User.create({ name: 'User 2' })
await assertDatabaseCount('users', 2)
})
Soft Delete Assertions
import { assertSoftDeleted, assertNotSoftDeleted } from '@stacksjs/testing/database'
it('should soft delete user', async () => {
const user = await User.create({ name: 'John' })
await user.delete() // Soft delete
await assertSoftDeleted('users', { id: user.id })
})
it('should restore user', async () => {
const user = await User.create({ name: 'John' })
await user.delete()
await user.restore()
await assertNotSoftDeleted('users', { id: user.id })
})
DynamoDB Testing
Table-level fixtures only. Point AWS_ENDPOINT_URL at a DynamoDB you started
yourself - DynamoDB Local, or a real region:
import { afterAll, beforeAll } from 'bun:test'
import { createStacksTable, deleteStacksTable } from '@stacksjs/testing/dynamodb'
beforeAll(async () => {
await createStacksTable()
})
afterAll(async () => {
await deleteStacksTable()
})
There are no item assertions. The DynamoDBClient in @stacksjs/ts-cloud
covers createTable, deleteTable and describeTable, and no data-plane
operations, so assert against DynamoDB through whichever client your code
already uses to write to it.
Feature Testing
HTTP Testing
import { describe, it, expect } from '@stacksjs/testing'
describe('API Routes', () => {
it('should return users list', async () => {
const response = await fetch('http://localhost:3000/api/users')
const data = await response.json()
expect(response.status).toBe(200)
expect(data.users).toBeInstanceOf(Array)
})
it('should create user', async () => {
const response = await fetch('http://localhost:3000/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'John', email: 'john@test.com' })
})
expect(response.status).toBe(201)
const data = await response.json()
expect(data.user.name).toBe('John')
})
})
Authenticated Requests
import { actingAs } from '@stacksjs/testing'
it('should access protected route', async () => {
const user = await User.create({ name: 'John', email: 'john@test.com' })
const token = await actingAs(user)
const response = await fetch('http://localhost:3000/api/profile', {
headers: { 'Authorization': `Bearer ${token}` }
})
expect(response.status).toBe(200)
})
Factories
Defining Factories
A factory is declared once, on the model, per attribute:
// app/Models/User.ts
export default defineModel({
name: 'User',
attributes: {
name: { fillable: true, factory: faker => faker.person.fullName() },
email: { fillable: true, unique: true, factory: faker => faker.internet.email() },
password: { fillable: true, factory: faker => faker.internet.password() },
},
})
Using Factories
import { factory } from '@stacksjs/testing/database'
it('should create user with factory', async () => {
// Create single record
const user = await factory('User').create()
expect(user.id).toBeDefined()
// Create multiple records
const users = await factory('User').createMany(5)
expect(users).toHaveLength(5)
// Create with overrides
const admin = await factory('User').create({
role: 'admin',
email: 'admin@test.com'
})
expect(admin.role).toBe('admin')
// Attributes only, nothing written
const attrs = await factory('User').make({ name: 'Test User' })
expect(attrs.name).toBe('Test User')
})
Factory States
A state is an override object at the call site, not a name registered up front:
const admin = await factory('User').create({ role: 'admin' })
const unverified = await factory('User').create({ email_verified_at: null })
For a state used across a file, give the object a name where you use it:
const admin = { role: 'admin', email_verified_at: new Date() }
const one = await factory('User').create(admin)
const many = await factory('User').createMany(3, admin)
Factory Relationships
belongsTo columns are filled automatically - the generator creates the parent
row and uses its id, because that is what buddy seed has to do to satisfy the
foreign key:
// Post belongsTo User, so post.user_id points at a real row
const post = await factory('Post').create()
The other direction is explicit, since only the test knows how many children it wants:
const post = await factory('Post').create()
const comments = await factory('Comment').createMany(3, { post_id: post.id })
Mocking
Mock Functions
import { mock, spyOn } from '@stacksjs/testing'
it('should call function with correct args', () => {
const mockFn = mock(() => 'result')
const result = mockFn('arg1', 'arg2')
expect(mockFn).toHaveBeenCalled()
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2')
expect(mockFn).toHaveBeenCalledTimes(1)
expect(result).toBe('result')
})
Spy on Methods
it('should spy on method', () => {
const obj = {
method: () => 'original'
}
const spy = spyOn(obj, 'method')
obj.method()
expect(spy).toHaveBeenCalled()
})
Mock Return Values
const mockFn = mock()
.mockReturnValue('value')
.mockReturnValueOnce('first call')
.mockImplementation((x) => x * 2)
expect(mockFn(5)).toBe(10)
Mock Modules
import { mock } from '@stacksjs/testing'
// Mock entire module
mock.module('@stacksjs/email', () => ({
send: mock(() => Promise.resolve({ sent: true }))
}))
// In test
it('should send email', async () => {
const result = await sendWelcomeEmail('user@test.com')
expect(result.sent).toBe(true)
})
Time Testing
Freezing Time
import { afterEach, freezeTime, travelTo, useRealTime } from '@stacksjs/testing'
// Not optional. The system clock is process-wide and bun does not roll it back
// between test files, so a frozen clock leaks into every later suite in the
// run - where it surfaces as a token that is inexplicably expired, a long way
// from the test that caused it.
afterEach(useRealTime)
it('should test time-dependent code', () => {
// Freeze time. Pass a Date, an ISO string, or an epoch number.
freezeTime('2024-01-15T10:00:00Z')
const now = new Date()
expect(now.toISOString()).toBe('2024-01-15T10:00:00.000Z')
// Travel to a specific time; the clock stays frozen there.
travelTo(new Date('2024-06-01'))
const future = new Date()
expect(future.getMonth()).toBe(5) // June
})
Test Utilities
Skip and Only
// Skip a test
it.skip('should be skipped', () => {
// This test won't run
})
// Run only this test
it.only('should run only this', () => {
// Only this test runs
})
// Skip describe block
describe.skip('Skipped Suite', () => {
// All tests skipped
})
Todo Tests
it.todo('should implement this feature')
Test Timeout
it('should complete within timeout', async () => {
await longRunningOperation()
}, 10000) // 10 second timeout
Running Tests
CLI Commands
# Run all tests
bun test
# Run specific file
bun test tests/user.test.ts
# Run tests matching pattern
bun test --filter "User"
# Watch mode
bun test --watch
# With coverage
bun test --coverage
Configuration
// bunfig.toml
[test]
# Glob patterns for test files
preload = ["./tests/setup.ts"]
timeout = 5000
coverage = true
coverageThreshold = {
line = 80
function = 80
branch = 75
}
Edge Cases
Testing Async Errors
it('should handle async errors', async () => {
await expect(async () => {
await failingAsyncFunction()
}).rejects.toThrow('Expected error')
})
Testing Event Emitters
it('should emit event', async () => {
const emitter = new EventEmitter()
const handler = mock()
emitter.on('event', handler)
emitter.emit('event', { data: 'value' })
expect(handler).toHaveBeenCalledWith({ data: 'value' })
})
Testing Race Conditions
it('should handle concurrent operations', async () => {
const results = await Promise.all([
incrementCounter(),
incrementCounter(),
incrementCounter()
])
expect(await getCounter()).toBe(3)
})
API Reference
Test Functions
| Function | Description |
|---|---|
describe(name, fn) | Group tests |
it(name, fn, timeout?) | Define test |
expect(value) | Create assertion |
beforeAll(fn) | Run before all tests |
beforeEach(fn) | Run before each test |
afterEach(fn) | Run after each test |
afterAll(fn) | Run after all tests |
Database Assertions
| Function | Description |
|---|---|
assertDatabaseHas(table, data) | Assert record exists |
assertDatabaseMissing(table, data) | Assert record missing |
assertDatabaseCount(table, count) | Assert row count |
assertSoftDeleted(table, data) | Assert soft deleted |
assertNotSoftDeleted(table, data) | Assert not soft deleted |
Matchers
| Matcher | Description |
|---|---|
toBe(value) | Strict equality |
toEqual(value) | Deep equality |
toBeTruthy() | Truthy value |
toBeFalsy() | Falsy value |
toBeNull() | Is null |
toBeUndefined() | Is undefined |
toBeDefined() | Is defined |
toContain(item) | Contains item |
toHaveLength(n) | Has length n |
toThrow(msg?) | Throws error |
toBeInstanceOf(class) | Instance of class |
toHaveProperty(key) | Has property |
toMatchObject(obj) | Partial object match |
Mock Functions
| Method | Description |
|---|---|
mock(fn?) | Create mock function |
spyOn(obj, method) | Spy on method |
mockReturnValue(val) | Set return value |
mockImplementation(fn) | Set implementation |
toHaveBeenCalled() | Was called |
toHaveBeenCalledWith(...args) | Called with args |
toHaveBeenCalledTimes(n) | Called n times |