
Vitest Best Practices
- 58 installs
- 283 repo stars
- Updated June 28, 2026
- pageai-pro/ralph-loop
Helps with testing & qa tasks during AI-assisted development.
About
vitest-best-practices is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted coding.
- vitest-best-practices
- Testing & QA
- AI-coding skill
Vitest Best Practices by the numbers
- 58 all-time installs (skills.sh)
- +1 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Ranked #1,171 of 2,154 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pageai-pro/ralph-loop --skill vitest-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 283 |
| Last updated | June 28, 2026 |
| Repository | pageai-pro/ralph-loop ↗ |
What it does
Helps with testing & qa tasks during AI-assisted development.
Files
Vitest Best Practices
When to Apply This Skill
Use this skill when you encounter any of these scenarios:
File Patterns
- Working with
*.test.ts,*.spec.ts, or similar test files - Creating new test files for TypeScript/JavaScript modules
- Reviewing existing vitest test suites
User Intent Keywords
- User mentions: vitest, testing, TDD, BDD, unit tests, integration tests
- User asks to: write tests, add test coverage, fix failing tests, refactor tests
- User discusses: mocking, stubbing, assertions, test performance, test organization
Code Context
- Files importing from
vitest(describe,it,expect,vi) - Test setup/teardown code (
beforeEach,afterEach,beforeAll,afterAll) - Mock/spy implementations using
vi.mock(),vi.spyOn(),vi.fn() - Assertion chains (
expect(...).toEqual(),.toBe(),.toThrow(), etc.)
Common Tasks
- Writing new test cases for existing functionality
- Refactoring tests for better clarity or performance
- Debugging flaky or failing tests
- Improving test coverage or maintainability
- Reviewing test code for best practices compliance
Do NOT Use This Skill When
- Writing end-to-end tests with Playwright/Cypress (different scope)
- The task is purely about implementation code, not tests
What This Skill Covers
This skill provides comprehensive guidance on:
1. Test Organization: File placement, naming conventions, grouping strategies 2. AAA Pattern: Arrange, Act, Assert structure for clarity 3. Parameterized Tests: Using it.each() for testing variations 4. Error Handling: Testing exceptions, edge cases, and fault injection 5. Assertions: Choosing strict assertions (toEqual, toStrictEqual, toThrow) 6. Test Doubles: Fakes, stubs, mocks, spies - when to use each 7. Async Testing: Promises, async/await, timers, and concurrent tests 8. Performance: Fast tests, avoiding expensive operations, cleanup patterns 9. Vitest-Specific Features: Coverage, watch mode, benchmarking, type testing, setup files 10. Snapshot Testing: When and how to use snapshots effectively
How to Use
This skill uses a progressive disclosure structure to minimize context usage:
1. Start with the Overview (AGENTS.md)
Read AGENTS.md for a concise overview of all rules with one-line summaries.
2. Load Specific Rules as Needed
When you identify a relevant optimization, load the corresponding reference file for detailed implementation guidance:
Core Patterns:
- organization.md
- aaa-pattern.md
- parameterized-tests.md
- error-handling.md
- assertions.md
- test-doubles.md
Advanced Topics:
- async-testing.md
- performance.md
- vitest-features.md
- snapshot-testing.md
3. Apply the Pattern
Each reference file contains:
- ❌ Incorrect examples showing the anti-pattern
- ✅ Correct examples showing the optimal implementation
- Explanations of why the pattern matters
Quick Example
This skill helps you transform unclear tests into clear, maintainable ones:
Before (unclear):
test('product test', () => {
const p = new ProductService().add({name: 'Widget'});
expect(p.status).toBe('pendingApproval');
});After (optimized with this skill):
describe('ProductService', () => {
describe('Add new product', () => {
it('should have status "pending approval" when no price is specified', () => {
// Arrange
const productService = new ProductService();
// Act
const newProduct = productService.add({name: 'Widget'});
// Assert
expect(newProduct.status).toEqual('pendingApproval');
});
});
});Key Principles
- Clarity over cleverness: Tests should be instantly understandable
- Flat structure: Avoid deep nesting in describe blocks
- One assertion per concept: Focus tests on single behaviors
- Strict assertions: Prefer
toEqualovertoBe,toStrictEqualwhen needed - Minimal mocking: Use real implementations when practical
- Fast execution: Keep tests quick through efficient setup/teardown
Vitest Best Practices
Note:
This document is mainly for agents and LLMs to follow when maintaining, generating, or refactoring vitest tests. Humans may also find it useful, but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Comprehensive guide for testing with vitest, designed for AI agents and LLMs. Each rule includes one-line summaries here, with links to detailed examples in the references/ folder. Load reference files only when you need detailed implementation guidance for a specific rule.
Use the vitest testing framework. Design tests to be short, simple, flat, and instantly understandable.
---
How to Use This Guide
1. Start here: Scan the rule summaries to identify relevant patterns 2. Load references as needed: Click through to detailed examples only when implementing 3. Progressive loading: Each reference file is self-contained with examples
This structure minimizes context usage while providing complete implementation guidance when needed.
---
General Test Structure
Use it() with sentence-style descriptions:
✅ Correct: appropriate structure
describe('ProductsService', () => {
describe('Add new product', () => {
it('should have status "pending approval" when no price is specified', () => {
const newProduct = new ProductService().add(/*...*/);
expect(newProduct.status).toEqual('pendingApproval');
});
});
});---
1. General
1.1 Organization
Place test files next to implementation; one test file per module. View detailed examples
1.2 AAA Pattern
Structure tests as Arrange, Act, Assert for clarity. View detailed examples
1.3 Parameterized Tests
Use it.each for variations; one behavior per test. View detailed examples
1.4 Error Handling
Test negative cases, fault injection, and recovery thoroughly. View detailed examples
1.5 Assertions
Use strict assertions (toEqual, toStrictEqual) over loose ones. View detailed examples
1.6 Test Doubles
Prefer fakes > stubs > spies/mocks; avoid over-mocking. View detailed examples
1.7 Async Testing
Test promises, async/await, and timers correctly. View detailed examples
1.8 Performance
Keep tests fast through efficient setup and avoiding expensive operations. View detailed examples
1.9 Vitest Features
Use coverage, watch mode, benchmarking, and other vitest-specific features. View detailed examples
1.10 Snapshot Testing
Use snapshots for appropriate cases; avoid common pitfalls. View detailed examples
1.2 AAA Pattern
Structure tests as Arrange, Act, Assert for maximum clarity and readability.
What is AAA?
- Arrange: Set up the test data, dependencies, and preconditions
- Act: Execute the code under test
- Assert: Verify the expected outcome
This pattern makes tests instantly understandable by separating setup, execution, and verification.
Basic AAA Structure
❌ Incorrect: mixed arrange/act/assert
it('should return the default value for an unknown property', () => {
const defaultColor: Color = [128, 128, 128, 155];
const colorLookup = lookup(colorTable, defaultVal(defaultColor));
const actual = colorLookup('UNKNOWN');
expect(actual).toEqual(defaultColor);
// Another test mixed in!
const result2 = colorLookup('ANOTHER');
expect(result2).toEqual(defaultColor);
});✅ Correct: clear AAA structure with comments
it('should return the default value for an unknown property', () => {
// Arrange
const defaultColor: Color = [128, 128, 128, 155];
const colorLookup = lookup(colorTable, defaultVal(defaultColor));
// Act
const actual = colorLookup('UNKNOWN');
// Assert
expect(actual).toEqual(defaultColor);
});Why? Without clear separation and with multiple behaviors tested together, it's hard to understand what's being tested and why a test fails.
Blank Lines for Separation
Use blank lines between AAA sections even without comments for simple tests.
❌ Incorrect: no visual separation
it('should calculate total price with tax', () => {
const cart = new ShoppingCart();
cart.addItem({ name: 'Widget', price: 100 });
const total = cart.calculateTotal(0.08);
expect(total).toEqual(108);
});✅ Correct: visual separation with blank lines
it('should calculate total price with tax', () => {
const cart = new ShoppingCart();
cart.addItem({ name: 'Widget', price: 100 });
const total = cart.calculateTotal(0.08);
expect(total).toEqual(108);
});Why? Without visual separation, it's harder to quickly identify where setup ends and the actual test logic begins.
Multiple Assertions for Same Behavior
Multiple assertions are OK when they verify different aspects of the same behavior.
❌ Incorrect: testing multiple unrelated behaviors
it('should handle user operations', () => {
// Testing creation
const user = createUser({ email: 'test@example.com' });
expect(user.email).toEqual('test@example.com');
// Testing update (different behavior!)
updateUser(user.id, { name: 'Updated' });
expect(user.name).toEqual('Updated');
// Testing deletion (different behavior!)
deleteUser(user.id);
expect(getUser(user.id)).toBeNull();
});✅ Correct: multiple assertions for one behavior
it('should create user with all required fields', () => {
// Arrange
const userData = { email: 'test@example.com', name: 'Test User' };
// Act
const user = createUser(userData);
// Assert
expect(user.email).toEqual('test@example.com');
expect(user.name).toEqual('Test User');
expect(user.id).toBeDefined();
expect(user.createdAt).toBeInstanceOf(Date);
});Why? Each test should verify one behavior. Split this into three separate tests: creation, update, and deletion.
Avoid Logic in Tests
Keep the AAA sections simple - avoid conditional logic, loops, or complex calculations.
❌ Incorrect: complex logic in test
it('should filter active users', () => {
const users = generateUsers(100); // Hidden complexity
const userService = new UserService(users);
const activeUsers = userService.getActiveUsers();
// Complex verification logic
let count = 0;
for (const user of users) {
if (user.active) {
expect(activeUsers).toContain(user);
count++;
}
}
expect(activeUsers).toHaveLength(count);
});✅ Correct: straightforward test logic
it('should filter active users', () => {
// Arrange
const users = [
{ id: 1, name: 'Alice', active: true },
{ id: 2, name: 'Bob', active: false },
{ id: 3, name: 'Charlie', active: true },
];
const userService = new UserService(users);
// Act
const activeUsers = userService.getActiveUsers();
// Assert
expect(activeUsers).toHaveLength(2);
expect(activeUsers[0].name).toEqual('Alice');
expect(activeUsers[1].name).toEqual('Charlie');
});Why? If the test has bugs, you won't know if the test or the code is wrong. Keep tests simple and obvious.
Complex Arrange Sections
For complex setup, extract to helper functions or factories.
❌ Incorrect: complex setup in test
it('should apply discount to orders over $50', () => {
// Arrange - too much going on!
const order = new Order();
const items = [];
for (let i = 0; i < 10; i++) {
const item = {
id: i,
name: `Item ${i}`,
price: 10 * i,
category: i % 2 === 0 ? 'even' : 'odd',
taxable: i > 5,
};
items.push(item);
order.addItem(item);
}
const discountService = new DiscountService();
// Act
const discountedTotal = discountService.applyDiscount(order);
// Assert
expect(discountedTotal).toBeLessThan(order.total);
});✅ Correct: extracted setup helper
function createTestOrder(items: number = 3): Order {
const order = new Order();
for (let i = 0; i < items; i++) {
order.addItem({ id: i, name: `Item ${i}`, price: 10 * i });
}
return order;
}
it('should apply discount to orders over $50', () => {
// Arrange
const order = createTestOrder(10);
const discountService = new DiscountService();
// Act
const discountedTotal = discountService.applyDiscount(order);
// Assert
expect(discountedTotal).toBeLessThan(order.total);
expect(discountService.discountApplied).toEqual(0.1);
});Why? Complex setup obscures the test's purpose. Extract to helper functions or test-utils.
Async/Await with AAA
AAA works perfectly with async tests - just add await in the Act section.
❌ Incorrect: mixing setup with async calls
it('should fetch user from API', async () => {
const userId = 'user-123';
const apiClient = new ApiClient();
const user = await apiClient.getUser(userId); // Act hidden in middle
const profile = await apiClient.getProfile(userId); // More acts!
expect(user.id).toEqual(userId);
expect(profile.userId).toEqual(userId);
});✅ Correct: async AAA pattern
it('should fetch user from API', async () => {
// Arrange
const userId = 'user-123';
const apiClient = new ApiClient();
// Act
const user = await apiClient.getUser(userId);
// Assert
expect(user.id).toEqual(userId);
expect(user.name).toBeDefined();
});Why? Multiple async operations without clear separation make it unclear what's being tested.
When to Omit AAA Comments
For very simple tests, AAA comments can be omitted if blank lines provide sufficient clarity.
✅ Correct: simple test without AAA comments
it('should add two numbers', () => {
const calculator = new Calculator();
const result = calculator.add(2, 3);
expect(result).toEqual(5);
});However, when in doubt, include the comments. They never hurt and help onboarding developers understand your test structure.
1.5 Assertions
Use strict, precise assertions that verify exact behavior. Loose assertions can pass even when code is wrong.
Equality Assertions
Prefer toEqual over toBe for objects and arrays. Use toStrictEqual when you need to verify undefined properties.
✅ Correct: toEqual for objects
it('should return user object with correct properties', () => {
const user = createUser({ name: 'John', email: 'john@example.com' });
expect(user).toEqual({
name: 'John',
email: 'john@example.com',
id: expect.any(String),
createdAt: expect.any(Date),
});
});❌ Incorrect: toBe for objects
it('should return user object', () => {
const user = createUser({ name: 'John' });
expect(user).toBe({ name: 'John' }); // Always fails - different references!
});Why? toBe uses Object.is() reference equality. For objects/arrays, use toEqual.
toEqual vs toStrictEqual
Use toStrictEqual when you need to verify that undefined properties don't exist.
✅ Correct: toStrictEqual catches undefined properties
it('should not include undefined properties', () => {
const user = { name: 'John', email: 'john@example.com' };
expect(user).toStrictEqual({ name: 'John', email: 'john@example.com' });
});⚠️ Potential issue: toEqual ignores undefined
it('may not catch unexpected undefined', () => {
const user = { name: 'John', email: undefined };
// This passes with toEqual!
expect(user).toEqual({ name: 'John' });
// This fails with toStrictEqual (better)
expect(user).toStrictEqual({ name: 'John' }); // Fails - email is undefined
});Primitives: toBe vs toEqual
For primitives (numbers, strings, booleans), both work, but toBe is more semantically correct.
✅ Correct: toBe for primitives
expect(count).toBe(5);
expect(name).toBe('John');
expect(isValid).toBe(true);✅ Also correct but less semantic: toEqual for primitives
expect(count).toEqual(5); // Works but toBe is clearer for primitivesAvoid Loose Assertions
Don't use fuzzy matchers when you can be precise.
❌ Incorrect: loose assertion
it('should return user data', () => {
const result = fetchUser('123');
expect(result).toContain('john'); // Too vague!
});✅ Correct: precise assertion
it('should return user with email', () => {
const result = fetchUser('123');
expect(result).toEqual({
id: '123',
name: 'John Doe',
email: 'john@example.com',
});
});Array Assertions
Use specific array matchers for clarity.
✅ Correct: specific array matchers
describe('filterActiveUsers', () => {
it('should return array of active users only', () => {
const users = [
{ id: 1, name: 'Alice', active: true },
{ id: 2, name: 'Bob', active: false },
{ id: 3, name: 'Charlie', active: true },
];
const result = filterActiveUsers(users);
expect(result).toHaveLength(2);
expect(result).toEqual([
{ id: 1, name: 'Alice', active: true },
{ id: 3, name: 'Charlie', active: true },
]);
});
it('should contain specific user', () => {
const result = getAllUsers();
expect(result).toContainEqual({ id: 1, name: 'Alice', active: true });
});
it('should return empty array when no active users', () => {
const users = [{ id: 1, name: 'Bob', active: false }];
expect(filterActiveUsers(users)).toEqual([]);
});
});❌ Incorrect: imprecise array checks
it('should return users', () => {
const result = filterActiveUsers(users);
expect(result.length).toBeGreaterThan(0); // How many? Which users?
});String Assertions
Use appropriate string matchers based on what you're testing.
✅ Correct: precise string assertions
describe('formatName', () => {
it('should return full name', () => {
expect(formatName('john', 'doe')).toBe('John Doe');
});
it('should include title when provided', () => {
const result = formatName('john', 'doe', 'Dr.');
expect(result).toBe('Dr. John Doe');
});
it('should match name pattern', () => {
const result = formatName('john', 'doe');
expect(result).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/);
});
it('should contain first name', () => {
const result = formatName('john', 'doe');
expect(result).toContain('John');
});
});❌ Incorrect: overly loose string checks
it('should format name', () => {
const result = formatName('john', 'doe');
expect(result).toBeTruthy(); // Way too vague!
expect(result.length).toBeGreaterThan(0); // Still vague!
});Number Assertions
Use comparison matchers for numeric ranges and boundaries.
✅ Correct: numeric matchers
describe('calculateDiscount', () => {
it('should return positive discount', () => {
const discount = calculateDiscount(100, 0.1);
expect(discount).toBeGreaterThan(0);
expect(discount).toBeLessThanOrEqual(100);
});
it('should return exact discount amount', () => {
expect(calculateDiscount(100, 0.1)).toBe(10);
});
it('should handle floating point comparison', () => {
const result = calculateTax(99.99, 0.0825);
expect(result).toBeCloseTo(8.25, 2); // Within 2 decimal places
});
});Boolean and Nullish Assertions
Be explicit about boolean, null, and undefined checks.
✅ Correct: explicit boolean checks
describe('isValidEmail', () => {
it('should return true for valid email', () => {
expect(isValidEmail('test@example.com')).toBe(true);
});
it('should return false for invalid email', () => {
expect(isValidEmail('invalid')).toBe(false);
});
});
describe('findUser', () => {
it('should return null when user not found', () => {
expect(findUser('invalid-id')).toBeNull();
});
it('should return undefined for missing optional field', () => {
const user = createUser({ name: 'John' });
expect(user.middleName).toBeUndefined();
});
it('should have defined email', () => {
const user = createUser({ name: 'John', email: 'john@example.com' });
expect(user.email).toBeDefined();
});
});❌ Incorrect: loose truthy/falsy checks
it('should validate email', () => {
expect(isValidEmail('test@example.com')).toBeTruthy(); // Could be any truthy value!
});
it('should not find user', () => {
expect(findUser('invalid')).toBeFalsy(); // Could be false, null, undefined, 0, etc.
});Why? toBeTruthy/toBeFalsy are too permissive. Be explicit about the expected value.
Object Property Assertions
Use matchers that verify object structure and properties.
✅ Correct: object property matchers
describe('createUser', () => {
it('should have required properties', () => {
const user = createUser({ name: 'John', email: 'john@example.com' });
expect(user).toHaveProperty('id');
expect(user).toHaveProperty('name', 'John');
expect(user).toHaveProperty('email', 'john@example.com');
expect(user).toHaveProperty('createdAt');
});
it('should match expected shape', () => {
const user = createUser({ name: 'John', email: 'john@example.com' });
expect(user).toMatchObject({
name: 'John',
email: 'john@example.com',
});
// toMatchObject allows extra properties like id, createdAt
});
});Type Assertions
Verify types when type checking is important.
✅ Correct: type assertions
describe('parseData', () => {
it('should return correct types', () => {
const result = parseData('{"count": 5}');
expect(result.count).toEqual(expect.any(Number));
expect(result.timestamp).toEqual(expect.any(Date));
expect(result.tags).toEqual(expect.any(Array));
});
it('should return string array', () => {
const tags = getTags();
expect(tags).toEqual(expect.arrayContaining([expect.any(String)]));
});
});Asymmetric Matchers
Use asymmetric matchers when exact values aren't known but structure is.
✅ Correct: asymmetric matchers for dynamic values
describe('createOrder', () => {
it('should create order with generated ID', () => {
const order = createOrder({ items: [{ id: 1, quantity: 2 }] });
expect(order).toEqual({
id: expect.stringMatching(/^order-[a-f0-9]+$/),
items: expect.arrayContaining([
expect.objectContaining({ id: 1, quantity: 2 }),
]),
createdAt: expect.any(Date),
status: 'pending',
});
});
});Negation
Use .not to assert something is NOT true, but be specific.
✅ Correct: specific negation
it('should not include deleted users', () => {
const users = getActiveUsers();
expect(users).not.toContainEqual(
expect.objectContaining({ status: 'deleted' })
);
});
it('should not be empty string', () => {
const username = generateUsername();
expect(username).not.toBe('');
expect(username.length).toBeGreaterThan(0);
});❌ Incorrect: vague negation
it('should not be wrong', () => {
expect(result).not.toBeFalsy(); // What IS it then?
});Custom Error Messages
Add custom messages to clarify assertion failures.
✅ Correct: custom error messages for complex assertions
it('should process all items', () => {
const result = processItems(items);
expect(
result.every(item => item.processed === true),
'All items should have processed=true'
).toBe(true);
});Common Assertion Anti-Patterns
❌ Incorrect: no assertion
it('should create user', () => {
createUser({ name: 'John' });
// No assertion! Test always passes!
});❌ Incorrect: asserting implementation details
it('should call internal helper', () => {
const spy = vi.spyOn(service, '_internalHelper');
service.publicMethod();
expect(spy).toHaveBeenCalled(); // Testing internal implementation
});Why? Test behavior, not implementation. Internal helpers can change without breaking public API.
❌ Incorrect: multiple unrelated assertions
it('should work', () => {
const user = createUser({ name: 'John' });
expect(user.name).toBe('John');
const product = createProduct({ name: 'Widget' });
expect(product.name).toBe('Widget'); // Different concern - split into separate test
});1.7 Async Testing
Testing asynchronous code requires special handling to ensure tests wait for async operations to complete.
Basic Async/Await
Always use async/await for testing async functions.
✅ Correct: async/await
describe('fetchUser', () => {
it('should return user data', async () => {
const user = await fetchUser('user-123');
expect(user).toEqual({
id: 'user-123',
name: 'John Doe',
email: 'john@example.com',
});
});
});❌ Incorrect: not awaiting async function
it('should return user data', () => {
const user = fetchUser('user-123'); // Returns Promise, not user!
expect(user).toEqual({ id: 'user-123' }); // Test passes but wrong!
});Why? Without await, you're comparing a Promise object, not the actual result.
Testing Promises
Use resolves and rejects matchers for clean promise testing.
✅ Correct: using resolves matcher
it('should resolve with user data', async () => {
await expect(fetchUser('user-123')).resolves.toEqual({
id: 'user-123',
name: 'John Doe',
});
});✅ Correct: using rejects matcher
it('should reject for invalid user', async () => {
await expect(fetchUser('invalid')).rejects.toThrow('User not found');
});❌ Incorrect: manual promise handling
it('should return user', () => {
return fetchUser('user-123').then(user => {
expect(user.id).toBe('user-123');
});
});Why? While this works, async/await is clearer and more maintainable.
Testing Multiple Async Operations
✅ Correct: sequential async operations
it('should create and update user', async () => {
// Arrange
const userData = { name: 'John', email: 'john@example.com' };
// Act
const created = await userService.create(userData);
const updated = await userService.update(created.id, { name: 'Jane' });
// Assert
expect(updated.name).toBe('Jane');
expect(updated.email).toBe('john@example.com');
});✅ Correct: parallel async operations
it('should fetch multiple users in parallel', async () => {
const [user1, user2, user3] = await Promise.all([
fetchUser('user-1'),
fetchUser('user-2'),
fetchUser('user-3'),
]);
expect(user1.id).toBe('user-1');
expect(user2.id).toBe('user-2');
expect(user3.id).toBe('user-3');
});Testing Async Callbacks
For functions that use callbacks, promisify them or use done callback.
✅ Correct: promisify callback-based code
function fetchDataCallback(callback: (err: Error | null, data?: Data) => void) {
// ... async operation
}
function fetchDataPromise(): Promise<Data> {
return new Promise((resolve, reject) => {
fetchDataCallback((err, data) => {
if (err) reject(err);
else resolve(data!);
});
});
}
it('should fetch data', async () => {
const data = await fetchDataPromise();
expect(data).toBeDefined();
});✅ Correct: using done callback (when promisify isn't possible)
it('should call callback with data', (done) => {
fetchDataCallback((err, data) => {
expect(err).toBeNull();
expect(data).toBeDefined();
done();
});
});Testing Timeouts and Delays
Use fake timers to speed up tests that involve delays.
✅ Correct: fake timers for delays
describe('retry logic', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should retry after delay', async () => {
const mockFn = vi.fn()
.mockRejectedValueOnce(new Error('Fail'))
.mockResolvedValueOnce('Success');
const promise = retryWithDelay(mockFn, { delay: 1000, maxRetries: 2 });
// Fast-forward time
await vi.advanceTimersByTimeAsync(1000);
const result = await promise;
expect(result).toBe('Success');
expect(mockFn).toHaveBeenCalledTimes(2);
});
});❌ Incorrect: actually waiting for delays
it('should retry after delay', async () => {
// Test takes 1+ second to run!
const result = await retryWithDelay(mockFn, { delay: 1000 });
expect(result).toBe('Success');
});Why? Real delays slow down your test suite. Use fake timers instead.
Testing Concurrent Operations
Test that async operations work correctly when running concurrently.
✅ Correct: testing race conditions
describe('RateLimiter', () => {
it('should limit concurrent requests', async () => {
const limiter = new RateLimiter({ maxConcurrent: 2 });
const calls: number[] = [];
const task = async (id: number) => {
await limiter.acquire();
calls.push(id);
await delay(10);
limiter.release();
};
await Promise.all([
task(1),
task(2),
task(3),
task(4),
]);
expect(calls).toHaveLength(4);
// Verify no more than 2 concurrent
expect(limiter.getMaxConcurrent()).toBe(2);
});
});Testing Async Iteration
Test async generators and iterables properly.
✅ Correct: testing async generators
async function* generateNumbers() {
yield 1;
yield 2;
yield 3;
}
it('should yield all numbers', async () => {
const numbers: number[] = [];
for await (const num of generateNumbers()) {
numbers.push(num);
}
expect(numbers).toEqual([1, 2, 3]);
});Testing Event Emitters
Wait for async events using promises.
✅ Correct: testing event emitters
it('should emit "complete" event after processing', async () => {
const processor = new DataProcessor();
const completePromise = new Promise((resolve) => {
processor.once('complete', resolve);
});
processor.process(data);
await completePromise;
expect(processor.isComplete()).toBe(true);
});✅ Correct: testing multiple events
it('should emit progress events', async () => {
const processor = new DataProcessor();
const events: string[] = [];
processor.on('progress', (event) => {
events.push(event);
});
const completePromise = new Promise((resolve) => {
processor.once('complete', resolve);
});
processor.process(data);
await completePromise;
expect(events).toEqual(['started', 'processing', 'done']);
});Testing Async Setup/Teardown
Use async beforeEach and afterEach for async setup.
✅ Correct: async setup and teardown
describe('DatabaseTests', () => {
let db: Database;
beforeEach(async () => {
db = await createDatabase();
await db.migrate();
await db.seed();
});
afterEach(async () => {
await db.clear();
await db.close();
});
it('should query users', async () => {
const users = await db.query('SELECT * FROM users');
expect(users).toHaveLength(3);
});
});Testing Promise Rejection
Always test both success and failure paths for async operations.
✅ Correct: testing rejection cases
describe('authenticateUser', () => {
it('should resolve with token for valid credentials', async () => {
const token = await authenticateUser('user@example.com', 'password123');
expect(token).toMatch(/^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/);
});
it('should reject for invalid credentials', async () => {
await expect(
authenticateUser('user@example.com', 'wrongpassword')
).rejects.toThrow('Invalid credentials');
});
it('should reject for non-existent user', async () => {
await expect(
authenticateUser('nonexistent@example.com', 'password')
).rejects.toThrow('User not found');
});
});Avoiding Unhandled Promise Rejections
Always handle or assert on promises.
❌ Incorrect: unhandled promise
it('should handle error', () => {
fetchUser('invalid'); // Promise rejection not handled!
});✅ Correct: handling promise
it('should handle error', async () => {
await expect(fetchUser('invalid')).rejects.toThrow();
});Testing Async Error Handling
Verify that errors are properly caught and handled.
✅ Correct: testing try/catch behavior
async function processWithRetry(fn: () => Promise<any>) {
try {
return await fn();
} catch (error) {
// Retry once
return await fn();
}
}
it('should retry on failure', async () => {
const mockFn = vi.fn()
.mockRejectedValueOnce(new Error('Fail'))
.mockResolvedValueOnce('Success');
const result = await processWithRetry(mockFn);
expect(result).toBe('Success');
expect(mockFn).toHaveBeenCalledTimes(2);
});Testing Async Timeout Behavior
Test that operations timeout correctly.
✅ Correct: testing timeouts
describe('fetchWithTimeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should timeout after specified duration', async () => {
const slowFn = () => new Promise(resolve => {
setTimeout(() => resolve('done'), 5000);
});
const promise = fetchWithTimeout(slowFn, 1000);
await vi.advanceTimersByTimeAsync(1000);
await expect(promise).rejects.toThrow('Timeout');
});
it('should resolve before timeout', async () => {
const fastFn = () => new Promise(resolve => {
setTimeout(() => resolve('done'), 500);
});
const promise = fetchWithTimeout(fastFn, 1000);
await vi.advanceTimersByTimeAsync(500);
await expect(promise).resolves.toBe('done');
});
});Common Async Testing Anti-Patterns
❌ Incorrect: forgetting async keyword
it('should fetch user', () => { // Missing async!
await fetchUser('user-123'); // Syntax error!
});❌ Incorrect: mixing async/await with done
it('should fetch user', async (done) => { // Don't mix these!
const user = await fetchUser('user-123');
expect(user).toBeDefined();
done();
});❌ Incorrect: not returning or awaiting promise
it('should fetch user', () => {
// Promise not returned or awaited - test finishes before fetch!
fetchUser('user-123').then(user => {
expect(user).toBeDefined();
});
});Fix: Either await the promise or return it.
✅ Correct: returning promise
it('should fetch user', () => {
return fetchUser('user-123').then(user => {
expect(user).toBeDefined();
});
});✅ Better: using async/await
it('should fetch user', async () => {
const user = await fetchUser('user-123');
expect(user).toBeDefined();
});1.4 Error Handling
Error handling is critical - test it as thoroughly as the happy path. Good error handling tests verify that your code fails gracefully and provides meaningful feedback.
Basic Exception Testing
Use toThrow to verify exceptions are thrown for invalid inputs.
✅ Correct: testing specific error messages
describe('divide', () => {
it('should throw TypeError for division by zero', () => {
expect(() => divide(10, 0)).toThrow(TypeError);
expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
});
it('should throw TypeError for non-numeric inputs', () => {
expect(() => divide('10' as any, 5)).toThrow(TypeError);
expect(() => divide('10' as any, 5)).toThrow('Arguments must be numbers');
});
});❌ Incorrect: not testing error specifics
it('should throw for division by zero', () => {
expect(() => divide(10, 0)).toThrow(); // Which error? What message?
});Why? Testing only that an error is thrown doesn't verify you're throwing the right error with the right message.
Testing Error Types
Always verify the error type and message, not just that an error was thrown.
✅ Correct: specific error type and message
class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
describe('validateUser', () => {
it('should throw ValidationError for missing email', () => {
const invalidUser = { name: 'John' };
expect(() => validateUser(invalidUser)).toThrow(ValidationError);
expect(() => validateUser(invalidUser)).toThrow('Email is required');
});
it('should throw ValidationError for invalid email format', () => {
const invalidUser = { name: 'John', email: 'not-an-email' };
expect(() => validateUser(invalidUser)).toThrow(ValidationError);
expect(() => validateUser(invalidUser)).toThrow('Invalid email format');
});
});❌ Incorrect: generic error testing
it('should throw error for invalid user', () => {
expect(() => validateUser({ name: 'John' })).toThrow(Error);
});Why? Any error will pass this test, including unexpected errors from bugs in your code.
Async Error Testing
Use rejects matchers for async functions that throw.
✅ Correct: async error testing
describe('fetchUser', () => {
it('should reject with error for non-existent user', async () => {
await expect(fetchUser('invalid-id')).rejects.toThrow('User not found');
});
it('should reject with NotFoundError', async () => {
await expect(fetchUser('invalid-id')).rejects.toThrow(NotFoundError);
});
});❌ Incorrect: improper async error testing
it('should throw for non-existent user', async () => {
try {
await fetchUser('invalid-id');
// Missing fail() here - test will pass if no error!
} catch (error) {
expect(error.message).toEqual('User not found');
}
});Why? If the function doesn't throw, the test will pass incorrectly. Use rejects matcher instead.
Negative Testing
Test boundary conditions and invalid inputs comprehensively.
✅ Correct: comprehensive negative testing
describe('calculateAge', () => {
it.each([
{ birthdate: 'not-a-date', error: 'Invalid date format' },
{ birthdate: new Date('2099-01-01'), error: 'Birth date cannot be in the future' },
{ birthdate: null, error: 'Birth date is required' },
{ birthdate: undefined, error: 'Birth date is required' },
])('should throw for invalid birthdate: $birthdate',
({ birthdate, error }) => {
expect(() => calculateAge(birthdate as any)).toThrow(error);
}
);
it('should calculate age for valid birthdate', () => {
const birthdate = new Date('1990-01-01');
const age = calculateAge(birthdate);
expect(age).toBeGreaterThan(0);
});
});Fault Injection
Simulate system failures to test error handling and resilience.
✅ Correct: simulating network failures
describe('DataService', () => {
it('should retry on network failure', async () => {
const apiClient = {
fetch: vi.fn()
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({ data: 'success' }),
};
const service = new DataService(apiClient);
const result = await service.fetchWithRetry('/api/data');
expect(result).toEqual({ data: 'success' });
expect(apiClient.fetch).toHaveBeenCalledTimes(3);
});
it('should fail after max retries', async () => {
const apiClient = {
fetch: vi.fn().mockRejectedValue(new Error('Network error')),
};
const service = new DataService(apiClient);
await expect(service.fetchWithRetry('/api/data'))
.rejects.toThrow('Max retries exceeded');
expect(apiClient.fetch).toHaveBeenCalledTimes(3);
});
});✅ Correct: simulating database errors
describe('UserRepository', () => {
it('should handle database connection errors', async () => {
const db = {
query: vi.fn().mockRejectedValue(new Error('Connection lost')),
};
const repo = new UserRepository(db);
await expect(repo.findById('user-123'))
.rejects.toThrow('Database error: Connection lost');
});
it('should handle query timeouts', async () => {
const db = {
query: vi.fn().mockRejectedValue(new Error('Query timeout')),
};
const repo = new UserRepository(db);
await expect(repo.findById('user-123'))
.rejects.toThrow('Query timeout');
});
});Recovery Testing
Verify that systems can recover from failures and return to normal operation.
✅ Correct: testing circuit breaker recovery
describe('CircuitBreaker', () => {
it('should open circuit after threshold failures', async () => {
const failingService = vi.fn().mockRejectedValue(new Error('Service down'));
const breaker = new CircuitBreaker(failingService, { threshold: 3 });
// Trigger failures to open circuit
for (let i = 0; i < 3; i++) {
await expect(breaker.call()).rejects.toThrow('Service down');
}
// Circuit should now be open
await expect(breaker.call()).rejects.toThrow('Circuit breaker is open');
expect(failingService).toHaveBeenCalledTimes(3); // No more calls
});
it('should close circuit after recovery period', async () => {
vi.useFakeTimers();
const service = vi.fn()
.mockRejectedValueOnce(new Error('Service down'))
.mockResolvedValue('success');
const breaker = new CircuitBreaker(service, {
threshold: 1,
resetTimeout: 5000,
});
// Open circuit
await expect(breaker.call()).rejects.toThrow('Service down');
await expect(breaker.call()).rejects.toThrow('Circuit breaker is open');
// Wait for reset timeout
vi.advanceTimersByTime(5000);
// Circuit should allow retry
const result = await breaker.call();
expect(result).toEqual('success');
vi.useRealTimers();
});
});Error Guessing
Anticipate edge cases based on domain knowledge.
✅ Correct: testing common edge cases
describe('parseJSON', () => {
it.each([
{ input: '', description: 'empty string' },
{ input: 'null', description: 'null value' },
{ input: 'undefined', description: 'undefined as string' },
{ input: '{broken json', description: 'malformed JSON' },
{ input: '{"key": undefined}', description: 'undefined in object' },
{ input: 'NaN', description: 'NaN value' },
{ input: '{\"key\": Infinity}', description: 'Infinity value' },
])('should handle $description gracefully', ({ input }) => {
expect(() => parseJSON(input)).toThrow(SyntaxError);
});
});
describe('processFile', () => {
it.each([
{ filename: '', error: 'Filename cannot be empty' },
{ filename: '../../../etc/passwd', error: 'Invalid filename' },
{ filename: 'file\x00name', error: 'Invalid characters' },
{ filename: '.'.repeat(300), error: 'Filename too long' },
])('should reject dangerous filename: "$filename"',
({ filename, error }) => {
expect(() => processFile(filename)).toThrow(error);
}
);
});Testing Error Boundaries (React)
For React components, test error boundaries handle errors gracefully.
✅ Correct: testing error boundary
describe('ErrorBoundary', () => {
it('should catch errors and display fallback UI', () => {
const ThrowError = () => {
throw new Error('Test error');
};
const { getByText } = render(
<ErrorBoundary fallback={<div>Error occurred</div>}>
<ThrowError />
</ErrorBoundary>
);
expect(getByText('Error occurred')).toBeInTheDocument();
});
it('should log error to error reporting service', () => {
const errorLogger = vi.fn();
const ThrowError = () => {
throw new Error('Test error');
};
render(
<ErrorBoundary onError={errorLogger}>
<ThrowError />
</ErrorBoundary>
);
expect(errorLogger).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Test error',
})
);
});
});Validation Errors
Test comprehensive input validation.
✅ Correct: thorough validation testing
describe('createOrder', () => {
it('should validate all required fields', () => {
const invalidOrders = [
{ error: 'Customer ID is required' },
{ customerId: 'c1', error: 'Items array cannot be empty' },
{ customerId: 'c1', items: [], error: 'Items array cannot be empty' },
{ customerId: '', items: [{ id: 1 }], error: 'Customer ID is required' },
];
invalidOrders.forEach(({ error, ...order }) => {
expect(() => createOrder(order as any)).toThrow(error);
});
});
it('should validate item quantities', () => {
const order = {
customerId: 'c1',
items: [{ id: 1, quantity: -1 }],
};
expect(() => createOrder(order)).toThrow('Quantity must be positive');
});
it('should validate item prices', () => {
const order = {
customerId: 'c1',
items: [{ id: 1, quantity: 1, price: -10 }],
};
expect(() => createOrder(order)).toThrow('Price cannot be negative');
});
});Error Messages Quality
Test that error messages are helpful and actionable.
✅ Correct: descriptive error messages
describe('processPayment', () => {
it('should provide helpful error for insufficient funds', async () => {
const payment = { amount: 1000, accountBalance: 100 };
await expect(processPayment(payment))
.rejects.toThrow('Insufficient funds: balance $100, required $1000');
});
it('should include transaction ID in error messages', async () => {
const payment = { amount: 1000, transactionId: 'txn-123' };
await expect(processPayment(payment))
.rejects.toThrow(expect.stringContaining('txn-123'));
});
});❌ Incorrect: vague error messages
it('should throw error for invalid payment', async () => {
await expect(processPayment(payment)).rejects.toThrow('Error'); // Not helpful!
});1.1 Organization
File Placement and Naming
Place test files next to their implementation for easy discovery and maintenance.
❌ Incorrect: separate test directory
src/
components/
button.tsx
utils/
formatters.ts
tests/
components/
button.test.tsx
utils/
formatters.test.ts✅ Correct: co-located test files
src/
components/
button.tsx
button.test.tsx
utils/
formatters.ts
formatters.test.ts
services/
api.ts
api.test.tsWhy? Separate test directories make it harder to find tests, keep them in sync with implementation, and increase cognitive overhead.
Naming Conventions
Use consistent naming patterns for test files:
*.test.tsor*.spec.tsfor tests
❌ Incorrect: vague or inconsistent names
test1.ts
user_tests.ts // snake_case instead of kebab-case
payment.spec.js // mixing .js and .ts
authTest.ts // camelCase instead of kebab-case✅ Correct: descriptive test file names
user-service.test.ts
payment-processor.test.ts
auth.integration.test.tsOne Test File Per Module
Each module, component, or class should have exactly one corresponding test file.
❌ Incorrect: multiple test files for one module
user-service.test.ts
user-service.get-user.test.ts
user-service.update-user.test.ts✅ Correct: one-to-one mapping
// user-service.ts
export class UserService {
getUser(id: string) { /* ... */ }
updateUser(id: string, data: UserData) { /* ... */ }
}
// user-service.test.ts
describe('UserService', () => {
describe('getUser', () => { /* ... */ });
describe('updateUser', () => { /* ... */ });
});Why? Multiple test files fragment related tests, making it harder to understand the full behavior of a module.
Shared Test Utilities
Store reusable test setup, fixtures, and helpers in dedicated directories.
✅ Correct: organized test utilities
src/
test-utils/
setup.ts // Global test configuration
factories.ts // Test data factories
matchers.ts // Custom matchers
fixtures/
users.json // Test data
products.json
__tests__/
integration/ // Shared integration test setup
db-setup.tsExample test utility:
// test-utils/factories.ts
export function createMockUser(overrides?: Partial<User>): User {
return {
id: 'test-user-id',
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides,
};
}
// user-service.test.ts
import { createMockUser } from '../test-utils/factories';
it('should update user email', () => {
const user = createMockUser({ email: 'old@example.com' });
// ...
});Describe Block Organization
Use flat, focused describe blocks to group related tests.
❌ Incorrect: deep nesting
describe('ShoppingCart', () => {
describe('when cart is empty', () => {
describe('addItem', () => {
describe('with valid item', () => {
it('should add item', () => { /* ... */ });
});
describe('with invalid item', () => {
it('should throw', () => { /* ... */ });
});
});
});
describe('when cart has items', () => {
describe('addItem', () => {
// deeply nested...
});
});
});✅ Correct: flat structure, grouped by method
describe('ShoppingCart', () => {
describe('addItem', () => {
it('should add item to empty cart', () => { /* ... */ });
it('should increment quantity when adding existing item', () => { /* ... */ });
it('should throw when adding invalid item', () => { /* ... */ });
});
describe('removeItem', () => {
it('should remove item from cart', () => { /* ... */ });
it('should throw when removing non-existent item', () => { /* ... */ });
});
describe('calculateTotal', () => {
it('should return 0 for empty cart', () => { /* ... */ });
it('should sum all item prices', () => { /* ... */ });
});
});Why? Deep nesting makes tests harder to read and adds unnecessary indentation. Put context in the test name instead.
Setup and Teardown
Use beforeEach and afterEach for common setup, but keep tests independent.
❌ Incorrect: shared mutable state between tests
describe('DatabaseService', () => {
const db = createTestDatabase(); // Shared across all tests!
it('should insert record', async () => {
await db.insert({ name: 'Test' });
// ...
});
it('should update record', async () => {
// Depends on previous test's data!
await db.update(1, { name: 'Updated' });
});
});✅ Correct: clean setup per test
describe('DatabaseService', () => {
let db: Database;
beforeEach(async () => {
db = await createTestDatabase();
});
afterEach(async () => {
await db.close();
});
it('should insert record', async () => {
await db.insert({ name: 'Test' });
const records = await db.query('SELECT * FROM users');
expect(records).toHaveLength(1);
});
});Why? Tests that share state are fragile and order-dependent. Each test should be fully independent.
1.3 Parameterized Tests
Use it.each to test the same behavior with different inputs. This eliminates code duplication while keeping tests focused and readable.
Basic Parameterized Tests
Test one behavior with multiple input/output combinations using it.each.
✅ Correct: it.each for variations of same behavior
describe('factorial', () => {
it.each([
{ input: 0, expected: 1 },
{ input: 1, expected: 1 },
{ input: 5, expected: 120 },
{ input: 7, expected: 5040 },
])('should return $expected when given $input', ({ input, expected }) => {
expect(factorial(input)).toEqual(expected);
});
it('should throw when the input is negative', () => {
expect(() => factorial(-1)).toThrow('Number must not be negative');
});
});❌ Incorrect: duplicate tests
describe('factorial', () => {
it('should return 1 when given 0', () => {
expect(factorial(0)).toEqual(1);
});
it('should return 1 when given 1', () => {
expect(factorial(1)).toEqual(1);
});
it('should return 120 when given 5', () => {
expect(factorial(5)).toEqual(120);
});
it('should return 5040 when given 7', () => {
expect(factorial(7)).toEqual(5040);
});
});Why? Duplicated test code is harder to maintain. If the assertion logic changes, you need to update multiple tests.
Template Strings in Test Names
Use $variable syntax in test descriptions to show which inputs are being tested.
✅ Correct: descriptive test names with variables
it.each([
{ email: 'test@example.com', valid: true },
{ email: 'invalid-email', valid: false },
{ email: 'test@', valid: false },
{ email: '@example.com', valid: false },
])('should return $valid for email "$email"', ({ email, valid }) => {
expect(isValidEmail(email)).toEqual(valid);
});❌ Incorrect: generic test name
it.each([
{ email: 'test@example.com', valid: true },
{ email: 'invalid-email', valid: false },
])('should validate email', ({ email, valid }) => {
expect(isValidEmail(email)).toEqual(valid);
});Why? When a test fails, you won't know which input caused the failure without checking test output details.
Array Syntax
You can also use array syntax instead of objects for simpler cases.
✅ Correct: array syntax for simple cases
it.each([
[0, 1],
[1, 1],
[5, 120],
[7, 5040],
])('factorial(%i) should equal %i', (input, expected) => {
expect(factorial(input)).toEqual(expected);
});Object syntax is preferred when you have many parameters or want clearer naming:
✅ Better: object syntax for clarity
it.each([
{ n: 0, expected: 1 },
{ n: 1, expected: 1 },
{ n: 5, expected: 120 },
{ n: 7, expected: 5040 },
])('factorial($n) should equal $expected', ({ n, expected }) => {
expect(factorial(n)).toEqual(expected);
});Testing Multiple Related Behaviors
Don't mix different behaviors in one parameterized test. Split them into separate tests.
✅ Correct: separate tests for different behaviors
describe('calculateDiscount', () => {
it.each([
{ price: 100, discount: 0.1, expected: 90 },
{ price: 50, discount: 0.2, expected: 40 },
{ price: 200, discount: 0.15, expected: 170 },
])('should return $expected when price is $price and discount is $discount',
({ price, discount, expected }) => {
expect(calculateDiscount(price, discount)).toEqual(expected);
}
);
it.each([
{ price: -10, discount: 0.1 },
{ price: 100, discount: -0.1 },
{ price: 100, discount: 1.5 },
])('should throw for invalid inputs: price=$price, discount=$discount',
({ price, discount }) => {
expect(() => calculateDiscount(price, discount)).toThrow();
}
);
});❌ Incorrect: mixing valid and error cases
it.each([
{ price: 100, discount: 0.1, expected: 90, shouldThrow: false },
{ price: 50, discount: 0.2, expected: 40, shouldThrow: false },
{ price: -10, discount: 0.1, expected: null, shouldThrow: true },
{ price: 100, discount: -0.1, expected: null, shouldThrow: true },
])('should handle price=$price and discount=$discount',
({ price, discount, expected, shouldThrow }) => {
if (shouldThrow) {
expect(() => calculateDiscount(price, discount)).toThrow();
} else {
expect(calculateDiscount(price, discount)).toEqual(expected);
}
}
);Why? Conditional logic in tests makes them harder to understand and maintain. Each test should have one clear purpose.
Complex Test Data
For complex test cases, extract data to a separate constant.
✅ Correct: extracted test data
const USER_VALIDATION_CASES = [
{
user: { email: 'valid@example.com', age: 25, name: 'John' },
expected: true,
description: 'valid user',
},
{
user: { email: 'invalid-email', age: 25, name: 'John' },
expected: false,
description: 'invalid email',
},
{
user: { email: 'valid@example.com', age: 15, name: 'John' },
expected: false,
description: 'underage user',
},
{
user: { email: 'valid@example.com', age: 25, name: '' },
expected: false,
description: 'empty name',
},
];
describe('validateUser', () => {
it.each(USER_VALIDATION_CASES)(
'should return $expected for $description',
({ user, expected }) => {
expect(validateUser(user)).toEqual(expected);
}
);
});❌ Incorrect: inline complex data obscures test structure
it.each([
{ user: { email: 'valid@example.com', age: 25, name: 'John', address: { street: '123 Main', city: 'NYC', zip: '10001' }, preferences: { newsletter: true, notifications: false } }, expected: true },
{ user: { email: 'invalid', age: 25, name: 'John', address: { street: '123 Main', city: 'NYC', zip: '10001' }, preferences: { newsletter: true, notifications: false } }, expected: false },
// ... more complex objects
])('should validate user', ({ user, expected }) => {
expect(validateUser(user)).toEqual(expected);
});Edge Cases and Boundaries
Use parameterized tests to comprehensively cover edge cases and boundary conditions.
✅ Correct: comprehensive edge case coverage
describe('clamp', () => {
it.each([
{ value: 5, min: 0, max: 10, expected: 5, case: 'value within range' },
{ value: -5, min: 0, max: 10, expected: 0, case: 'value below min' },
{ value: 15, min: 0, max: 10, expected: 10, case: 'value above max' },
{ value: 0, min: 0, max: 10, expected: 0, case: 'value equals min' },
{ value: 10, min: 0, max: 10, expected: 10, case: 'value equals max' },
{ value: 5, min: 5, max: 5, expected: 5, case: 'min equals max' },
])('should return $expected when $case', ({ value, min, max, expected }) => {
expect(clamp(value, min, max)).toEqual(expected);
});
});Using describe.each for Test Groups
For testing multiple related scenarios, use describe.each to create test suites.
✅ Correct: describe.each for different user roles
describe.each([
{ role: 'admin', canEdit: true, canDelete: true, canView: true },
{ role: 'editor', canEdit: true, canDelete: false, canView: true },
{ role: 'viewer', canEdit: false, canDelete: false, canView: true },
])('User with $role role', ({ role, canEdit, canDelete, canView }) => {
let user: User;
beforeEach(() => {
user = createUser({ role });
});
it(`should ${canEdit ? '' : 'not '}be able to edit`, () => {
expect(user.canEdit()).toEqual(canEdit);
});
it(`should ${canDelete ? '' : 'not '}be able to delete`, () => {
expect(user.canDelete()).toEqual(canDelete);
});
it(`should ${canView ? '' : 'not '}be able to view`, () => {
expect(user.canView()).toEqual(canView);
});
});When NOT to Use Parameterized Tests
Don't use it.each when test cases have different setup or assertion logic.
❌ Incorrect: forcing parameterization
it.each([
{ type: 'email', input: 'test@example.com', setupFn: setupEmail },
{ type: 'phone', input: '123-456-7890', setupFn: setupPhone },
])('should validate $type', ({ type, input, setupFn }) => {
setupFn(); // Different setup for each type
if (type === 'email') {
expect(validateEmail(input)).toEqual(true);
} else {
expect(validatePhone(input)).toEqual(true);
}
});✅ Correct: separate tests with different logic
describe('validateEmail', () => {
it.each([
'test@example.com',
'user.name@example.co.uk',
'user+tag@example.com',
])('should return true for valid email: %s', (email) => {
expect(validateEmail(email)).toEqual(true);
});
});
describe('validatePhone', () => {
it.each([
'123-456-7890',
'(123) 456-7890',
'+1-123-456-7890',
])('should return true for valid phone: %s', (phone) => {
expect(validatePhone(phone)).toEqual(true);
});
});Why? When setup or assertions differ significantly, separate tests are clearer than conditional logic within a parameterized test.
1.8 Performance
Keep tests fast by avoiding expensive operations and optimizing setup/teardown. Fast tests encourage developers to run them frequently.
Keep Tests Fast
Tests should run in milliseconds, not seconds.
✅ Correct: fast, focused test
it('should calculate total price', () => {
const order = { items: [{ price: 10 }, { price: 20 }] };
expect(calculateTotal(order)).toBe(30);
});
// Runs in <1ms❌ Incorrect: unnecessarily slow test
it('should calculate total price', async () => {
await delay(1000); // Why?
const order = await fetchOrderFromAPI(); // Use test data instead!
expect(calculateTotal(order)).toBeGreaterThan(0);
});
// Runs in >1 secondAvoid Real Network Calls
Never make actual HTTP requests in unit tests.
❌ Incorrect: real network calls
it('should fetch user data', async () => {
const response = await fetch('https://api.example.com/users/123');
const user = await response.json();
expect(user.id).toBe('123');
});
// Slow, flaky, requires network✅ Correct: mocked network calls
it('should fetch user data', async () => {
const mockFetch = vi.fn().mockResolvedValue({
json: async () => ({ id: '123', name: 'John' }),
});
const user = await fetchUser('123', mockFetch);
expect(user.id).toBe('123');
});
// Fast, reliable, no network neededMinimize Setup and Teardown
Only set up what you need for each test.
❌ Incorrect: expensive shared setup
describe('UserService', () => {
let database: Database;
let cache: Cache;
let emailService: EmailService;
let analyticsService: AnalyticsService;
beforeEach(async () => {
// Setting up everything for every test!
database = await createRealDatabase();
await database.migrate();
cache = await createRedisCache();
emailService = new EmailService(await createSMTPConnection());
analyticsService = new AnalyticsService(await createKafkaProducer());
});
it('should validate email format', () => {
// Only needs email validation, but set up entire system!
expect(UserService.isValidEmail('test@example.com')).toBe(true);
});
});✅ Correct: minimal setup per test
describe('UserService', () => {
it('should validate email format', () => {
// No setup needed for pure function
expect(UserService.isValidEmail('test@example.com')).toBe(true);
});
it('should save user to database', async () => {
// Only set up what's needed
const db = new InMemoryDatabase();
const service = new UserService(db);
await service.saveUser({ name: 'John', email: 'john@example.com' });
const users = await db.findAll();
expect(users).toHaveLength(1);
});
});Use In-Memory Implementations
Prefer in-memory fakes over real external services.
✅ Correct: in-memory database
class InMemoryUserRepository {
private users = new Map<string, User>();
async save(user: User) {
this.users.set(user.id, user);
}
async findById(id: string) {
return this.users.get(id) || null;
}
}
describe('UserService', () => {
it('should create and retrieve user', async () => {
const repo = new InMemoryUserRepository();
const service = new UserService(repo);
const created = await service.createUser({ name: 'John' });
const retrieved = await service.getUser(created.id);
expect(retrieved).toEqual(created);
});
});
// Fast: no database connection, no I/OAvoid File System I/O
Mock file system operations in unit tests.
❌ Incorrect: real file operations
it('should save config to file', async () => {
const config = { theme: 'dark', language: 'en' };
await saveConfig('./test-config.json', config);
const loaded = await loadConfig('./test-config.json');
expect(loaded).toEqual(config);
// Clean up
await fs.unlink('./test-config.json');
});✅ Correct: mocked file system
it('should save config to file', async () => {
const mockFs = {
writeFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue('{"theme":"dark","language":"en"}'),
};
const config = { theme: 'dark', language: 'en' };
await saveConfig('./test-config.json', config, mockFs);
expect(mockFs.writeFile).toHaveBeenCalledWith(
'./test-config.json',
JSON.stringify(config)
);
});Optimize Fake Timers
Use fake timers instead of real delays.
❌ Incorrect: real delays
it('should throttle function calls', async () => {
const fn = vi.fn();
const throttled = throttle(fn, 1000);
throttled();
await delay(500);
throttled(); // Ignored
await delay(600);
throttled(); // Called
expect(fn).toHaveBeenCalledTimes(2);
});
// Takes 1.1+ seconds✅ Correct: fake timers
it('should throttle function calls', () => {
vi.useFakeTimers();
const fn = vi.fn();
const throttled = throttle(fn, 1000);
throttled();
vi.advanceTimersByTime(500);
throttled(); // Ignored
vi.advanceTimersByTime(600);
throttled(); // Called
expect(fn).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
// Runs in millisecondsAvoid Unnecessary Async
Don't make tests async if they don't need to be.
❌ Incorrect: unnecessary async
it('should add two numbers', async () => {
const result = add(2, 3);
expect(result).toBe(5);
});✅ Correct: synchronous test
it('should add two numbers', () => {
const result = add(2, 3);
expect(result).toBe(5);
});Batch Similar Tests
Use it.each to reduce setup duplication.
❌ Incorrect: repeated setup
describe('isValidEmail', () => {
it('should return true for test@example.com', () => {
expect(isValidEmail('test@example.com')).toBe(true);
});
it('should return true for user.name@example.co.uk', () => {
expect(isValidEmail('user.name@example.co.uk')).toBe(true);
});
it('should return false for invalid', () => {
expect(isValidEmail('invalid')).toBe(false);
});
it('should return false for @example.com', () => {
expect(isValidEmail('@example.com')).toBe(false);
});
});✅ Correct: parameterized tests
describe('isValidEmail', () => {
it.each([
{ email: 'test@example.com', expected: true },
{ email: 'user.name@example.co.uk', expected: true },
{ email: 'invalid', expected: false },
{ email: '@example.com', expected: false },
])('should return $expected for "$email"', ({ email, expected }) => {
expect(isValidEmail(email)).toBe(expected);
});
});
// Less overhead, faster executionLazy Initialization
Only create expensive objects when needed.
❌ Incorrect: eager initialization
describe('OrderService', () => {
const emailService = new EmailService(); // Created even if not used
const paymentGateway = new PaymentGateway(); // Created even if not used
it('should calculate order total', () => {
const order = { items: [{ price: 10 }] };
expect(OrderService.calculateTotal(order)).toBe(10);
// Didn't need emailService or paymentGateway!
});
});✅ Correct: lazy initialization
describe('OrderService', () => {
function createEmailService() {
return new EmailService();
}
function createPaymentGateway() {
return new PaymentGateway();
}
it('should calculate order total', () => {
// No services created
const order = { items: [{ price: 10 }] };
expect(OrderService.calculateTotal(order)).toBe(10);
});
it('should send confirmation email', async () => {
// Only create what's needed
const emailService = createEmailService();
const orderService = new OrderService(emailService);
await orderService.confirmOrder(order);
expect(emailService.getSentEmails()).toHaveLength(1);
});
});Cleanup Between Tests
Always clean up to prevent memory leaks and test pollution.
✅ Correct: proper cleanup
describe('EventEmitter', () => {
let emitter: EventEmitter;
beforeEach(() => {
emitter = new EventEmitter();
});
afterEach(() => {
emitter.removeAllListeners(); // Clean up listeners
vi.clearAllMocks(); // Clear mock call history
});
it('should emit event', () => {
const listener = vi.fn();
emitter.on('test', listener);
emitter.emit('test');
expect(listener).toHaveBeenCalled();
});
});Use Test-Specific Data
Create minimal test data instead of large fixtures.
❌ Incorrect: large, realistic data
it('should validate user name', () => {
const user = {
id: '550e8400-e29b-41d4-a716-446655440000',
name: 'John Doe',
email: 'john.doe@example.com',
phone: '+1-555-123-4567',
address: {
street: '123 Main St',
city: 'Springfield',
state: 'IL',
zip: '62701',
country: 'USA',
},
preferences: {
theme: 'dark',
language: 'en',
timezone: 'America/Chicago',
notifications: {
email: true,
sms: false,
push: true,
},
},
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-15'),
};
expect(validateUserName(user.name)).toBe(true);
// Only needed name!
});✅ Correct: minimal test data
it('should validate user name', () => {
expect(validateUserName('John Doe')).toBe(true);
});
it('should validate full user object', () => {
const user = {
name: 'John Doe',
email: 'john@example.com',
};
expect(validateUser(user)).toBe(true);
});Avoid Deep Object Comparisons
Use specific assertions instead of comparing entire objects.
❌ Incorrect: deep comparison
it('should update user name', async () => {
const user = await userService.updateName('user-123', 'Jane Doe');
expect(user).toEqual({
id: 'user-123',
name: 'Jane Doe',
email: 'user@example.com',
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
preferences: { /* large nested object */ },
// ... many more fields
});
});✅ Correct: specific assertions
it('should update user name', async () => {
const user = await userService.updateName('user-123', 'Jane Doe');
expect(user.name).toBe('Jane Doe');
expect(user.id).toBe('user-123');
});Run Tests in Parallel
Vitest runs tests in parallel by default. Don't disable it unless necessary.
✅ Correct: parallel execution (default)
// vitest.config.ts
export default {
test: {
// No need to configure - parallel by default
},
};❌ Incorrect: forcing sequential execution
// vitest.config.ts
export default {
test: {
pool: 'forks',
poolOptions: {
threads: {
singleThread: true, // Slow!
},
},
},
};Isolate Test Files
Avoid shared global state between test files.
❌ Incorrect: shared mutable state
// global-state.ts
export const cache = new Map();
// test1.test.ts
import { cache } from './global-state';
it('should add item to cache', () => {
cache.set('key', 'value');
expect(cache.get('key')).toBe('value');
});
// test2.test.ts
import { cache } from './global-state';
it('should have empty cache', () => {
expect(cache.size).toBe(0); // Fails if test1 runs first!
});✅ Correct: isolated state
// test1.test.ts
it('should add item to cache', () => {
const cache = new Map();
cache.set('key', 'value');
expect(cache.get('key')).toBe('value');
});
// test2.test.ts
it('should have empty cache', () => {
const cache = new Map();
expect(cache.size).toBe(0);
});Monitor Test Performance
Identify and fix slow tests using Vitest's reporter.
# Run tests with timing information
vitest --reporter=verbose
# Find slow tests
vitest --reporter=default --slow-test-threshold=1000✅ Correct: refactor slow tests
// Before: 5 seconds
it('should process large dataset', async () => {
const data = await fetchLargeDataset(); // Slow API call
const result = processData(data);
expect(result.length).toBeGreaterThan(0);
});
// After: <10ms
it('should process large dataset', () => {
const data = createMockDataset(1000); // Fast mock data
const result = processData(data);
expect(result.length).toBe(1000);
});1.10 Snapshot Testing
Snapshots capture the output of code and save it for comparison in future test runs. Use them sparingly for appropriate use cases.
When to Use Snapshots
✅ Good use cases:
- Testing complex object structures that rarely change
- Testing error messages and stack traces
- Testing serialized output (JSON, XML, HTML)
- Testing CLI output or formatted text
- Testing generated code or configurations
❌ Bad use cases:
- Testing dynamic data (dates, IDs, random values)
- Testing simple values (use explicit assertions instead)
- Testing implementation details
- Replacing proper assertions for laziness
Basic Snapshot Testing
✅ Correct: snapshot for complex structure
import { describe, it, expect } from 'vitest';
import { generateConfig } from './config-generator';
describe('generateConfig', () => {
it('should generate correct config structure', () => {
const config = generateConfig({
environment: 'production',
features: ['auth', 'analytics'],
});
expect(config).toMatchSnapshot();
});
});First run creates snapshot:
// __snapshots__/config-generator.test.ts.snap
exports[`generateConfig > should generate correct config structure 1`] = `
{
"analytics": {
"enabled": true,
"provider": "google-analytics",
},
"auth": {
"enabled": true,
"provider": "oauth2",
"timeout": 3600,
},
"environment": "production",
}
`;Inline Snapshots
For smaller snapshots, use inline snapshots to keep tests self-contained.
✅ Correct: inline snapshot
it('should format error message', () => {
const error = formatError({
code: 'AUTH_FAILED',
message: 'Invalid credentials',
});
expect(error).toMatchInlineSnapshot(`
{
"code": "AUTH_FAILED",
"message": "Invalid credentials",
"timestamp": Any<Date>,
}
`);
});Property Matchers
Use property matchers for dynamic values like dates and IDs.
✅ Correct: snapshot with property matchers
it('should create user with generated fields', () => {
const user = createUser({
name: 'John Doe',
email: 'john@example.com',
});
expect(user).toMatchSnapshot({
id: expect.any(String),
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
});
});Snapshot saved:
exports[`should create user with generated fields 1`] = `
{
"createdAt": Any<Date>,
"email": "john@example.com",
"id": Any<String>,
"name": "John Doe",
"updatedAt": Any<Date>,
}
`;Updating Snapshots
Update snapshots when intentional changes occur.
# Update all snapshots
vitest run -u
# Update snapshots for specific file
vitest run user-service.test.ts -u
# Interactive update mode
vitest --ui⚠️ Warning: Review changes carefully
# Before updating, review what changed
vitest run
# Check git diff to see snapshot changes
git diff __snapshots__/
# Only update if changes are intentional
vitest run -uTesting React Components
Snapshots work well for component output (but prefer visual regression tools for styling).
✅ Correct: component snapshot
import { render } from '@testing-library/react';
it('should render user profile', () => {
const { container } = render(
<UserProfile
name="John Doe"
email="john@example.com"
role="admin"
/>
);
expect(container.firstChild).toMatchSnapshot();
});✅ Better: snapshot with property testing
it('should render user profile with all fields', () => {
const { getByText, getByRole } = render(
<UserProfile
name="John Doe"
email="john@example.com"
role="admin"
/>
);
// Test specific behaviors
expect(getByText('John Doe')).toBeInTheDocument();
expect(getByText('john@example.com')).toBeInTheDocument();
expect(getByRole('img')).toHaveAttribute('alt', 'John Doe');
// Snapshot for full output
expect(container.firstChild).toMatchSnapshot();
});Testing Error Messages
Snapshots work well for error messages that include context.
✅ Correct: error snapshot
it('should throw detailed validation error', () => {
const invalidData = {
email: 'invalid-email',
age: -5,
name: '',
};
expect(() => validateUser(invalidData)).toThrowErrorMatchingSnapshot();
});Snapshot:
exports[`should throw detailed validation error 1`] = `
[ValidationError: User validation failed:
- email: Invalid email format
- age: Age must be between 0 and 150
- name: Name is required]
`;Serializers
Custom serializers normalize output for consistent snapshots.
✅ Correct: custom serializer for dates
import { expect } from 'vitest';
expect.addSnapshotSerializer({
test: (val) => val instanceof Date,
serialize: (val) => `Date<${val.toISOString()}>`,
});
it('should create order with timestamp', () => {
const order = createOrder({ items: [] });
expect(order).toMatchInlineSnapshot(`
{
"createdAt": Date<2024-01-15T10:30:00.000Z>,
"id": "order-123",
"items": [],
}
`);
});When NOT to Use Snapshots
❌ Incorrect: snapshot for simple value
it('should return user name', () => {
const name = getUserName(user);
expect(name).toMatchInlineSnapshot(`"John Doe"`);
});✅ Correct: explicit assertion
it('should return user name', () => {
const name = getUserName(user);
expect(name).toBe('John Doe');
});❌ Incorrect: snapshot with dynamic data
it('should generate report', () => {
const report = generateReport();
// Snapshot will change every run!
expect(report).toMatchSnapshot();
});✅ Correct: snapshot with matchers
it('should generate report', () => {
const report = generateReport();
expect(report).toMatchSnapshot({
generatedAt: expect.any(Date),
id: expect.any(String),
});
});Testing CLI Output
Snapshots work well for command-line output.
✅ Correct: CLI output snapshot
it('should display help text', () => {
const output = cli.getHelpText();
expect(output).toMatchInlineSnapshot(`
"Usage: myapp [options] [command]
Options:
-v, --version Output the version number
-h, --help Display help for command
Commands:
start [options] Start the application
stop Stop the application
status Show application status"
`);
});Testing JSON/API Responses
✅ Correct: API response snapshot
it('should return user API response', async () => {
const response = await api.getUser('user-123');
expect(response).toMatchSnapshot({
data: {
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
meta: {
requestId: expect.any(String),
timestamp: expect.any(Number),
},
});
});Snapshot Best Practices
1. Keep Snapshots Small
❌ Incorrect: massive snapshot
it('should render entire page', () => {
const { container } = render(<App />);
expect(container).toMatchSnapshot(); // Huge snapshot!
});✅ Correct: focused snapshot
it('should render header', () => {
const { container } = render(<Header user={mockUser} />);
expect(container).toMatchSnapshot();
});
it('should render navigation', () => {
const { container } = render(<Navigation items={mockItems} />);
expect(container).toMatchSnapshot();
});2. Name Snapshots Clearly
❌ Incorrect: generic name
it('test 1', () => {
expect(result).toMatchSnapshot();
});✅ Correct: descriptive name
it('should format currency with symbol and decimals', () => {
expect(formatCurrency(1234.56, 'USD')).toMatchInlineSnapshot(`"$1,234.56"`);
});3. Review Snapshot Changes
Always review snapshot updates in code review.
# In CI/CD, ensure snapshots match
vitest run
# Fail if snapshots need updating
# This prevents accidental updates✅ Correct: intentional update
1. Make code change
2. Run tests - see snapshot diff
3. Review diff carefully
4. Update if intentional: vitest run -u
5. Commit updated snapshots
6. Explain changes in PR description4. Combine with Explicit Assertions
✅ Correct: snapshots + assertions
it('should generate invoice', () => {
const invoice = generateInvoice({
items: [{ name: 'Widget', price: 10, quantity: 2 }],
tax: 0.08,
});
// Explicit assertions for critical values
expect(invoice.subtotal).toBe(20);
expect(invoice.tax).toBe(1.6);
expect(invoice.total).toBe(21.6);
// Snapshot for full structure
expect(invoice).toMatchSnapshot({
id: expect.any(String),
createdAt: expect.any(Date),
});
});Snapshot Anti-Patterns
❌ Incorrect: using snapshots as a crutch
it('should work', () => {
// Lazy: just snapshot everything instead of thinking about what to test
expect(doSomething()).toMatchSnapshot();
});❌ Incorrect: ignoring failing snapshots
# Don't blindly update snapshots without understanding why they changed
vitest run -u # ❌ Without reviewing changes first❌ Incorrect: snapshots for test doubles
it('should call service', () => {
const mockService = vi.fn();
callService(mockService);
// Don't snapshot mock calls!
expect(mockService.mock.calls).toMatchSnapshot();
});✅ Correct: explicit assertions for mocks
it('should call service with correct params', () => {
const mockService = vi.fn();
callService(mockService);
expect(mockService).toHaveBeenCalledWith({
id: '123',
action: 'update',
});
});Organizing Snapshots
Snapshots are stored in __snapshots__/ directories.
src/
components/
button.tsx
button.test.tsx
__snapshots__/
button.test.tsx.snap
services/
user-service.ts
user-service.test.ts
__snapshots__/
user-service.test.ts.snap✅ Correct: commit snapshots
# Always commit snapshot files
git add src/**/__snapshots__
git commit -m "Update snapshots after button styling changes"When to Prefer Explicit Assertions
Use explicit assertions when:
- Testing simple values
- Testing critical business logic
- Testing behavior, not structure
- You need clear failure messages
Use snapshots when:
- Testing complex structures
- Output format matters
- Regression testing large outputs
- Maintaining consistency across many similar tests
1.6 Test Doubles
Test doubles replace real dependencies in tests. Use them sparingly and prefer real implementations when practical.
Hierarchy of Test Doubles
Prefer in this order (best to worst):
1. Real Implementation: Use the actual code whenever possible 2. Fakes: Lightweight working implementations (e.g., in-memory database) 3. Stubs: Return pre-configured responses without behavior 4. Spies: Record calls while allowing real implementation to run 5. Mocks: Replace behavior AND verify interactions (last resort)
When to Use Test Doubles
✅ DO mock these:
- External services (APIs, databases, file systems)
- Third-party libraries you don't control
- Non-deterministic functions (Date.now(), Math.random())
- Slow operations (network calls, large file I/O)
❌ DON'T mock these:
- Pure functions (deterministic, no side effects)
- Your own application code
- Simple utilities (array helpers, formatters)
- Code you're actively testing
1. Real Implementation (Preferred)
Use real code whenever possible - it provides the most confidence.
✅ Correct: using real implementation
describe('OrderService', () => {
it('should calculate order total correctly', () => {
const priceCalculator = new PriceCalculator(); // Real implementation
const orderService = new OrderService(priceCalculator);
const order = orderService.createOrder([
{ id: 1, price: 10, quantity: 2 },
{ id: 2, price: 5, quantity: 3 },
]);
expect(order.total).toEqual(35);
});
});2. Fakes
Fakes are lightweight implementations with real behavior.
✅ Correct: in-memory fake for database
class FakeUserRepository implements UserRepository {
private users: Map<string, User> = new Map();
async save(user: User): Promise<void> {
this.users.set(user.id, user);
}
async findById(id: string): Promise<User | null> {
return this.users.get(id) || null;
}
async findAll(): Promise<User[]> {
return Array.from(this.users.values());
}
clear() {
this.users.clear();
}
}
describe('UserService', () => {
let userRepo: FakeUserRepository;
let userService: UserService;
beforeEach(() => {
userRepo = new FakeUserRepository();
userService = new UserService(userRepo);
});
it('should create and retrieve user', async () => {
const user = await userService.createUser({
name: 'John',
email: 'john@example.com',
});
const retrieved = await userService.getUser(user.id);
expect(retrieved).toEqual(user);
});
it('should list all users', async () => {
await userService.createUser({ name: 'Alice', email: 'alice@example.com' });
await userService.createUser({ name: 'Bob', email: 'bob@example.com' });
const users = await userService.getAllUsers();
expect(users).toHaveLength(2);
});
});✅ Correct: fake for external API
class FakePaymentGateway implements PaymentGateway {
private payments: Payment[] = [];
async charge(amount: number, token: string): Promise<PaymentResult> {
const payment: Payment = {
id: `pay_${Date.now()}`,
amount,
token,
status: 'succeeded',
createdAt: new Date(),
};
this.payments.push(payment);
return { success: true, paymentId: payment.id };
}
getPayments(): Payment[] {
return [...this.payments];
}
}3. Stubs
Stubs return pre-configured responses without implementing real behavior.
✅ Correct: stubbing external API call
describe('WeatherService', () => {
it('should return temperature for city', async () => {
const apiClient = {
fetch: vi.fn().mockResolvedValue({
temperature: 72,
conditions: 'sunny',
city: 'San Francisco',
}),
};
const weatherService = new WeatherService(apiClient);
const weather = await weatherService.getWeather('San Francisco');
expect(weather.temperature).toEqual(72);
expect(apiClient.fetch).toHaveBeenCalledWith('/weather?city=San+Francisco');
});
it('should handle API errors', async () => {
const apiClient = {
fetch: vi.fn().mockRejectedValue(new Error('API unavailable')),
};
const weatherService = new WeatherService(apiClient);
await expect(weatherService.getWeather('Invalid'))
.rejects.toThrow('API unavailable');
});
});✅ Correct: stubbing multiple scenarios
describe('DataService', () => {
it('should retry on failure then succeed', async () => {
const apiClient = {
fetch: vi.fn()
.mockRejectedValueOnce(new Error('Timeout'))
.mockRejectedValueOnce(new Error('Timeout'))
.mockResolvedValueOnce({ data: 'success' }),
};
const service = new DataService(apiClient);
const result = await service.fetchWithRetry('/api/data');
expect(result).toEqual({ data: 'success' });
expect(apiClient.fetch).toHaveBeenCalledTimes(3);
});
});4. Spies
Spies record function calls while preserving original behavior.
✅ Correct: spying on method calls
describe('Logger', () => {
it('should log errors to console', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const logger = new Logger();
logger.error('Something went wrong');
expect(consoleSpy).toHaveBeenCalledWith('[ERROR]', 'Something went wrong');
consoleSpy.mockRestore();
});
});✅ Correct: spying to verify side effects
describe('Analytics', () => {
it('should track page views', () => {
const trackingSpy = vi.fn();
const analytics = new Analytics({ track: trackingSpy });
analytics.pageView('/home', { userId: '123' });
expect(trackingSpy).toHaveBeenCalledWith('pageview', {
path: '/home',
userId: '123',
});
});
});5. Mocks (Use Sparingly)
Mocks replace behavior AND verify interactions. Use only when necessary.
✅ Correct: mocking external service
describe('EmailService', () => {
it('should send welcome email to new users', async () => {
const emailProvider = {
send: vi.fn().mockResolvedValue({ messageId: 'msg-123' }),
};
const emailService = new EmailService(emailProvider);
await emailService.sendWelcomeEmail('user@example.com');
expect(emailProvider.send).toHaveBeenCalledWith({
to: 'user@example.com',
subject: 'Welcome!',
body: expect.stringContaining('Welcome'),
});
});
});❌ Incorrect: over-mocking internal code
describe('OrderProcessor', () => {
it('should process order', () => {
const calculateTaxMock = vi.fn().mockReturnValue(5);
const calculateShippingMock = vi.fn().mockReturnValue(10);
const formatPriceMock = vi.fn().mockReturnValue('$50.00');
// Too many mocks! Just use real implementations
const processor = new OrderProcessor({
calculateTax: calculateTaxMock,
calculateShipping: calculateShippingMock,
formatPrice: formatPriceMock,
});
// ...test logic
});
});Why? Mocking your own simple functions makes tests brittle and less valuable. Use real implementations.
vi.fn() - Manual Mocks
Create mock functions when you need fine control.
✅ Correct: creating mock with specific behavior
describe('DataFetcher', () => {
it('should handle pagination', async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce({ items: [1, 2, 3], hasMore: true })
.mockResolvedValueOnce({ items: [4, 5, 6], hasMore: true })
.mockResolvedValueOnce({ items: [7, 8], hasMore: false });
const fetcher = new DataFetcher(fetchFn);
const allItems = await fetcher.fetchAll();
expect(allItems).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
expect(fetchFn).toHaveBeenCalledTimes(3);
});
});vi.mock() - Module Mocking
Mock entire modules when you need to replace external dependencies.
✅ Correct: mocking external module
import { v4 as uuidv4 } from 'uuid';
vi.mock('uuid', () => ({
v4: vi.fn(),
}));
describe('UserService', () => {
it('should generate unique user IDs', () => {
vi.mocked(uuidv4)
.mockReturnValueOnce('id-1')
.mockReturnValueOnce('id-2');
const service = new UserService();
const user1 = service.createUser({ name: 'Alice' });
const user2 = service.createUser({ name: 'Bob' });
expect(user1.id).toBe('id-1');
expect(user2.id).toBe('id-2');
});
});✅ Correct: partial module mock
vi.mock('../utils', async () => {
const actual = await vi.importActual('../utils');
return {
...actual,
fetchData: vi.fn(), // Only mock fetchData, keep other utils real
};
});Mocking Timers
Use fake timers for testing time-dependent code.
✅ Correct: testing debounce with fake timers
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should delay function execution', () => {
const callback = vi.fn();
const debounced = debounce(callback, 1000);
debounced();
debounced();
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
});
});✅ Correct: testing intervals
it('should poll every 5 seconds', () => {
vi.useFakeTimers();
const pollFn = vi.fn();
const poller = new Poller(pollFn, 5000);
poller.start();
expect(pollFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(5000);
expect(pollFn).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(5000);
expect(pollFn).toHaveBeenCalledTimes(3);
poller.stop();
vi.useRealTimers();
});Testing with Dates
Mock dates for consistent test results.
✅ Correct: mocking current date
describe('isExpired', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01'));
});
afterEach(() => {
vi.useRealTimers();
});
it('should return true for expired items', () => {
const expiredItem = { expiryDate: new Date('2023-12-31') };
expect(isExpired(expiredItem)).toBe(true);
});
it('should return false for valid items', () => {
const validItem = { expiryDate: new Date('2024-12-31') };
expect(isExpired(validItem)).toBe(false);
});
});Clearing and Restoring Mocks
Always clean up mocks between tests.
✅ Correct: clearing mocks
describe('UserService', () => {
const mockApi = {
fetchUser: vi.fn(),
};
beforeEach(() => {
mockApi.fetchUser.mockClear(); // Clear call history
});
it('test 1', async () => {
mockApi.fetchUser.mockResolvedValue({ id: '1', name: 'Alice' });
// ... test logic
});
it('test 2', async () => {
mockApi.fetchUser.mockResolvedValue({ id: '2', name: 'Bob' });
// ... test logic
});
});✅ Correct: restoring spies
describe('Logger', () => {
afterEach(() => {
vi.restoreAllMocks(); // Restore all spies
});
it('should log to console', () => {
const spy = vi.spyOn(console, 'log');
// ... test logic
});
});Anti-Patterns
❌ Incorrect: mocking everything
it('should create user', () => {
const mockValidate = vi.fn().mockReturnValue(true);
const mockHash = vi.fn().mockReturnValue('hashed');
const mockGenId = vi.fn().mockReturnValue('id-123');
const mockSave = vi.fn();
// Way too many mocks - just use real code!
const service = new UserService({
validate: mockValidate,
hash: mockHash,
generateId: mockGenId,
save: mockSave,
});
});❌ Incorrect: testing mocks instead of behavior
it('should call formatUser', () => {
const mockFormat = vi.fn();
service.formatUser = mockFormat;
service.getUser('123');
expect(mockFormat).toHaveBeenCalled(); // Who cares if it was called?
});Why? Test the actual behavior (what the user gets), not internal implementation (which functions were called).
❌ Incorrect: brittle interaction testing
it('should process user', () => {
service.processUser(user);
expect(logger.log).toHaveBeenCalledTimes(3); // Fragile!
expect(logger.log).toHaveBeenNthCalledWith(1, 'start');
expect(logger.log).toHaveBeenNthCalledWith(2, 'processing');
expect(logger.log).toHaveBeenNthCalledWith(3, 'done');
});Why? This test breaks if you change logging details, even though the actual functionality works fine.
1.9 Vitest-Specific Features
Vitest provides powerful features beyond basic testing. Use them to write better, more maintainable tests.
Test Filtering
Use .only, .skip, and .todo to control test execution during development.
✅ Correct: focusing on specific tests
describe('UserService', () => {
it.only('should create user', () => {
// Only this test runs
const user = createUser({ name: 'John' });
expect(user.name).toBe('John');
});
it('should update user', () => {
// Skipped while .only is active
});
it.skip('should delete user', () => {
// Temporarily disabled
});
it.todo('should restore deleted user');
// Placeholder for future test
});⚠️ Warning: Remove `.only` before committing
// DON'T commit this!
it.only('should work', () => {
// Other tests won't run in CI
});Conditional Tests
Run tests conditionally based on environment.
✅ Correct: platform-specific tests
describe('FileSystem', () => {
it.runIf(process.platform === 'win32')('should handle Windows paths', () => {
expect(normalizePath('C:\\Users\\test')).toBe('C:/Users/test');
});
it.skipIf(process.platform === 'win32')('should handle Unix paths', () => {
expect(normalizePath('/home/test')).toBe('/home/test');
});
});Concurrent Tests
Run independent tests in parallel for faster execution.
✅ Correct: concurrent test execution
describe.concurrent('API endpoints', () => {
it('should fetch users', async () => {
const users = await api.getUsers();
expect(users).toHaveLength(10);
});
it('should fetch products', async () => {
const products = await api.getProducts();
expect(products).toHaveLength(20);
});
it('should fetch orders', async () => {
const orders = await api.getOrders();
expect(orders).toHaveLength(5);
});
});
// All three tests run in parallel⚠️ Caution: Only use for isolated tests
describe.concurrent('Database tests', () => {
// ❌ BAD: These tests share state and will interfere!
it('should create user', async () => {
await db.insert({ id: 1, name: 'Alice' });
});
it('should count users', async () => {
const count = await db.count();
expect(count).toBe(1); // Flaky! Depends on other test
});
});Test Context
Use test.extend to create custom test fixtures.
✅ Correct: reusable test fixtures
import { test as base, expect } from 'vitest';
interface TestContext {
userService: UserService;
db: Database;
}
const test = base.extend<TestContext>({
userService: async ({}, use) => {
const service = new UserService();
await use(service);
await service.cleanup();
},
db: async ({}, use) => {
const db = await createTestDatabase();
await use(db);
await db.close();
},
});
test('should create user', async ({ userService, db }) => {
const user = await userService.create({ name: 'John' });
const saved = await db.findById(user.id);
expect(saved).toEqual(user);
});Mocking Modules
Use vi.mock() to mock entire modules.
✅ Correct: mocking external dependency
import { sendEmail } from './email-service';
vi.mock('./email-service', () => ({
sendEmail: vi.fn().mockResolvedValue({ success: true }),
}));
it('should send welcome email', async () => {
await onUserSignup({ email: 'user@example.com' });
expect(sendEmail).toHaveBeenCalledWith({
to: 'user@example.com',
template: 'welcome',
});
});✅ Correct: partial module mock
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
fetchData: vi.fn(), // Mock only fetchData
};
});Spy On Methods
Use vi.spyOn() to spy on object methods.
✅ Correct: spying on console methods
it('should log error message', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation();
logger.error('Something went wrong');
expect(consoleSpy).toHaveBeenCalledWith('[ERROR]', 'Something went wrong');
consoleSpy.mockRestore();
});Fake Timers
Control time in tests using fake timers.
✅ Correct: testing debounce
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should delay function execution', () => {
const callback = vi.fn();
const debounced = debounce(callback, 1000);
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
});
it('should reset timer on subsequent calls', () => {
const callback = vi.fn();
const debounced = debounce(callback, 1000);
debounced();
vi.advanceTimersByTime(500);
debounced(); // Resets timer
vi.advanceTimersByTime(500);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(1);
});
});✅ Correct: testing intervals
it('should poll every second', () => {
vi.useFakeTimers();
const callback = vi.fn();
const poller = new Poller(callback, 1000);
poller.start();
expect(callback).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(3000);
expect(callback).toHaveBeenCalledTimes(4); // Initial + 3 intervals
poller.stop();
vi.useRealTimers();
});Test Each
Use test.each() for parameterized tests.
✅ Correct: testing multiple inputs
test.each([
{ input: 2, expected: 4 },
{ input: 3, expected: 9 },
{ input: 4, expected: 16 },
])('square($input) should equal $expected', ({ input, expected }) => {
expect(square(input)).toBe(expected);
});✅ Correct: array syntax
test.each([
[1, 1],
[2, 4],
[3, 9],
])('square(%i) = %i', (input, expected) => {
expect(square(input)).toBe(expected);
});Coverage
Generate code coverage reports to identify untested code.
vitest.config.ts:
export default {
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: [
'node_modules/',
'test/',
'**/*.test.ts',
'**/*.config.ts',
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
};Run coverage:
vitest --coverage✅ Correct: focus on meaningful coverage
// Don't chase 100% coverage blindly
// Focus on testing critical business logic
describe('PaymentProcessor', () => {
it('should process valid payment', () => {
// Critical path - must be tested
});
it('should reject invalid payment', () => {
// Error path - must be tested
});
it('should handle network timeout', () => {
// Edge case - important to test
});
});Watch Mode
Use watch mode for rapid test-driven development.
# Watch mode with UI
vitest --ui
# Watch mode in terminal
vitest --watch
# Watch specific files
vitest watch src/servicesBenchmarking
Use bench() to measure performance.
✅ Correct: comparing implementations
import { bench, describe } from 'vitest';
describe('Array operations', () => {
bench('for loop', () => {
const arr = Array.from({ length: 1000 }, (_, i) => i);
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i];
}
return sum;
});
bench('forEach', () => {
const arr = Array.from({ length: 1000 }, (_, i) => i);
let sum = 0;
arr.forEach(n => sum += n);
return sum;
});
bench('reduce', () => {
const arr = Array.from({ length: 1000 }, (_, i) => i);
return arr.reduce((sum, n) => sum + n, 0);
});
});In-Source Testing
Define tests alongside implementation code.
example.ts:
export function add(a: number, b: number): number {
return a + b;
}
if (import.meta.vitest) {
const { it, expect } = import.meta.vitest;
it('should add two numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('should handle negative numbers', () => {
expect(add(-2, 3)).toBe(1);
});
}vitest.config.ts:
export default {
test: {
includeSource: ['src/**/*.ts'],
},
define: {
'import.meta.vitest': 'undefined',
},
};Type Testing
Test TypeScript types using expectTypeOf.
✅ Correct: type assertions
import { expectTypeOf } from 'vitest';
it('should have correct return type', () => {
const result = fetchUser('123');
expectTypeOf(result).toEqualTypeOf<Promise<User>>();
});
it('should accept correct parameter types', () => {
expectTypeOf(createUser).parameter(0).toMatchTypeOf<UserInput>();
});
it('should infer correct generic type', () => {
const users = [{ id: 1, name: 'Alice' }];
expectTypeOf(users).toEqualTypeOf<Array<{ id: number; name: string }>>();
});Setup Files
Configure global test setup and teardown.
vitest.config.ts:
export default {
test: {
setupFiles: ['./test/setup.ts'],
globalSetup: ['./test/global-setup.ts'],
},
};test/setup.ts (runs before each test file):
import { beforeEach, afterEach } from 'vitest';
beforeEach(() => {
// Runs before each test
vi.clearAllMocks();
});
afterEach(() => {
// Runs after each test
vi.restoreAllMocks();
});test/global-setup.ts (runs once before all tests):
export async function setup() {
// Start test database, etc.
console.log('Starting test environment...');
}
export async function teardown() {
// Clean up global resources
console.log('Cleaning up test environment...');
}Environment
Specify different test environments for different tests.
✅ Correct: browser environment for DOM tests
/**
* @vitest-environment jsdom
*/
import { render } from '@testing-library/react';
it('should render component', () => {
const { getByText } = render(<Button>Click me</Button>);
expect(getByText('Click me')).toBeInTheDocument();
});✅ Correct: node environment for API tests
/**
* @vitest-environment node
*/
it('should read file', async () => {
const content = await fs.readFile('./test.txt', 'utf-8');
expect(content).toContain('test data');
});Retry Flaky Tests
Retry flaky tests automatically.
✅ Correct: retry configuration
// vitest.config.ts
export default {
test: {
retry: 2, // Retry failed tests up to 2 times
},
};✅ Correct: per-test retry
it('flaky network test', { retry: 3 }, async () => {
const data = await fetchFromUnreliableAPI();
expect(data).toBeDefined();
});⚠️ Better: Fix the flakiness instead of retrying
// Instead of retrying, mock the unreliable dependency
it('network test', async () => {
const mockFetch = vi.fn().mockResolvedValue({ data: 'test' });
const data = await fetchData(mockFetch);
expect(data).toBeDefined();
});