416 lines
8.6 KiB
Markdown
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
|
|
});
|
|
```
|