A clean test embodies three foundational properties: it is deterministic, isolated from external side effects, and asserts observable behavior rather than private implementation details. The organizational backbone of any dependable automated test is the AAA (Arrange, Act, Assert) pattern.
When validating software components coupled to external infrastructure (relational databases, network sockets, cryptographic hardware APIs, authentication providers), isolation becomes imperative. Software engineering addresses this through the umbrella abstraction of the Test Double.
Gerard Meszaros' classic taxonomy (xUnit Test Patterns) delineates five distinct classifications of test doubles:
InMemoryRepository backed by a Map instead of PostgreSQL).+----------------------------------------+
| Test Double (Meszaros) |
+----------------------------------------+
|
+-------------+-----------+-----------+-------------+
| | | | |
v v v v v
+---------+ +---------+ +---------+ +---------+ +---------+
| Dummy | | Stub | | Spy | | Mock | | Fake |
| (Types) | | (Answers)| |(Auditing)|(Expectations)| (In-Mem) |
+---------+ +---------+ +---------+ +---------+ +---------+crypto.randomUUID, Date.now, performance.now) via vi.spyOn().vi.hoisted() and vi.mock() for modules with top-level evaluation side effects.vi.fn().Consider a banking funds transfer service TransferService. We will implement all five test doubles with 100% strict type safety, avoiding any or loose casts.
import { describe, it, expect, vi } from 'vitest';
export interface UserAccount {
id: string;
email: string;
balanceCents: number;
}
export interface AccountRepository {
findById(id: string): Promise<UserAccount | null>;
save(account: UserAccount): Promise<void>;
}
export interface AuditLogger {
logTransfer(fromId: string, toId: string, amount: number): void;
}
export interface NotificationService {
notifyUser(email: string, message: string): Promise<void>;
}
// Target domain service
export class TransferService {
constructor(
private readonly accountRepo: AccountRepository,
private readonly logger: AuditLogger,
private readonly notifier: NotificationService
) {}
async execute(fromId: string, toId: string, amountCents: number): Promise<void> {
if (amountCents <= 0) {
throw new Error('Transfer amount must be strictly positive');
}
const sender = await this.accountRepo.findById(fromId);
const recipient = await this.accountRepo.findById(toId);
if (!sender || !recipient) {
throw new Error('One or more accounts do not exist');
}
if (sender.balanceCents < amountCents) {
throw new Error('Insufficient funds in sender account');
}
sender.balanceCents -= amountCents;
recipient.balanceCents += amountCents;
await this.accountRepo.save(sender);
await this.accountRepo.save(recipient);
this.logger.logTransfer(fromId, toId, amountCents);
}
}
// 1. FAKE: Fully functional in-memory repository implementation
class InMemoryAccountRepository implements AccountRepository {
private readonly store = new Map<string, UserAccount>();
seed(account: UserAccount): void {
this.store.set(account.id, { ...account });
}
async findById(id: string): Promise<UserAccount | null> {
const acc = this.store.get(id);
return acc ? { ...acc } : null;
}
async save(account: UserAccount): Promise<void> {
this.store.set(account.id, { ...account });
}
}
describe('TransferService (Test Double Taxonomy & AAA Pattern)', () => {
it('transfers balance successfully following Arrange-Act-Assert', async () => {
// ---------------- Arrange ----------------
const fakeRepo = new InMemoryAccountRepository();
fakeRepo.seed({ id: 'acc-1', email: '[email protected]', balanceCents: 10_000 });
fakeRepo.seed({ id: 'acc-2', email: '[email protected]', balanceCents: 2_000 });
// 2. SPY: Passive invocation tracker for telemetry
const spyLogger: AuditLogger = {
logTransfer: vi.fn(),
};
// 3. DUMMY: Parameter required by constructor signature, unused in this execution branch
const dummyNotifier: NotificationService = {
notifyUser: vi.fn(),
};
const service = new TransferService(fakeRepo, spyLogger, dummyNotifier);
// ---------------- Act ----------------
await service.execute('acc-1', 'acc-2', 3_000);
// ---------------- Assert ----------------
// 1. State Verification (verifying persisted state in the repository)
const updatedSender = await fakeRepo.findById('acc-1');
const updatedRecipient = await fakeRepo.findById('acc-2');
expect(updatedSender?.balanceCents).toBe(7_000);
expect(updatedRecipient?.balanceCents).toBe(5_000);
// 2. Behavior Verification (verifying terminal side-effect telemetry)
expect(spyLogger.logTransfer).toHaveBeenCalledTimes(1);
expect(spyLogger.logTransfer).toHaveBeenCalledWith('acc-1', 'acc-2', 3_000);
// 3. Verify Dummy was genuinely never called
expect(dummyNotifier.notifyUser).not.toHaveBeenCalled();
});
it('aborts transfer and preserves balance when sender balance is deficient', async () => {
// Arrange
const fakeRepo = new InMemoryAccountRepository();
fakeRepo.seed({ id: 'acc-1', email: '[email protected]', balanceCents: 1_000 });
fakeRepo.seed({ id: 'acc-2', email: '[email protected]', balanceCents: 500 });
const dummyLogger: AuditLogger = { logTransfer: vi.fn() };
const dummyNotifier: NotificationService = { notifyUser: vi.fn() };
const service = new TransferService(fakeRepo, dummyLogger, dummyNotifier);
// Act & Assert
await expect(service.execute('acc-1', 'acc-2', 5_000)).rejects.toThrow(
'Insufficient funds in sender account'
);
// State Verification confirms balance was untouched
const sender = await fakeRepo.findById('acc-1');
expect(sender?.balanceCents).toBe(1_000);
});
});vi.hoisted()#In ECMAScript specifications, static import declarations evaluate before any top-level executable code. Vitest's compiler automatically hoists vi.mock() calls above imports. If your mock factory references external local variables (const mockFn = vi.fn()), Node throws a ReferenceError. Vitest provides vi.hoisted() to bridge this scope gap safely.
import { describe, it, expect, vi } from 'vitest';
// Declare variables hoisted above static ESM imports
const { mockedPost, fakeAuthHeader } = vi.hoisted(() => {
return {
mockedPost: vi.fn(),
fakeAuthHeader: 'Bearer hoisted-token-xyz',
};
});
// vi.mock is automatically hoisted above the axios import below
vi.mock('axios', () => {
return {
default: {
post: mockedPost,
defaults: {
headers: {
common: { Authorization: fakeAuthHeader },
},
},
},
};
});
import axios from 'axios';
import { dispatchSecurityAlert } from './alertService';
describe('dispatchSecurityAlert with hoisted mocks', () => {
it('dispatches incident payload via mocked transport', async () => {
mockedPost.mockResolvedValueOnce({ status: 200, data: { status: 'acknowledged' } });
const result = await dispatchSecurityAlert('UNAUTHORIZED_ACCESS_ATTEMPT');
expect(mockedPost).toHaveBeenCalledWith(
'/api/v1/security/incidents',
expect.objectContaining({ incidentType: 'UNAUTHORIZED_ACCESS_ATTEMPT' })
);
expect(result).toBe(true);
});
});vi.spyOn() and Safe Teardown#Spies (vi.spyOn) wrap existing object methods to monitor interactions while selectively preserving or overriding original implementations, with guaranteed cleanup via mockRestore().
import { describe, it, expect, vi, afterEach } from 'vitest';
function getSessionExpiryIsoString(durationMinutes: number): string {
const expiryTime = Date.now() + durationMinutes * 60 * 1000;
return new Date(expiryTime).toISOString();
}
describe('getSessionExpiryIsoString', () => {
afterEach(() => {
// Restore original native implementations after every test
vi.restoreAllMocks();
});
it('calculates deterministic ISO expiry relative to a fixed timestamp', () => {
// Freeze Date.now using vi.spyOn
const fixedNow = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
vi.spyOn(Date, 'now').mockReturnValue(fixedNow);
const result = getSessionExpiryIsoString(30);
// 1_700_000_000_000 + 30 * 60 * 1000 = 1_700_001_800_000
expect(result).toBe(new Date(1_700_001_800_000).toISOString());
});
});When a test file requires 20 lines of mock configurations, the test validates developer assumptions rather than application reality. If the genuine API signature changes, the test remains green, masking critical failures (False Negatives).
// ❌ Anti-pattern: hyper-mocked suite testing mock wiring instead of business value
it('processes user registration', async () => {
const db = { query: vi.fn().mockReturnValue([{ id: 1 }]) };
const validator = { validate: vi.fn().mockReturnValue(true) };
const logger = { log: vi.fn() };
// Disconnected from genuine runtime execution
});
// ✅ Correct pattern: leverage domain Fakes and integration boundaries
const fakeDb = new InMemoryDatabase();
const service = new UserService(fakeDb); // Verifies real business workflowsConfiguring an asynchronous method with mockReturnValue(data) rather than mockResolvedValue(data) causes promise consumers (await service.getData()) to behave erratically or throw TypeError: Cannot read properties of undefined (reading 'then').
// ❌ Anti-pattern: asynchronous method returning a raw synchronous value
const fetchUser = vi.fn().mockReturnValue({ id: 'u1' });
// In production: fetchUser().then(...) crashes with a runtime TypeError!
// ✅ Correct pattern: explicitly declare a Promise resolution
const fetchUser = vi.fn().mockResolvedValue({ id: 'u1' });Declaring vi.fn() in file-level scope without clearing call counts prior to each it(...) causes toHaveBeenCalledTimes counters to aggregate across tests.
// ❌ Anti-pattern: call counts bleed between tests
const globalReporter = vi.fn();
it('first test invocation', () => {
globalReporter();
expect(globalReporter).toHaveBeenCalledTimes(1); // Passes
});
it('second test invocation', () => {
globalReporter();
expect(globalReporter).toHaveBeenCalledTimes(1); // Fails! Call count is now 2
});
// ✅ Correct pattern: automated state reset in afterEach
afterEach(() => {
vi.clearAllMocks(); // Resets call histories without breaking mock implementations
});Passing local variables directly into vi.mock() triggers ReferenceError: Cannot access 'mockService' before initialization because the compiler hoists vi.mock above the variable declaration.
// ❌ Compiler error: mockService is uninitialized when vi.mock evaluates
const mockService = vi.fn();
vi.mock('./myModule', () => ({ service: mockService }));
// ✅ Correct pattern: instantiate via vi.hoisted()
const { mockService } = vi.hoisted(() => ({ mockService: vi.fn() }));
vi.mock('./myModule', () => ({ service: mockService }));If an argument is required only to satisfy an interface and is never called in this scenario, provide a Dummy.
If the component only reads data (configuration, flags) without mutating state, use a Stub (vi.fn().mockReturnValue).
If you must verify that an external telemetry or logging method was invoked with correct arguments, attach a Spy (vi.spyOn).
If the dependency coordinates CRUD lifecycle operations, construct a lightweight in-memory Fake class.