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?
- Fast: Milliseconds, not seconds. If your tests are slow, you won't run them.
- Isolated: No database, no network, no file system. Mock external dependencies.
- Readable: The test name should tell you what broke without reading the code.
- Deterministic: Same input, same output, every time. Flaky tests erode trust.
// 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');
});
});
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
- Test critical paths only: Sign up, login, checkout—the flows that must never break.
- Use stable selectors: Test IDs (
data-testid="submit-button") are better than CSS classes that change with styling. - Handle flakiness: Add retries, use proper waiting strategies, and quarantine flaky tests.
- Run in CI: E2E tests should block deploys, not just run on developer machines.
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:
- Well-understood problems with clear requirements
- Bug fixes (write a failing test first, then fix)
- Refactoring (tests give you confidence to change code)
TDD works poorly for:
- Exploratory coding where you're figuring out the design
- UI work where visual feedback matters more than assertions
- Prototype code that might be thrown away
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
- Write tests for business logic: The core algorithms and rules that make your app valuable.
- Integration test database operations: Queries, constraints, and data integrity.
- E2E test critical user flows: The paths that generate revenue or would cause support tickets if broken.
- Skip testing glue code: Simple wiring between components often isn't worth testing.
- 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. 🚀