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

8.6 KiB

Testing with bun:test

Bun's built-in test runner patterns and lifecycle hooks.

Test Structure

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

// 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

// 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

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

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

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

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

// 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

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

# 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

// ✅ 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
});