Skip to content

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

afterEach(() => {
  vi.restoreAllMocks();  // Restores original implementations
});

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