DOMAIN:TESTING — VITEST_PATTERNS¶
OWNER: marije, judith
ALSO_USED_BY: antje (TDD), koen (mutation base), jasper (reconciliation)
UPDATED: 2026-03-24
SCOPE: unit and integration testing for all GE client projects
VITEST_OVERVIEW¶
Vitest is GE's unit and integration test runner.
It is Vite-native, ESM-first, and TypeScript-native.
It replaces Jest entirely — do NOT use Jest in any GE project.
WHY_VITEST:
- Native ESM support (no transform hacks)
- Native TypeScript (no ts-jest)
- Vite-powered HMR for watch mode
- Compatible with Jest API (easy migration)
- Workspace mode for monorepos
- Built-in coverage via v8 or istanbul
TEST_STRUCTURE¶
BASIC_PATTERN¶
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
describe('UserService', () => {
// Group related tests
describe('createUser', () => {
let service: UserService;
beforeEach(() => {
service = new UserService(mockDb);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('creates a user with valid input', async () => {
const result = await service.createUser({
name: 'Test User',
email: 'test@example.com',
});
expect(result).toMatchObject({
name: 'Test User',
email: 'test@example.com',
});
expect(result.id).toBeDefined();
});
it('rejects duplicate email', async () => {
await service.createUser({ name: 'First', email: 'dupe@example.com' });
await expect(
service.createUser({ name: 'Second', email: 'dupe@example.com' })
).rejects.toThrow('Email already exists');
});
});
});
RULE: one describe per module/class, nested describe per method/function
RULE: it descriptions start with a VERB — "creates", "rejects", "returns", "throws"
RULE: each it block tests ONE behavior — if you need "and" in the description, split it
RULE: always restoreAllMocks() in afterEach to prevent test pollution
NAMING_CONVENTIONS¶
FILE_NAMES: {source-file-name}.test.ts for unit, {feature}.integration.test.ts for integration
DESCRIBE_TEXT: class/module name — describe('UserService', ...)
IT_TEXT: behavior description — it('returns null for nonexistent user', ...)
ANTI_PATTERN: it('should work'), it('test 1'), it('handles edge case')
FIX: be specific — it('returns 404 when user ID does not exist in database')
ASSERTIONS¶
COMMON_MATCHERS¶
// Equality
expect(value).toBe(primitive); // strict equality (===)
expect(value).toEqual(object); // deep equality
expect(value).toStrictEqual(object); // deep equality + type checking
// 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).toBeCloseTo(0.3, 5); // floating point
// Strings
expect(value).toMatch(/regex/);
expect(value).toContain('substring');
// Arrays
expect(array).toContain(item);
expect(array).toHaveLength(3);
expect(array).toEqual(expect.arrayContaining([1, 2]));
// Objects
expect(object).toHaveProperty('key');
expect(object).toHaveProperty('key', 'value');
expect(object).toMatchObject({ subset: true });
expect(object).toEqual(expect.objectContaining({ key: 'value' }));
// Exceptions
expect(() => fn()).toThrow();
expect(() => fn()).toThrow('specific message');
expect(() => fn()).toThrow(SpecificError);
// Async
await expect(promise).resolves.toBe(value);
await expect(promise).rejects.toThrow('message');
RULE: prefer toEqual for objects, toBe for primitives
RULE: prefer toMatchObject when you only care about a subset of properties
RULE: NEVER use toBeTruthy() when you can be specific — use toBeDefined(), toBeNull(), etc.
ANTI_PATTERN: expect(result !== null).toBe(true) — loses error context
FIX: expect(result).not.toBeNull() — Vitest shows actual value on failure
MOCKING¶
VI_MOCK — MODULE_MOCKING¶
import { vi, describe, it, expect } from 'vitest';
import { sendEmail } from '../lib/email';
import { UserService } from '../lib/user-service';
// Mock entire module
vi.mock('../lib/email', () => ({
sendEmail: vi.fn().mockResolvedValue({ sent: true }),
}));
describe('UserService', () => {
it('sends welcome email on registration', async () => {
const service = new UserService();
await service.register({ email: 'new@example.com', name: 'New User' });
expect(sendEmail).toHaveBeenCalledWith({
to: 'new@example.com',
template: 'welcome',
data: expect.objectContaining({ name: 'New User' }),
});
});
});
RULE: mock at the MODULE BOUNDARY, not internal implementation
RULE: mock what you DON'T own (external APIs, email, filesystem)
RULE: do NOT mock what you're testing — that's a tautology
VI_SPYON — PARTIAL_MOCKING¶
import { vi, describe, it, expect } from 'vitest';
describe('Logger', () => {
it('calls console.error for error level', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
logger.error('test message');
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('test message')
);
});
});
USE_SPYON_WHEN: you want to observe calls without replacing the entire module
USE_MOCK_WHEN: you want to replace the entire module with controlled behavior
MOCK_RESET_STRATEGY¶
OPTIONS:
- vi.clearAllMocks() — clears call history, keeps mock implementation
- vi.resetAllMocks() — clears call history AND mock implementation
- vi.restoreAllMocks() — restores original implementations (PREFERRED)
RULE: always use vi.restoreAllMocks() unless you have a specific reason not to
RULE: put it in afterEach, not afterAll — every test starts clean
MOCK_RETURN_VALUES¶
const mockFn = vi.fn();
// Single return
mockFn.mockReturnValue('value');
mockFn.mockResolvedValue('async value');
mockFn.mockRejectedValue(new Error('failed'));
// Sequential returns
mockFn
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default');
// Custom implementation
mockFn.mockImplementation((input) => {
if (input > 10) return 'high';
return 'low';
});
TESTING_HONO_HANDLERS¶
GE backend uses Hono. Test handlers using the app.request() method — no HTTP server needed.
BASIC_ROUTE_TEST¶
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { userRoutes } from '../routes/users';
describe('GET /api/users/:id', () => {
const app = new Hono();
app.route('/api', userRoutes);
it('returns user by ID', async () => {
const res = await app.request('/api/users/123', {
method: 'GET',
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toMatchObject({
id: '123',
name: expect.any(String),
});
});
it('returns 404 for nonexistent user', async () => {
const res = await app.request('/api/users/nonexistent', {
method: 'GET',
});
expect(res.status).toBe(404);
});
it('returns 400 for invalid ID format', async () => {
const res = await app.request('/api/users/!!!invalid!!!', {
method: 'GET',
});
expect(res.status).toBe(400);
});
});
HONO_WITH_MIDDLEWARE¶
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Hono } from 'hono';
import { authMiddleware } from '../middleware/auth';
import { protectedRoutes } from '../routes/protected';
describe('Protected routes', () => {
const app = new Hono();
app.use('/api/protected/*', authMiddleware);
app.route('/api/protected', protectedRoutes);
it('rejects unauthenticated requests', async () => {
const res = await app.request('/api/protected/data');
expect(res.status).toBe(401);
});
it('allows authenticated requests', async () => {
const res = await app.request('/api/protected/data', {
headers: {
Authorization: 'Bearer valid-test-token',
},
});
expect(res.status).toBe(200);
});
});
HONO_POST_WITH_BODY¶
it('creates a resource', async () => {
const res = await app.request('/api/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Test Item',
price: 29.99,
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.id).toBeDefined();
expect(body.name).toBe('Test Item');
});
RULE: test routes through app.request() — not by importing handler functions directly
RULE: test middleware by applying it to a test app — not by calling middleware functions
RULE: always test both success AND error paths for every endpoint
TESTING_DRIZZLE_QUERIES¶
GE uses Drizzle ORM with PostgreSQL. Integration tests use a real test database.
TEST_DATABASE_SETUP¶
// test/setup-db.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
import * as schema from '../drizzle/schema';
const TEST_DB_URL = process.env.TEST_DATABASE_URL
?? 'postgresql://test:test@localhost:5432/ge_test';
export async function createTestDb() {
const pool = new Pool({ connectionString: TEST_DB_URL });
const db = drizzle(pool, { schema });
await migrate(db, { migrationsFolder: './drizzle/migrations' });
return { db, pool };
}
export async function cleanTestDb(db: ReturnType<typeof drizzle>) {
// Truncate all tables in reverse dependency order
await db.execute(sql`TRUNCATE TABLE work_items, tasks, users CASCADE`);
}
DRIZZLE_QUERY_TEST¶
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import { createTestDb, cleanTestDb } from '../test/setup-db';
import { users } from '../drizzle/schema';
import { eq } from 'drizzle-orm';
describe('User queries', () => {
let db: Awaited<ReturnType<typeof createTestDb>>['db'];
let pool: Awaited<ReturnType<typeof createTestDb>>['pool'];
beforeAll(async () => {
const setup = await createTestDb();
db = setup.db;
pool = setup.pool;
});
beforeEach(async () => {
await cleanTestDb(db);
});
afterAll(async () => {
await pool.end();
});
it('inserts and retrieves a user', async () => {
const [inserted] = await db.insert(users).values({
name: 'Test User',
email: 'test@example.com',
}).returning();
const [found] = await db.select().from(users)
.where(eq(users.id, inserted.id));
expect(found).toMatchObject({
name: 'Test User',
email: 'test@example.com',
});
});
it('enforces unique email constraint', async () => {
await db.insert(users).values({
name: 'First',
email: 'unique@example.com',
});
await expect(
db.insert(users).values({
name: 'Second',
email: 'unique@example.com',
})
).rejects.toThrow();
});
});
RULE: integration tests use a REAL test database — never mock Drizzle for integration tests
RULE: unit tests for service logic CAN mock the db layer — test the logic, not the ORM
RULE: always truncate between tests — test isolation is non-negotiable
RULE: run migrations before tests — schema must match production
FIXTURES_AND_FACTORIES¶
TEST_FACTORIES¶
// test/factories/user.ts
import { faker } from '@faker-js/faker';
export function buildUser(overrides: Partial<User> = {}): User {
return {
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
createdAt: new Date(),
...overrides,
};
}
// Usage in tests
it('formats user display name', () => {
const user = buildUser({ name: 'Jan de Vries' });
expect(formatDisplayName(user)).toBe('J. de Vries');
});
RULE: factories use faker for realistic data — not "test1", "test2"
RULE: factories accept overrides — test only specifies what matters for THAT test
RULE: factory defaults should be VALID — a factory with no overrides produces a valid entity
VITEST_FIXTURES (vi.hoisted)¶
import { vi, describe, it, expect } from 'vitest';
// Hoisted — runs before imports
const mockDb = vi.hoisted(() => ({
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
}));
vi.mock('../lib/db', () => ({ db: mockDb }));
SNAPSHOT_TESTING¶
WHEN_TO_USE: complex output that's tedious to assert manually (e.g., rendered HTML, serialized configs)
WHEN_NOT_TO_USE: simple values, frequently changing output, anything with timestamps/IDs
it('renders user profile HTML', () => {
const html = renderProfile(buildUser({ name: 'Test User' }));
expect(html).toMatchSnapshot();
});
// Inline snapshots (preferred for small outputs)
it('serializes config', () => {
const config = buildConfig({ port: 3000 });
expect(config).toMatchInlineSnapshot(`
{
"host": "localhost",
"port": 3000,
}
`);
});
RULE: prefer inline snapshots for small outputs — they live next to the assertion
RULE: review snapshot changes in PR diffs — never blindly update with -u
RULE: snapshots that change on every run (timestamps, random IDs) are BROKEN — filter them
ANTI_PATTERN: snapshot testing complex objects with unstable fields
FIX: use toMatchObject with the stable subset instead
COVERAGE_CONFIGURATION¶
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
include: ['src/**/*.ts'],
exclude: [
'src/**/*.test.ts',
'src/**/*.spec.ts',
'src/**/types.ts',
'src/**/index.ts', // barrel files
'src/**/*.d.ts',
],
thresholds: {
lines: 80,
branches: 75,
functions: 85,
statements: 80,
},
},
},
});
RULE: exclude test files, type definitions, and barrel files from coverage
RULE: coverage reports in CI use lcov format for tooling integration
RULE: HTML coverage report is for local dev — never commit it
WORKSPACE_CONFIG (MONOREPO)¶
// vitest.workspace.ts
import { defineWorkspace } from 'vitest/config';
export default defineWorkspace([
{
extends: './vitest.config.ts',
test: {
name: 'unit',
include: ['src/**/*.test.ts'],
exclude: ['src/**/*.integration.test.ts'],
},
},
{
extends: './vitest.config.ts',
test: {
name: 'integration',
include: ['src/**/*.integration.test.ts'],
setupFiles: ['./test/setup-db.ts'],
poolOptions: {
threads: {
singleThread: true, // DB tests must be serial
},
},
},
},
]);
RULE: unit tests run in parallel (default) — they must be isolated
RULE: integration tests with shared DB run in single-thread mode
RULE: workspace names match CI pipeline stage names
ASYNC_TESTING_PATTERNS¶
// Always await async operations
it('fetches data', async () => {
const data = await fetchData();
expect(data).toBeDefined();
});
// Test timers
it('debounces input', async () => {
vi.useFakeTimers();
const handler = vi.fn();
const debounced = debounce(handler, 300);
debounced('a');
debounced('ab');
debounced('abc');
expect(handler).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(300);
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith('abc');
vi.useRealTimers();
});
// Test event emitters
it('emits change event', async () => {
const callback = vi.fn();
emitter.on('change', callback);
emitter.setValue('new');
expect(callback).toHaveBeenCalledWith('new');
});
RULE: always use async/await — never use .then() chains in tests
RULE: always restore real timers after fake timer tests
RULE: use vi.advanceTimersByTimeAsync (not sync version) when testing async code with timers
CROSS_REFERENCES¶
TDD_METHODOLOGY: domains/testing/tdd-methodology.md — how Antje writes pre-impl tests
PLAYWRIGHT: domains/testing/playwright-e2e.md — E2E testing patterns
PITFALLS: domains/testing/pitfalls.md — common testing mistakes
MUTATION: domains/testing/mutation-testing.md — verifying test quality with Stryker