Testing
Stacks provides a comprehensive testing framework built on Bun's native test runner. Write unit tests, integration tests, and end-to-end tests with excellent developer experience.
Overview
Testing in Stacks offers:
- Fast execution - Bun's native test runner is blazing fast
- TypeScript support - First-class TypeScript integration
- Rich assertions - Comprehensive assertion library
- Database utilities - Transaction rollback, factories
- HTTP testing - Test API endpoints easily
- Browser testing - E2E with Playwright integration
Quick Start
Running Tests
# Run all tests
bun test
# Run specific file
bun test tests/Unit/UserTest.ts
# Run with pattern
bun test --grep "user registration"
# Run with coverage
bun test --coverage
# Watch mode
bun test --watch
Writing Tests
Basic Structure
// tests/Unit/ExampleTest.ts
import { describe, expect, it, beforeEach, afterEach } from 'bun:test'
describe('Calculator', () => {
let calculator: Calculator
beforeEach(() => {
calculator = new Calculator()
})
it('adds two numbers', () => {
expect(calculator.add(2, 3)).toBe(5)
})
it('subtracts two numbers', () => {
expect(calculator.subtract(5, 3)).toBe(2)
})
describe('division', () => {
it('divides two numbers', () => {
expect(calculator.divide(10, 2)).toBe(5)
})
it('throws on division by zero', () => {
expect(() => calculator.divide(10, 0)).toThrow('Division by zero')
})
})
})
Assertions
import { expect } from 'bun:test'
// Equality
expect(value).toBe(expected) // Strict equality
expect(value).toEqual(expected) // Deep equality
expect(value).not.toBe(unexpected) // Negation
// Truthiness
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeUndefined()
expect(value).toBeDefined()
// Numbers
expect(value).toBeGreaterThan(3)
expect(value).toBeGreaterThanOrEqual(3)
expect(value).toBeLessThan(5)
expect(value).toBeLessThanOrEqual(5)
expect(value).toBeCloseTo(0.3, 5)
// Strings
expect(value).toContain('substring')
expect(value).toMatch(/pattern/)
expect(value).toHaveLength(5)
// Arrays
expect(array).toContain(item)
expect(array).toHaveLength(3)
expect(array).toEqual([1, 2, 3])
// Objects
expect(object).toHaveProperty('key')
expect(object).toHaveProperty('key', value)
expect(object).toMatchObject({ key: value })
// Errors
expect(() => fn()).toThrow()
expect(() => fn()).toThrow('message')
expect(() => fn()).toThrow(ErrorClass)
// Async
await expect(promise).resolves.toBe(value)
await expect(promise).rejects.toThrow()
Test Organization
Directory Structure
tests/
├── Unit/ # Unit tests
│ ├── Models/
│ │ └── UserTest.ts
│ ├── Services/
│ │ └── PaymentServiceTest.ts
│ └── Utils/
│ └── StringTest.ts
├── Feature/ # Integration tests
│ ├── Auth/
│ │ └── LoginTest.ts
│ └── Api/
│ └── UsersTest.ts
├── Browser/ # E2E tests
│ └── CheckoutTest.ts
├── factories/ # Test factories
│ └── UserFactory.ts
└── helpers/ # Test utilities
└── index.ts
Naming Conventions
- Test files:
*Test.tsor*.test.ts - Describe blocks: Feature or class name
- It blocks: Behavior description
Unit Testing
Testing Functions
// src/utils/string.ts
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}
// tests/Unit/Utils/StringTest.ts
import { describe, expect, it } from 'bun:test'
import { capitalize } from '@/utils/string'
describe('capitalize', () => {
it('capitalizes first letter', () => {
expect(capitalize('hello')).toBe('Hello')
})
it('handles empty string', () => {
expect(capitalize('')).toBe('')
})
it('handles already capitalized', () => {
expect(capitalize('Hello')).toBe('Hello')
})
})
Testing Classes
import { describe, expect, it, beforeEach } from 'bun:test'
import { UserService } from '@/services/UserService'
import { MockUserRepository } from '../mocks/MockUserRepository'
describe('UserService', () => {
let service: UserService
let mockRepo: MockUserRepository
beforeEach(() => {
mockRepo = new MockUserRepository()
service = new UserService(mockRepo)
})
it('creates a user', async () => {
const user = await service.create({
name: 'John',
email: 'john@example.com',
})
expect(user.id).toBeDefined()
expect(user.name).toBe('John')
})
it('validates email format', async () => {
await expect(service.create({
name: 'John',
email: 'invalid-email',
})).rejects.toThrow('Invalid email')
})
})
Database Testing
Transaction Rollback
import { describe, it, expect } from 'bun:test'
import { useTransaction } from '@stacksjs/testing'
import { User } from '@/models/User'
describe('User Model', () => {
useTransaction() // Rollback after each test
it('creates user in database', async () => {
const user = await User.create({
name: 'Test User',
email: 'test@example.com',
})
expect(user.id).toBeDefined()
// User exists in DB
const found = await User.find(user.id)
expect(found).not.toBeNull()
})
// Database is rolled back - user no longer exists
})
Factories
// tests/factories/UserFactory.ts
import { Factory } from '@stacksjs/testing'
import { User } from '@/models/User'
export const UserFactory = new Factory(User, {
name: () => faker.person.fullName(),
email: () => faker.internet.email(),
password: () => 'password',
})
// Usage in tests
describe('UserService', () => {
useTransaction()
it('finds user by email', async () => {
const user = await UserFactory.create({
email: 'specific@example.com',
})
const found = await UserService.findByEmail('specific@example.com')
expect(found?.id).toBe(user.id)
})
it('lists all users', async () => {
await UserFactory.createMany(5)
const users = await UserService.all()
expect(users).toHaveLength(5)
})
})
HTTP Testing
API Endpoints
import { describe, expect, it } from 'bun:test'
import { http, useTransaction } from '@stacksjs/testing'
import { UserFactory } from '../factories/UserFactory'
describe('Users API', () => {
useTransaction()
it('lists users', async () => {
await UserFactory.createMany(3)
const response = await http.get('/api/users')
expect(response.status).toBe(200)
const data = await response.json()
expect(data.users).toHaveLength(3)
})
it('creates a user', async () => {
const response = await http.post('/api/users', {
body: {
name: 'New User',
email: 'new@example.com',
password: 'password123',
},
})
expect(response.status).toBe(201)
const data = await response.json()
expect(data.user.email).toBe('new@example.com')
})
it('validates required fields', async () => {
const response = await http.post('/api/users', {
body: { name: 'Test' }, // Missing email and password
})
expect(response.status).toBe(422)
const data = await response.json()
expect(data.errors.email).toBeDefined()
expect(data.errors.password).toBeDefined()
})
})
Authenticated Requests
import { actingAs } from '@stacksjs/testing'
describe('Profile API', () => {
useTransaction()
it('gets current user profile', async () => {
const user = await UserFactory.create()
const response = await actingAs(user).get('/api/profile')
expect(response.status).toBe(200)
const data = await response.json()
expect(data.user.id).toBe(user.id)
})
it('requires authentication', async () => {
const response = await http.get('/api/profile')
expect(response.status).toBe(401)
})
})
Mocking
Function Mocks
import { describe, expect, it, mock, spyOn } from 'bun:test'
describe('Mocking', () => {
it('mocks a function', () => {
const mockFn = mock(() => 'mocked')
expect(mockFn()).toBe('mocked')
expect(mockFn).toHaveBeenCalled()
})
it('spies on methods', () => {
const service = new EmailService()
const spy = spyOn(service, 'send').mockResolvedValue({ sent: true })
await service.send('test@example.com', 'Hello')
expect(spy).toHaveBeenCalledWith('test@example.com', 'Hello')
})
})
Module Mocks
import { mock } from 'bun:test'
mock.module('@/services/stripe', () => ({
charge: mock().mockResolvedValue({ success: true }),
}))
import { processPayment } from '@/services/payment'
it('processes payment', async () => {
const result = await processPayment(100)
expect(result.success).toBe(true)
})
Browser Testing
Playwright Integration
// tests/Browser/CheckoutTest.ts
import { test, expect } from '@playwright/test'
test.describe('Checkout', () => {
test('completes purchase', async ({ page }) => {
await page.goto('/products/1')
await page.click('button:has-text("Add to Cart")')
await page.click('a:has-text("Checkout")')
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="card"]', '4242424242424242')
await page.click('button:has-text("Pay")')
await expect(page.locator('.success-message')).toBeVisible()
})
})
Code Coverage
# Run with coverage
bun test --coverage
# Generate HTML report
bun test --coverage --coverage-reporter=html
Coverage Thresholds
// bunfig.toml
[test]
coverage = true
coverageThreshold = {
lines = 80,
functions = 80,
branches = 70,
statements = 80
}
Best Practices
- Test behavior, not implementation - Focus on what code does
- Use factories - Consistent test data creation
- Isolate tests - Each test should be independent
- Use transactions - Automatic database cleanup
- Mock external services - Don't make real API calls
- Keep tests fast - Fast feedback loop
- Test edge cases - Empty inputs, errors, boundaries
Related
- Unit Tests - Unit testing guide
- Feature Tests - Integration testing
- HTTP Tests - API testing
- Browser Tests - E2E testing
- Mocking - Mocking guide
- Database Testing - Database utilities