Most test suites I've inherited test the wrong things. They have 90% coverage and still miss every real bug. Here's what I've learned about writing tests that matter.
Test Behavior, Not Implementation
// Bad: tests implementation details
test('should call database.save', () => {
const spy = jest.spyOn(database, 'save');
createUser({ name: 'Alice' });
expect(spy).toHaveBeenCalledWith({ name: 'Alice', role: 'user' });
});
// Good: tests behavior
test('new users get the default role', () => {
const user = createUser({ name: 'Alice' });
expect(user.role).toBe('user');
});
The bad test breaks if you rename the method, change the ORM, or refactor the internals — even if the behavior is correct. The good test only breaks if the actual behavior changes.
Test Boundaries, Not Everything
┌─────────────────────┐
│ API Endpoint │ ← Integration test
├─────────────────────┤
│ Business Logic │ ← Unit test (pure functions)
├─────────────────────┤
│ Database Layer │ ← Integration test
└─────────────────────┘
- Unit test pure functions: validators, calculators, formatters, parsers
- Integration test boundaries: API endpoints, database queries
-
Don't test glue code:
getUserthat just callsdb.users.findById(id)
The 3 Tests That Catch Real Bugs
1. Edge Cases
test('handles empty cart', () => {
expect(calculateTotal([])).toBe(0);
});
test('handles negative quantities', () => {
expect(() => addToCart(item, -1)).toThrow('Quantity must be positive');
});
test('handles concurrent updates', async () => {
const [result1, result2] = await Promise.all([
updateBalance(userId, +100),
updateBalance(userId, -50),
]);
const balance = await getBalance(userId);
expect(balance).toBe(initialBalance + 50);
});
2. Error Paths
test('returns 404 when user not found', async () => {
const res = await request(app).get('/users/nonexistent');
expect(res.status).toBe(404);
expect(res.body.error.code).toBe('USER_NOT_FOUND');
});
test('retries on transient database error', async () => {
database.query
.mockRejectedValueOnce(new Error('connection reset'))
.mockResolvedValueOnce({ rows: [user] });
const result = await getUser(userId);
expect(result).toEqual(user);
expect(database.query).toHaveBeenCalledTimes(2);
});
3. State Transitions
test('order lifecycle', async () => {
const order = await createOrder(items);
expect(order.status).toBe('pending');
await payOrder(order.id, paymentInfo);
expect((await getOrder(order.id)).status).toBe('paid');
await shipOrder(order.id, trackingNumber);
expect((await getOrder(order.id)).status).toBe('shipped');
// Can't pay a shipped order
await expect(payOrder(order.id, paymentInfo))
.rejects.toThrow('Cannot pay a shipped order');
});
What NOT to Test
- Trivial code: Getters, setters, constructors with no logic
- Framework code: Does Express routing work? Yes. Don't test it.
- External services in unit tests: Mock them, or use integration tests
- CSS/styling: Unless it's critical business logic (pricing display)
Test Naming
// Bad
test('test1', ...);
test('createUser', ...);
// Good: describes the scenario and expected outcome
test('createUser with duplicate email returns conflict error', ...);
test('expired tokens are rejected with 401', ...);
When a test fails, the name should tell you what broke without reading the code.
What's your testing philosophy? I'm always refining my approach.
Top comments (0)