Back to Blog
Testing

Testing Strategies: Unit, Integration, and Beyond

I've shipped code that broke production at 3 AM. I've also worked on codebases with 95% test coverage that still had bugs. Testing isn't about achieving a magic number—it's about building confidence that your code works. Let me share what actually matters.

The Testing Pyramid: A Framework, Not a Religion

You've probably seen the testing pyramid: lots of unit tests at the bottom, fewer integration tests in the middle, and even fewer end-to-end tests at the top. It's a useful mental model, but don't follow it blindly.

The real goal is fast feedback with high confidence. Sometimes that means more integration tests and fewer unit tests. It depends on your system.

Unit Tests: Test Behavior, Not Implementation

Unit tests should verify that your code does what it's supposed to do, not how it does it. Tests that break every time you refactor are worse than no tests at all.

// Bad - testing implementation details
it('should call validateEmail and then hashPassword', () => {
    const validateSpy = jest.spyOn(utils, 'validateEmail');
    const hashSpy = jest.spyOn(bcrypt, 'hash');
    
    userService.createUser({ email: '[email protected]', password: 'pass' });
    
    expect(validateSpy).toHaveBeenCalledBefore(hashSpy);
});

// Good - testing behavior
it('should create a user with a hashed password', async () => {
    const user = await userService.createUser({
        email: '[email protected]',
        password: 'plaintext'
    });
    
    expect(user.email).toBe('[email protected]');
    expect(user.password).not.toBe('plaintext');
    expect(await bcrypt.compare('plaintext', user.password)).toBe(true);
});

What Makes a Good Unit Test?

// Test names should be sentences
describe('ShoppingCart', () => {
    describe('addItem', () => {
        it('should increase total price when adding an item', () => { /* ... */ });
        it('should increase quantity if item already exists', () => { /* ... */ });
        it('should throw error if item is out of stock', () => { /* ... */ });
    });
    
    describe('removeItem', () => {
        it('should decrease total price when removing an item', () => { /* ... */ });
        it('should remove item completely if quantity becomes zero', () => { /* ... */ });
    });
});

Integration Tests: Test the Seams

Integration tests verify that different parts of your system work together. This is where you catch the bugs that unit tests miss—like database queries that work locally but fail with real data.

describe('UserRepository Integration', () => {
    let db;
    
    beforeAll(async () => {
        db = await createTestDatabase();
    });
    
    afterAll(async () => {
        await db.close();
    });
    
    beforeEach(async () => {
        await db.clear(); // Clean state for each test
    });
    
    it('should persist user to database', async () => {
        const repo = new UserRepository(db);
        
        const user = await repo.create({
            email: '[email protected]',
            name: 'Test User'
        });
        
        const found = await repo.findById(user.id);
        
        expect(found.email).toBe('[email protected]');
        expect(found.name).toBe('Test User');
    });
    
    it('should enforce unique email constraint', async () => {
        const repo = new UserRepository(db);
        
        await repo.create({ email: '[email protected]', name: 'First' });
        
        await expect(
            repo.create({ email: '[email protected]', name: 'Second' })
        ).rejects.toThrow('Email already exists');
    });
});
💡 Use Real Dependencies When Practical

For integration tests, use real databases (Docker containers are great for this). In-memory substitutes often behave differently than the real thing—SQLite doesn't enforce the same constraints as PostgreSQL.

End-to-End Tests: The Safety Net

E2E tests simulate real user behavior. They're slow and flaky, but they catch bugs that nothing else will—like JavaScript errors that only occur in production builds.

// Using Playwright for E2E testing
describe('User Registration Flow', () => {
    it('should allow new users to sign up', async ({ page }) => {
        await page.goto('/signup');
        
        await page.fill('[name="email"]', '[email protected]');
        await page.fill('[name="password"]', 'SecurePass123!');
        await page.fill('[name="confirmPassword"]', 'SecurePass123!');
        
        await page.click('button[type="submit"]');
        
        // Should redirect to dashboard after successful signup
        await expect(page).toHaveURL('/dashboard');
        await expect(page.locator('h1')).toContainText('Welcome');
    });
    
    it('should show error for duplicate email', async ({ page }) => {
        // Assume user already exists
        await page.goto('/signup');
        
        await page.fill('[name="email"]', '[email protected]');
        await page.fill('[name="password"]', 'SecurePass123!');
        await page.fill('[name="confirmPassword"]', 'SecurePass123!');
        
        await page.click('button[type="submit"]');
        
        await expect(page.locator('.error-message'))
            .toContainText('Email already registered');
    });
});

E2E Testing Best Practices

Testing Edge Cases: Where Bugs Hide

Most bugs don't happen with normal input. They happen at the edges:

describe('calculateDiscount', () => {
    // Happy path
    it('should apply 10% discount for orders over $100', () => {
        expect(calculateDiscount(150)).toBe(15);
    });
    
    // Edge cases - this is where bugs live
    it('should handle zero amount', () => {
        expect(calculateDiscount(0)).toBe(0);
    });
    
    it('should handle negative amounts', () => {
        expect(() => calculateDiscount(-50)).toThrow('Amount cannot be negative');
    });
    
    it('should handle exactly $100', () => {
        expect(calculateDiscount(100)).toBe(0); // Discount is for OVER $100
    });
    
    it('should handle floating point precision', () => {
        expect(calculateDiscount(100.01)).toBeCloseTo(10.001, 2);
    });
    
    it('should handle extremely large amounts', () => {
        expect(calculateDiscount(Number.MAX_SAFE_INTEGER)).toBeDefined();
    });
});

Test-Driven Development: A Tool, Not a Dogma

TDD works great for:

TDD works poorly for:

⚠️ Don't Test for Test Coverage

100% code coverage with bad tests is worse than 70% coverage with good tests. Coverage tells you what code ran, not whether your tests actually verify anything useful.

Mocking: Use Sparingly

Mocks are necessary for unit testing, but too many mocks mean you're testing your mocks, not your code.

// Over-mocked - what are we even testing?
it('should process payment', async () => {
    const mockUser = { id: 1, balance: 100 };
    const mockPayment = { amount: 50 };
    const mockResult = { success: true };
    
    jest.spyOn(userService, 'findById').mockResolvedValue(mockUser);
    jest.spyOn(paymentGateway, 'charge').mockResolvedValue(mockResult);
    jest.spyOn(userService, 'updateBalance').mockResolvedValue(mockUser);
    jest.spyOn(emailService, 'sendReceipt').mockResolvedValue(undefined);
    
    const result = await paymentService.process(1, 50);
    
    expect(result.success).toBe(true);
});

// Better - test real behavior, mock only external services
it('should deduct balance after successful payment', async () => {
    const user = await createTestUser({ balance: 100 });
    
    // Only mock the external payment gateway
    jest.spyOn(paymentGateway, 'charge').mockResolvedValue({ success: true });
    
    await paymentService.process(user.id, 50);
    
    const updatedUser = await userService.findById(user.id);
    expect(updatedUser.balance).toBe(50);
});

My Testing Strategy

  1. Write tests for business logic: The core algorithms and rules that make your app valuable.
  2. Integration test database operations: Queries, constraints, and data integrity.
  3. E2E test critical user flows: The paths that generate revenue or would cause support tickets if broken.
  4. Skip testing glue code: Simple wiring between components often isn't worth testing.
  5. Always write a test for bugs: Regression tests prevent the same bug from returning.

Conclusion

Good tests give you confidence to ship fast. Bad tests slow you down and give false confidence. Focus on testing behavior, not implementation. Test the edges where bugs hide. And remember: the best test suite is one that catches bugs before your users do.

Ship with confidence. 🚀