| name | test-writing |
| description | Write unit tests following this project's established patterns using Bun's built-in test runner. Use when the user asks to write tests, add test coverage, create test files, or when generating tests for new or existing code. Ensures tests follow project conventions for imports, mocking, assertions, and file structure. Use when this capability is needed. |
| metadata | {"author":"frodeste"} |
Test Writing
This project uses Bun's built-in test runner -- not Jest, Vitest, or any other framework.
Quick Reference
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { MyModule } from "./my-module";
import type { MyType } from "@/types/my-type";
Run tests:
bun test
bun test src/sync/customers.test.ts
bun test --coverage
Conventions
File Naming and Location
- Name:
*.test.ts (not .spec.ts)
- Location: colocated next to the source file (e.g.,
src/clients/rubic.test.ts tests src/clients/rubic.ts)
Formatting (Biome)
- Tabs for indentation (not spaces)
- Double quotes for strings
- Semicolons always
- 100 char line width
Imports
- Use
"bun:test" for all test utilities: describe, expect, test, mock, beforeEach, afterEach
- Use
@/ path alias for project imports (e.g., @/mappers/customer.mapper, @/types/rubic)
- Use relative imports for the module under test (e.g.,
"./rubic")
- Use
import type for type-only imports
Test Structure
- Use
test() (not it())
- Group related tests with
describe() blocks named after the module or concern
- Keep test names descriptive:
"maps all fields correctly", "handles null values", "throws on API error"
Patterns
Pattern 1: Factory Functions for Test Data
For complex input objects, create makeEntity() factory functions with Partial<T> overrides:
function makeInvoice(overrides?: Partial<RubicInvoice>): RubicInvoice {
return {
invoiceID: 1001,
invoiceNumber: 5001,
orderID: 2001,
invoiceDate: "2025-06-01",
...overrides,
};
}
function makeLine(overrides?: Partial<RubicInvoiceLine>): RubicInvoiceLine {
return {
invoiceLineID: 1,
productCode: "PROD-001",
price: 499.0,
quantity: 1,
...overrides,
};
}
Use factory functions when the input type has more than 3-4 fields. For simpler types, inline the object.
Pattern 2: Mocking globalThis.fetch for API Clients
Save and restore the original fetch. Use mock() from bun:test and its built-in tracking features:
const originalFetch = globalThis.fetch;
describe("MyClient", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});
test("fetches data correctly", async () => {
const mockFetch = mock(async (url: string | URL | Request) => {
return new Response(JSON.stringify([{ id: 1 }]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
globalThis.fetch = mockFetch as unknown as typeof fetch;
const result = await client.getData();
expect(result).toHaveLength(1);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
test("throws on API error", async () => {
const mockFetch = mock(async () => {
(, { : , : });
});
globalThis. = mockFetch fetch;
(client.())..();
(mockFetch).();
});
});
Key details:
- Store
originalFetch at module scope, restore in afterEach
- Assign
mock() to a variable (mockFetch) to access its tracking properties
- Use
expect(mockFetch).toHaveBeenCalledTimes(1) instead of manual counters
- Access call arguments via
mockFetch.mock.calls when needed (e.g., to inspect captured URLs or headers)
- Cast mock as
unknown as typeof fetch for type compatibility
- Return
new Response(JSON.stringify(data), { status, headers }) to simulate responses
Pattern 3: Pure Mapper Tests
Test mappers as pure functions -- no mocking needed:
describe("Customer Mapper", () => {
test("maps all fields correctly", () => {
const input: RubicCustomer = { };
const result = mapRubicCustomerToTripletex(input);
expect(result.name).toBe("Test Customer");
expect(result.customerNumber).toBe(12345);
});
test("handles null values", () => {
const input: RubicCustomer = { };
const result = mapRubicCustomerToTripletex(input);
expect(result.email).toBeUndefined();
});
});
Pattern 4: Hash/Determinism Tests
For hash functions, verify determinism and sensitivity:
test("returns same hash for same input", () => {
const hash1 = computeHash(entity);
const hash2 = computeHash(entity);
expect(hash1).toBe(hash2);
expect(hash1).toHaveLength(64);
});
test("returns different hash for different input", () => {
const hash1 = computeHash(entity1);
const hash2 = computeHash(entity2);
expect(hash1).not.toBe(hash2);
});
Edge Cases to Always Cover
Every test suite should include tests for:
- Null/undefined fields -- verify graceful handling, not crashes
- Empty arrays --
[] should not throw
- Invalid/unexpected formats -- e.g., non-numeric strings where numbers are expected
- Boundary conditions -- empty strings, zero values, max-length data
What NOT to Do
- No database tests -- tests are pure unit tests; do not import from
src/db/
- No
it() calls -- use test() consistently
- No Jest/Vitest imports -- everything comes from
"bun:test"
- No snapshot testing -- use explicit assertions
- No test utilities from npm -- use Bun's built-in
mock() and expect()
Existing Test Files for Reference
src/clients/rubic.test.ts -- API client mocking with globalThis.fetch
src/clients/tripletex.test.ts -- API client mocking
src/mappers/invoice.mapper.test.ts -- factory functions and mapper testing
src/mappers/product.mapper.test.ts -- mapper testing
src/sync/customers.test.ts -- mapper and hash function testing
src/sync/invoices.test.ts -- sync logic testing
src/sync/payments.test.ts -- sync logic testing
src/sync/products.test.ts -- sync logic testing
Converted and distributed by TomeVault — claim your Tome and manage your conversions.