How to write tests that actually catch bugs
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: `getUser` that just calls `db.users.findById(id)`
## The 3 Tests That Catch Real Bugs
### 1. Edge Cases
```javascript
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
```javascript
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
```javascript
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
```javascript
// 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.
All Rights Reserved