DEV Community

EME GUG
EME GUG

Posted on

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');
});
Enter fullscreen mode Exit fullscreen mode

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
└─────────────────────┘
Enter fullscreen mode Exit fullscreen mode
  • 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

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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', ...);
Enter fullscreen mode Exit fullscreen mode

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)