playbook/outfitter-agents/plugins/outfitter/skills/bun-dev/references/testing.md

416 lines
8.6 KiB
Markdown

# Testing with bun:test
Bun's built-in test runner patterns and lifecycle hooks.
## Test Structure
```typescript
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
describe('feature', () => {
let resource: Resource;
beforeAll(() => {
// Suite setup — runs once before all tests
console.log('Setup test suite');
});
afterAll(() => {
// Suite cleanup — runs once after all tests
console.log('Cleanup test suite');
});
beforeEach(() => {
// Test setup — runs before each test
resource = createResource();
});
afterEach(() => {
// Test cleanup — runs after each test
resource.dispose();
});
test('behavior', () => {
expect(result).toBe(expected);
});
});
```
## Assertions
```typescript
// Equality
expect(value).toBe(expected); // Strict equality (===)
expect(obj).toEqual({ foo: 'bar' }); // Deep equality
expect(arr).toContain(item); // Array/string contains
expect(obj).toMatchObject({ key: 'value' }); // Partial object match
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeDefined();
expect(value).toBeUndefined();
expect(value).toBeNull();
// Numbers
expect(num).toBeGreaterThan(0);
expect(num).toBeGreaterThanOrEqual(0);
expect(num).toBeLessThan(100);
expect(num).toBeLessThanOrEqual(100);
expect(num).toBeCloseTo(0.3, 5); // Float comparison
// Strings
expect(str).toMatch(/pattern/);
expect(str).toStartWith('prefix');
expect(str).toEndWith('suffix');
// Arrays
expect(arr).toHaveLength(3);
expect(arr).toContainEqual({ id: 1 });
// Exceptions
expect(fn).toThrow();
expect(fn).toThrow('error message');
expect(fn).toThrow(ErrorType);
// Negation
expect(value).not.toBe(other);
expect(arr).not.toContain(item);
```
## Async Tests
```typescript
// Async/await
test('async operation', async () => {
const result = await fetchData();
expect(result).toBeDefined();
});
// Promise resolution
test('promise resolves', async () => {
await expect(asyncFn()).resolves.toBe('success');
});
// Promise rejection
test('promise rejects', async () => {
await expect(asyncFn()).rejects.toThrow('error');
});
// Timeout (default 5000ms)
test('slow operation', async () => {
const result = await slowOperation();
expect(result).toBeDefined();
}, 10000); // 10 second timeout
```
## Database Testing
```typescript
import { Database } from 'bun:sqlite';
describe('Database operations', () => {
let db: Database;
beforeEach(() => {
// Fresh in-memory database per test
db = new Database(':memory:');
db.run(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL
)
`);
});
afterEach(() => {
db.close();
});
test('insert user', () => {
const user = db.prepare(`
INSERT INTO users (id, email, name)
VALUES (?, ?, ?)
RETURNING *
`).get('1', 'alice@example.com', 'Alice');
expect(user).toMatchObject({
id: '1',
email: 'alice@example.com',
name: 'Alice'
});
});
test('query user', () => {
db.run("INSERT INTO users VALUES ('1', 'alice@example.com', 'Alice')");
const user = db.prepare('SELECT * FROM users WHERE id = ?').get('1');
expect(user).toBeDefined();
expect(user.email).toBe('alice@example.com');
});
test('unique constraint', () => {
db.run("INSERT INTO users VALUES ('1', 'alice@example.com', 'Alice')");
expect(() => {
db.run("INSERT INTO users VALUES ('2', 'alice@example.com', 'Alice2')");
}).toThrow();
});
});
```
## File System Testing
```typescript
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
describe('File operations', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'test-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true });
});
test('write and read file', async () => {
const filepath = join(tempDir, 'test.txt');
await Bun.write(filepath, 'Hello, world!');
const file = Bun.file(filepath);
expect(await file.exists()).toBe(true);
expect(await file.text()).toBe('Hello, world!');
});
test('write JSON', async () => {
const filepath = join(tempDir, 'data.json');
const data = { name: 'test', value: 42 };
await Bun.write(filepath, JSON.stringify(data));
const file = Bun.file(filepath);
expect(await file.json()).toEqual(data);
});
});
```
## Mocking
```typescript
import { mock, spyOn } from 'bun:test';
describe('Mocking', () => {
test('mock function', () => {
const mockFn = mock(() => 'mocked');
expect(mockFn()).toBe('mocked');
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(1);
});
test('mock with arguments', () => {
const mockFn = mock((x: number) => x * 2);
mockFn(5);
expect(mockFn).toHaveBeenCalledWith(5);
});
test('spy on method', () => {
const obj = {
method: (x: number) => x * 2
};
const spy = spyOn(obj, 'method');
obj.method(5);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(5);
});
test('mock return value', () => {
const mockFn = mock(() => 'original');
mockFn.mockReturnValue('mocked');
expect(mockFn()).toBe('mocked');
mockFn.mockReturnValueOnce('once');
expect(mockFn()).toBe('once');
expect(mockFn()).toBe('mocked');
});
test('mock implementation', () => {
const mockFn = mock(() => 'original');
mockFn.mockImplementation(() => 'new implementation');
expect(mockFn()).toBe('new implementation');
});
});
```
## Mock fetch
```typescript
describe('External API calls', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
test('mock API response', async () => {
global.fetch = mock(async () =>
new Response(JSON.stringify({ data: 'mocked' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
);
const res = await fetch('https://api.example.com/data');
const data = await res.json();
expect(data).toEqual({ data: 'mocked' });
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/data');
});
test('mock API error', async () => {
global.fetch = mock(async () =>
new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
);
const res = await fetch('https://api.example.com/missing');
expect(res.status).toBe(404);
});
});
```
## Test Organization
```typescript
// Skip tests
test.skip('work in progress', () => {
// Not executed
});
// Mark as todo
test.todo('future feature');
// Only run specific test
test.only('focus on this', () => {
// Only this test runs in file
});
// Conditional skip
const isCI = process.env.CI === 'true';
test.skipIf(isCI)('skip in CI', () => {
// Skipped when CI=true
});
// Run if condition
test.if(!isCI)('local only', () => {
// Only runs locally
});
```
## Snapshot Testing
```typescript
import { expect, test } from 'bun:test';
test('snapshot', () => {
const result = generateOutput();
expect(result).toMatchSnapshot();
});
test('inline snapshot', () => {
const result = { name: 'test', value: 42 };
expect(result).toMatchInlineSnapshot(`
{
"name": "test",
"value": 42
}
`);
});
```
## Running Tests
```bash
# Run all tests
bun test
# Specific file
bun test src/utils.test.ts
# Specific directory
bun test src/api/
# Pattern matching
bun test --test-name-pattern "should create"
# Watch mode
bun test --watch
# Coverage
bun test --coverage
# Timeout (ms)
bun test --timeout 10000
# Bail on first failure
bun test --bail
# Rerun only failed tests
bun test --rerun-each 3
```
## Best Practices
```typescript
// ✅ Isolated tests — each test sets up its own data
describe('User service', () => {
let db: Database;
beforeEach(() => {
db = new Database(':memory:');
setupSchema(db);
});
afterEach(() => {
db.close();
});
test('creates user', () => {
const user = createUser(db, { email: 'test@example.com' });
expect(user.id).toBeDefined();
});
});
// ✅ Descriptive test names
test('returns 404 when user not found', async () => { ... });
test('validates email format before creating user', async () => { ... });
// ✅ Single assertion focus
test('user has correct email', () => {
const user = createUser({ email: 'test@example.com' });
expect(user.email).toBe('test@example.com');
});
// ❌ Avoid shared mutable state between tests
let sharedUser; // Don't do this
beforeAll(() => {
sharedUser = createUser(); // Tests may interfere
});
```