| name | testing-best-practices |
| description | Comprehensive guide to writing effective unit, integration, and end-to-end tests with modern testing frameworks |
| keywords | testing, unit tests, integration tests, e2e, test-driven development, TDD, mocking, assertions |
This skill provides guidance on writing high-quality, maintainable tests across all levels of the testing pyramid.
Testing Pyramid
/\
/E2E\ (Few)
/------\
/ Inte- \
/ gration \ (Some)
/------------\
/ Unit Tests \ (Many)
/----------------\
Unit Tests: Test individual functions/classes in isolation Integration Tests: Test how components work together E2E Tests: Test complete user workflows through the UI/API
Unit Testing Best Practices
The AAA Pattern
Structure tests with Arrange-Act-Assert:
it('should calculate total price with tax', () => {
// Arrange: Set up test data
const cart = new ShoppingCart();
cart.addItem({ price: 100, quantity: 2 });
// Act: Execute the behavior
const total = cart.calculateTotal(0.08); // 8% tax
// Assert: Verify the outcome
expect(total).toBe(216); // 200 + 16 tax
});
Test Naming
Use descriptive names that explain:
- What is being tested
- Under what conditions
- What the expected outcome is
Good:
it('should throw ValidationError when email format is invalid', () => {})
it('should return empty array when no items match filter', () => {})
it('should retry failed requests up to 3 times', () => {})
Bad:
it('works', () => {})
it('test email', () => {})
it('should return correct value', () => {})
One Assertion Per Test (Generally)
Focus each test on a single behavior:
Good:
it('should add item to cart', () => {
cart.addItem(item);
expect(cart.items).toContain(item);
});
it('should increment total count when adding item', () => {
cart.addItem(item);
expect(cart.itemCount).toBe(1);
});
Acceptable (related assertions):
it('should create valid user object', () => {
const user = createUser('John', 'john@example.com');
expect(user.name).toBe('John');
expect(user.email).toBe('john@example.com');
expect(user.id).toBeDefined();
});
Test Independence
Each test should be independent and isolated:
describe('UserService', () => {
let userService: UserService;
beforeEach(() => {
// Fresh instance for each test
userService = new UserService();
});
it('should create user', () => {
const user = userService.create('John');
expect(user.name).toBe('John');
});
it('should delete user', () => {
const user = userService.create('John');
userService.delete(user.id);
expect(userService.find(user.id)).toBeUndefined();
});
});
Mocking Best Practices
Mock external dependencies, not the code under test:
// Good: Mock external API
it('should fetch user data from API', async () => {
const mockFetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ id: 1, name: 'John' })
});
global.fetch = mockFetch;
const user = await userService.getUser(1);
expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
expect(user.name).toBe('John');
});
// Bad: Over-mocking makes test meaningless
it('should calculate total', () => {
const mockCalculate = vi.fn().mockReturnValue(100);
calculator.calculate = mockCalculate;
expect(calculator.calculate()).toBe(100); // Testing the mock, not the code
});
Test Edge Cases
Cover boundary conditions and error scenarios:
describe('divide', () => {
it('should divide positive numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('should handle division by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
it('should handle negative numbers', () => {
expect(divide(-10, 2)).toBe(-5);
});
it('should handle decimal results', () => {
expect(divide(10, 3)).toBeCloseTo(3.333, 2);
});
});
Integration Testing
Test how components work together:
describe('Order Processing Integration', () => {
let orderService: OrderService;
let paymentService: PaymentService;
let inventoryService: InventoryService;
beforeEach(() => {
// Use real instances, not mocks
orderService = new OrderService(paymentService, inventoryService);
});
it('should complete order when payment succeeds and inventory available', async () => {
const order = await orderService.createOrder({
items: [{ productId: 1, quantity: 2 }],
paymentMethod: 'credit_card'
});
expect(order.status).toBe('completed');
expect(order.payment.status).toBe('paid');
expect(inventoryService.getStock(1)).toBe(8); // Was 10, reduced by 2
});
});
Async Testing
Handle promises and async code properly:
// Using async/await (preferred)
it('should fetch data asynchronously', async () => {
const data = await fetchData();
expect(data).toBeDefined();
});
// Using done callback (when needed)
it('should handle callback', (done) => {
fetchDataWithCallback((data) => {
expect(data).toBeDefined();
done();
});
});
// Testing errors
it('should reject with error on failure', async () => {
await expect(fetchData()).rejects.toThrow('Network error');
});
Test Coverage Goals
Not all code needs 100% coverage. Prioritize:
- Critical business logic: 90-100%
- Public APIs: 80-90%
- Error handling: 80-90%
- Complex algorithms: 90-100%
- Simple getters/setters: Lower priority
- UI rendering: E2E tests often better
Common Pitfalls to Avoid
1. Testing Implementation Details
Bad: Testing private methods or internal state Good: Testing public behavior and observable outcomes
2. Flaky Tests
Avoid:
- Timing dependencies (
setTimeoutin tests) - Random data (use fixed seeds)
- Shared mutable state between tests
- External service dependencies without mocks
3. Slow Tests
Speed up by:
- Using in-memory databases for integration tests
- Mocking expensive operations
- Running tests in parallel
- Avoiding unnecessary setup
4. Brittle Tests
Make tests resilient by:
- Testing behavior, not implementation
- Using flexible matchers (
toContainvs exact equality) - Avoiding hard-coded values when possible
- Not coupling to internal structure
Test Organization
src/
services/
userService.ts
userService.spec.ts ← Co-located with source
tests/
integration/ ← Integration tests separate
userFlow.spec.ts
e2e/
checkout.e2e.ts
Writing Testable Code
Dependency Injection
// Testable: Dependencies injected
class OrderService {
constructor(
private paymentService: PaymentService,
private emailService: EmailService
) {}
}
// Not testable: Hard-coded dependencies
class OrderService {
private paymentService = new PaymentService();
private emailService = new EmailService();
}
Pure Functions
// Testable: Pure function
function calculateDiscount(price: number, discountPercent: number): number {
return price * (1 - discountPercent / 100);
}
// Harder to test: Side effects
function applyDiscount(order: Order) {
order.total = order.total * 0.9;
saveToDatabase(order);
sendEmail(order.customerEmail);
}
Quick Reference
Vitest/Jest Matchers:
expect(value).toBe(expected)- Strict equalityexpect(value).toEqual(expected)- Deep equalityexpect(value).toBeTruthy()/.toBeFalsy()expect(array).toContain(item)expect(fn).toThrow(error)expect(value).toBeCloseTo(number, decimals)
Mocking:
vi.fn()- Create mock functionvi.spyOn(object, 'method')- Spy on existing methodmockFn.mockReturnValue(value)- Set return valuemockFn.mockResolvedValue(value)- Set async returnexpect(mockFn).toHaveBeenCalledWith(args)- Verify calls
Remember: Good tests are fast, isolated, deterministic, and focus on behavior over implementation.