Testing Best Practices
A team I worked with had 2,000 unit tests. They took 45 seconds to run. Nobody ran them before committing. Tests failed in CI. Developers ignored the failures. The test suite had become a liability.
Tests are code. They need the same care: fast, maintainable, and valuable.
The Testing Pyramid
Unit tests — Many. Fast. Isolated. Test one function or class. Mock external dependencies. Run in milliseconds.
Integration tests — Fewer. Slower. Test how components work together. Real database, real API, or in-memory substitutes. Run in seconds.
E2E tests — Few. Slowest. Test the full system through the UI or API. Run in minutes.
/\
/ \ E2E (few, slow, high confidence)
/----\
/ \ Integration (some, medium speed)
/--------\
/ \ Unit (many, fast, narrow scope)
/------------\
The pyramid is a ratio, not a rule. A CRUD API might need more integration tests. A pure logic library might be mostly unit tests. The principle: prefer fast, focused tests. Add slower, broader tests where they catch bugs unit tests miss.
What to Mock, What Not to Mock
Mock I/O and External Services
- Network calls — HTTP, APIs, third-party SDKs
- Database — Unless you're writing an integration test
- File system — Reading/writing files
- Time —
Date.now(),setTimeout(use fake timers) - Random —
Math.random()(seed or inject)
These are slow, flaky, or non-deterministic. Mock them to keep tests fast and reliable.
// Unit test: mock the payment gateway
it('charges customer and records transaction', async () => {
const mockGateway = {
charge: jest.fn().mockResolvedValue({ id: 'tx_123', status: 'succeeded' }),
};
const service = new PaymentService(mockGateway);
await service.processPayment({ amount: 100, customerId: 'c_1' });
expect(mockGateway.charge).toHaveBeenCalledWith(100, 'c_1');
});
Don't Mock the Thing You're Testing
If you're testing OrderCalculator, don't mock OrderCalculator. You're testing real behavior.
Don't Mock Everything
Over-mocking leads to tests that verify your mocks were called, not that the system works.
// BAD: mocking the collaborator we could test
const mockDiscountCalculator = {
calculate: jest.fn().returnValue(10),
};
const orderService = new OrderService(mockDiscountCalculator);
// We're testing that we call the mock. We're not testing calculation logic.
// GOOD: use real DiscountCalculator, mock only the DB
const discountCalculator = new DiscountCalculator(); // Real
const mockOrderRepo = { save: jest.fn() }; // Mock
const orderService = new OrderService(discountCalculator, mockOrderRepo);
// We test real discount logic. We mock only persistence.
Rule of thumb: Mock at the boundaries. Database, network, file system. Use real objects for in-process logic.
Testing Edge Cases
Don't just test the happy path. Think about:
Boundaries — Zero, empty, one, max. What happens at the edges?
it('returns 0 for empty cart', () => {});
it('handles single item', () => {});
it('rejects negative quantity', () => {});
Invalid input — Null, undefined, wrong type, malformed data.
it('throws when email is null', () => {});
it('throws when amount is negative', () => {});
Error paths — What happens when the database fails? When the API returns 500?
it('retries twice then throws when payment gateway fails', async () => {});
Concurrency and ordering — If order matters, test it. If race conditions are possible, test them.
You don't need to test every edge case. Prioritize: what would cause production bugs? Test that.
Avoiding Brittle Tests
A brittle test breaks when the implementation changes, even when behavior is correct.
Test Behavior, Not Implementation
// Brittle: tied to implementation
it('calls repository.save with user object', () => {
const mockRepo = { save: jest.fn() };
createUser({ email: 'a@b.com' }, mockRepo);
expect(mockRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ email: 'a@b.com' })
);
});
// Resilient: test the outcome
it('persists user and returns id', async () => {
const repo = new InMemoryUserRepository();
const user = await createUser({ email: 'a@b.com' }, repo);
const persisted = await repo.findById(user.id);
expect(persisted.email).toBe('a@b.com');
});
If you refactor from save to insert, the first test breaks. The second still passes.
Avoid Testing Private Methods
Private methods are implementation details. Test through the public API. If a private method is hard to test through the public API, it might be a sign to extract it.
Avoid Overspecified Assertions
// Brittle: exact structure
expect(result).toEqual({
id: '123',
name: 'Order',
items: [{ sku: 'A', price: 10, quantity: 2 }],
total: 20,
createdAt: '2024-01-15T10:00:00Z', // Exact timestamp?
});
// Better: assert what matters
expect(result.id).toBeDefined();
expect(result.total).toBe(20);
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({ sku: 'A', quantity: 2 });
Avoid Testing Through the UI When Unit Tests Suffice
E2E tests that assert "button click triggers API call" are brittle. The API call is what matters. Test it directly.
Test Maintenance Strategies
Keep Tests Close to Code
Co-locate or use the *.test.ts convention. When you change code, you see the tests.
Delete Obsolete Tests
When you remove a feature, remove its tests. Dead tests add noise.
Refactor Tests When Refactoring Code
Tests are part of the codebase. If you extract a function, update the tests. Don't let them drift.
Use Builders and Factories
function createOrder(overrides = {}) {
return {
id: 'ord_1',
total: 100,
status: 'pending',
...overrides,
};
}
it('applies discount', () => {
const order = createOrder({ total: 150 });
// ...
});
When the default shape changes, you update one place.
Run Fast Tests on Save
Configure your editor or watcher to run unit tests on file save. If they take 45 seconds, nobody will run them. Keep the fast suite under 10 seconds.
Tag Slow Tests
describe.skip('full checkout flow', () => {
// Or use a tag: describe('@slow', () => { ... });
});
Run slow tests in CI, not on every save.
Key insight: Follow the testing pyramid—many fast unit tests, fewer integration tests, few e2e tests. Mock at boundaries (I/O, external services); use real logic when you can. Test edge cases that would cause production bugs. Avoid brittle tests by testing behavior, not implementation. Maintain tests like production code—keep them fast, delete dead ones, refactor when the code changes.