| name | testing-strategy |
| description | Structures a test suite as a pyramid of unit, integration, load, and chaos tests where dependency injection enables typed mocking without module interception. Use when deciding what kind of test to write, setting up vitest with typed mocks, naming test files by type, or wiring database guardrails and Faker test data. |
| version | 1.1.0 |
| libraries | ["vitest","vitest-mock-extended","@faker-js/faker","k6"] |
Testing Strategy
Overview
A test suite is a pyramid: many fast unit tests at the base, fewer integration tests above, and a thin top of load and chaos tests. Each layer answers a different question and catches a different class of bug.
Why this matters: Testability drives design. The fn(args, deps) pattern makes functions testable: you mock only the dependencies a function uses, with vitest-mock-extended, and never reach for vi.mock or module interception. When code is hard to test, that is a design signal, not a testing problem. Investing effort according to the pyramid keeps the suite fast and reliable: most failures should surface in milliseconds at the unit layer, not minutes later in a flaky end-to-end run.
△ Chaos Tests ("Does it survive failures?")
/|\
/ | \ Load Tests ("Does it scale?")
/--+--\
/ | \ Integration Tests ("Does the stack work?")
/----+----\
| Unit Tests ("Does the logic work?")
When to Use
- Deciding which kind of test a given behavior needs
- Setting up vitest with typed mocks and database guardrails
- Naming and organizing test files by type
- Generating realistic test data with Faker
- Mocking Prisma or other fluent clients in unit tests
When NOT to use: Writing the assertions inside an individual test (see writing-tests); driving the red-green-refactor loop (see tdd-workflow); building load profiles in depth (see performance-testing).
Related: tdd-workflow drives test-first development; writing-tests covers naming and assertions; performance-testing expands the load and chaos layers; fn-args-deps is the pattern that makes mocking trivial; result-types defines the error shapes these tests assert on.
Choosing a Test Layer
| Question to answer | Layer | What it touches | Speed |
|---|
| Does the logic work? | Unit | Mocked deps, no I/O | Milliseconds |
| Does the stack work? | Integration | Real localhost database | Seconds |
| Does it scale? | Load | Running service under k6 | Minutes |
| Does it survive failures? | Chaos | Injected latency/failures | Minutes |
Is it pure logic or a function with injectable deps?
→ Unit test (mock deps)
Does it cross a real boundary (DB, file system)?
→ Integration test (.test.int.ts, localhost only)
Does it need to hold up under concurrent traffic?
→ Load test (k6) — see performance-testing
Does it need to survive dependency failures?
→ Chaos test — see performance-testing
Required Behaviors
1. Unit Tests with Explicit Deps
Use vitest-mock-extended for typed mocks:
import { describe, it, expect } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { getUser, type GetUserDeps } from './get-user';
it('returns user when found', async () => {
const mockUser = { id: '123', name: 'Alice', email: 'alice@test.com' };
const deps = mock<GetUserDeps>();
deps.db.findUser.mockResolvedValue(mockUser);
const result = await getUser({ userId: '123' }, deps);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toEqual(mockUser);
}
});
2. Integration Tests with Real Database
Use .test.int.ts suffix for integration tests:
import { describe, it, expect, beforeAll } from 'vitest';
import { createTestDb } from '../test-utils/db';
import { getUser } from './get-user';
describe('getUser integration', () => {
let db: Database;
beforeAll(async () => {
db = await createTestDb();
});
it('returns user from real database', async () => {
await db.saveUser({ id: '123', name: 'Alice' });
const result = await getUser({ userId: '123' }, { db });
expect(result.ok).toBe(true);
});
});
3. Database Guardrails
Prevent tests from hitting non-localhost databases:
const DB_URL = process.env.DATABASE_URL || '';
if (!DB_URL.includes('localhost') && !DB_URL.includes('127.0.0.1')) {
throw new Error(
'Integration tests must use localhost database. ' +
`Current DATABASE_URL: ${DB_URL}`
);
}
4. Load Tests with k6
import http from 'k6/http';
import { check } from 'k6';
export const options = {
vus: 1,
duration: '30s',
thresholds: {
http_req_duration: ['p(95)<500'],
},
};
export default function () {
const res = http.get('http://localhost:3000/api/users/1');
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
}
export const options = {
stages: [
{ duration: '2m', target: 100 },
{ duration: '5m', target: 100 },
{ duration: '2m', target: 200 },
{ duration: '5m', target: 200 },
{ duration: '2m', target: 0 },
],
};
Test File Naming
| File Pattern | Type | Purpose |
|---|
*.test.ts | Unit | Mock deps, fast, isolated |
*.test.int.ts | Integration | Real database, slower |
load/*.js | Load | k6 scripts |
chaos/*.ts | Chaos | Inject failures |
Why fn(args, deps) Enables Testing
With classes, you must satisfy the entire constructor:
new UserService(db, logger, mailer, cache, metrics);
With fn(args, deps), you only mock what the function uses:
const deps = mock<GetUserDeps>();
await getUser({ userId }, deps);
Testing Result Types
it('handles not found error', async () => {
const deps = mock<GetUserDeps>();
deps.db.findUser.mockResolvedValue(null);
const result = await getUser({ userId: '123' }, deps);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe('NOT_FOUND');
}
});
Prisma Mocking with mockDeep
For Prisma clients, use mockDeep for nested method access. Create a helper function:
import { PrismaClient } from '@prisma/client';
import { mockDeep } from 'vitest-mock-extended';
export function createMockPrisma() {
const mockPrisma = mockDeep<PrismaClient>();
mockPrisma.$transaction.mockImplementation(async (callback) => {
return callback(mockPrisma);
});
return mockPrisma;
}
Now in your unit tests:
import { createMockPrisma } from '../test-utils/prisma-mock';
it('creates user in database', async () => {
const prisma = createMockPrisma();
prisma.user.create.mockResolvedValue({ id: '1', name: 'Alice', email: 'alice@test.com' });
const deps = { db: prisma, logger: mock<Logger>() };
const result = await createUser({ name: 'Alice', email: 'alice@test.com' }, deps);
expect(result.ok).toBe(true);
expect(prisma.user.create).toHaveBeenCalledWith({
data: { name: 'Alice', email: 'alice@test.com' },
});
});
Test Data with Faker
Use Faker for realistic test data. Create helper functions for integration tests:
import { faker } from '@faker-js/faker';
import prisma from '../db';
export async function createTestCustomer(overrides: {
email?: string;
name?: string;
} = {}) {
return prisma.customer.create({
data: {
email: overrides.email ?? faker.internet.email(),
name: overrides.name ?? faker.person.fullName(),
phone: faker.phone.number(),
createdAt: faker.date.past(),
},
});
}
export async function createTestCustomerWithOrders(overrides: {
orderCount?: number;
orderStatus?: 'pending' | 'shipped' | 'delivered';
} = {}) {
const customer = await createTestCustomer();
const orders = await Promise.all(
Array.from({ length: overrides.orderCount ?? 1 }).map(() =>
prisma.order.create({
data: {
customerId: customer.id,
status: overrides.orderStatus ?? 'pending',
total: faker.number.int({ min: 1000, max: 100000 }),
shippingAddress: faker.location.streetAddress(),
},
})
)
);
return { customer, orders };
}
Why no cleanup? Each test uses faker.string.uuid() for IDs and faker.internet.email() for emails. Tests create their own unique data and query only that data. No shared state means no cleanup needed, and tests can run in parallel.
For unit tests, create stub objects (no database):
export const stubs = {
customer: (overrides: Partial<Customer> = {}): Customer => ({
id: faker.string.uuid(),
email: faker.internet.email(),
name: faker.person.fullName(),
phone: faker.phone.number(),
createdAt: faker.date.past(),
...overrides,
}),
order: (overrides: Partial<Order> = {}): Order => ({
id: faker.string.uuid(),
customerId: faker.string.uuid(),
status: 'pending',
total: faker.number.int({ min: 1000, max: 100000 }),
shippingAddress: faker.location.streetAddress(),
createdAt: faker.date.past(),
...overrides,
}),
};
const order = stubs.order({ status: 'shipped' });
deps.db.findOrder.mockResolvedValue(order);
vitest.setup.ts Configuration
import { beforeAll, afterAll, afterEach } from 'vitest';
const DB_URL = process.env.DATABASE_URL || '';
if (!DB_URL.includes('localhost') && !DB_URL.includes('127.0.0.1')) {
throw new Error(
'Integration tests must use localhost database. ' +
`Current DATABASE_URL: ${DB_URL}`
);
}
beforeAll(() => {
vi.setConfig({ testTimeout: 10000 });
});
afterEach(() => {
vi.clearAllMocks();
});
afterAll(async () => {
});
Add to vitest.config.ts:
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts'],
globals: true,
},
});
The Rules
- Unit tests mock deps explicitly - No vi.mock or module mocking
- Integration tests use real database - localhost only
- Database guardrails prevent accidents - Fail if not localhost
- Load tests validate scalability - k6 scripts per endpoint
- Name files by test type -
.test.ts, .test.int.ts
- Use mockDeep for Prisma - Handles nested method chains
- Use Faker for test data - Realistic, consistent test fixtures
Common Rationalizations
| Rationalization | Reality |
|---|
| "I'll just mock the whole module with vi.mock" | Module mocks couple tests to import paths and break on refactor. Inject deps and mock the typed interface. |
| "Everything should be an integration test for confidence" | Slow, flaky suites get ignored. Push logic into unit tests; reserve integration tests for real boundaries. |
| "Pointing the test DB at staging is fine" | One bad run mutates shared data. Guardrails must fail unless the URL is localhost. |
| "I'll write a load test for every endpoint" | Load tests belong at the thin top of the pyramid. Cover critical paths, not everything. |
| "I'll hand-write test fixtures" | Hand-written data hides edge cases and drifts from reality. Use Faker for realistic, unique data. |
Red Flags
vi.mock() used to replace application modules instead of injecting deps
- Integration tests that can reach a non-localhost database
- A suite where most tests are slow integration or end-to-end tests
- Shared mutable fixtures requiring cleanup between tests
- Prisma mocked manually instead of with
mockDeep
- Test files with no naming convention to distinguish unit from integration
Verification