Testing Best Practices
Real-world testing strategies, common patterns, debugging techniques, and building maintainable test suites
Writing Testable Code
Good tests start with good code. Code that's easy to test is usually better designed, more modular, and easier to maintain.
❌ Hard to Test
// Tightly coupled, hard dependencies
function processOrder() {
const db = new Database();
const payment = new PaymentGateway();
const email = new EmailService();
const order = db.getOrder(123);
const result = payment.charge(order.total);
email.send(order.email, 'Receipt');
return result;
}
// How do you test this without:
// - Real database
// - Real payment gateway
// - Actually sending emails
✅ Easy to Test
// Dependency injection, pure function
function processOrder(order, payment, email) {
const result = payment.charge(order.total);
if (result.success) {
email.send(order.email, 'Receipt');
}
return result;
}
// Easy to test with mocks
test('processes order successfully', () => {
const mockPayment = {
charge: jest.fn().mockReturnValue({ success: true })
};
const mockEmail = {
send: jest.fn()
};
const order = { total: 100, email: 'user@example.com' };
const result = processOrder(order, mockPayment, mockEmail);
expect(result.success).toBe(true);
expect(mockEmail.send).toHaveBeenCalled();
});
Test Organization Strategies
// Example: Well-organized test file
describe('ShoppingCart', () => {
// Group related functionality
describe('adding items', () => {
test('adds item to empty cart', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, price: 10 });
expect(cart.items).toHaveLength(1);
expect(cart.total).toBe(10);
});
test('increments quantity for existing item', () => {
const cart = new ShoppingCart();
const item = { id: 1, price: 10 };
cart.addItem(item);
cart.addItem(item);
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(2);
expect(cart.total).toBe(20);
});
test('adds different items separately', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, price: 10 });
cart.addItem({ id: 2, price: 15 });
expect(cart.items).toHaveLength(2);
expect(cart.total).toBe(25);
});
});
describe('removing items', () => {
let cart;
beforeEach(() => {
cart = new ShoppingCart();
cart.addItem({ id: 1, price: 10 });
cart.addItem({ id: 2, price: 15 });
});
test('removes item completely', () => {
cart.removeItem(1);
expect(cart.items).toHaveLength(1);
expect(cart.total).toBe(15);
});
test('decrements quantity when count > 1', () => {
cart.addItem({ id: 1, price: 10 });
cart.removeItem(1);
const item = cart.items.find(i => i.id === 1);
expect(item.quantity).toBe(1);
expect(cart.total).toBe(25);
});
});
describe('applying discounts', () => {
test('applies percentage discount', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, price: 100 });
cart.applyDiscount({ type: 'percentage', value: 10 });
expect(cart.total).toBe(90);
});
test('applies fixed amount discount', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, price: 100 });
cart.applyDiscount({ type: 'fixed', value: 25 });
expect(cart.total).toBe(75);
});
test('does not apply discount below zero', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, price: 10 });
cart.applyDiscount({ type: 'fixed', value: 20 });
expect(cart.total).toBe(0);
});
});
});
Test Naming Conventions
Pattern: "should [expected behavior] when [condition]"
test('should return user when ID exists', () => {});
test('should throw error when ID is invalid', () => {});
test('should update quantity when same item added twice', () => {});
Pattern: "[action] [expected result]"
test('calculates total with tax correctly', () => {});
test('throws error on division by zero', () => {});
test('filters completed todos', () => {});
Bad Examples - Too Vague
test('it works', () => {}); // What works?
test('test cart', () => {}); // What about the cart?
test('should pass', () => {}); // Meaningless
test('edge case', () => {}); // Which edge case?
Testing Edge Cases
// Always test edge cases and boundaries
describe('String utilities', () => {
describe('truncate', () => {
test('handles normal case', () => {
expect(truncate('Hello World', 8)).toBe('Hello...');
});
test('returns original when length is sufficient', () => {
expect(truncate('Hello', 10)).toBe('Hello');
});
test('handles empty string', () => {
expect(truncate('', 5)).toBe('');
});
test('handles null', () => {
expect(truncate(null, 5)).toBe('');
});
test('handles undefined', () => {
expect(truncate(undefined, 5)).toBe('');
});
test('handles string exactly at limit', () => {
expect(truncate('Hello', 5)).toBe('Hello');
});
test('handles length of 0', () => {
expect(truncate('Hello', 0)).toBe('...');
});
test('handles negative length', () => {
expect(truncate('Hello', -1)).toBe('...');
});
test('handles special characters', () => {
expect(truncate('Hello 👋 World', 8)).toBe('Hello...');
});
});
});
🎯 Common Edge Cases to Test:
- • Empty inputs: [], '', {}, null, undefined
- • Boundaries: 0, -1, max values, array bounds
- • Special values: NaN, Infinity, negative numbers
- • Types: Wrong type passed, mixed types
- • Strings: Empty, whitespace only, very long, special chars
- • Arrays: Empty, single item, duplicates, unsorted
Flaky Tests and How to Fix Them
❌ Common Causes of Flaky Tests
- Race conditions: Async operations not properly awaited
- Shared state: Tests affecting each other
- Time dependencies: Tests relying on current time/date
- Random data: Using Math.random() in tests
- Network issues: Real API calls timing out
- Animation timing: UI not ready when test runs
// ❌ Flaky: Race condition
test('loads user data', async () => {
fetchUser(1);
// Not awaited! Test might check before data arrives
expect(getUserName()).toBe('Alice');
});
// ✅ Fixed: Properly awaited
test('loads user data', async () => {
await fetchUser(1);
expect(getUserName()).toBe('Alice');
});
// ❌ Flaky: Time-dependent
test('creates timestamp', () => {
const record = createRecord();
expect(record.createdAt).toBe(new Date()); // Milliseconds might differ!
});
// ✅ Fixed: Mock time or check range
test('creates timestamp', () => {
const before = Date.now();
const record = createRecord();
const after = Date.now();
expect(record.createdAt).toBeGreaterThanOrEqual(before);
expect(record.createdAt).toBeLessThanOrEqual(after);
});
// Even better: Mock Date
test('creates timestamp', () => {
const mockDate = new Date('2024-01-01');
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
const record = createRecord();
expect(record.createdAt).toEqual(mockDate);
});
// ❌ Flaky: Shared state
let userId = 1;
test('creates user', () => {
const user = createUser(userId++);
expect(user.id).toBe(1); // Depends on test order!
});
// ✅ Fixed: Independent state
test('creates user', () => {
const userId = generateUniqueId();
const user = createUser(userId);
expect(user.id).toBe(userId);
});
Test Coverage Best Practices
✅ Good Coverage Goals
// Focus on critical code paths
✓ Business logic: 90-100%
✓ Utilities: 80-90%
✓ API routes: 80-90%
✓ Components: 70-80%
✓ Overall: 70-80%
// Not everything needs 100%
- Simple getters/setters
- Trivial wrappers
- Generated code
- UI presentation logic
⚠️ Coverage Isn't Everything
// 100% coverage doesn't mean bug-free
test('function exists', () => {
expect(calculateTotal).toBeDefined();
});
// This gives coverage but tests nothing!
// Better: Test behavior
test('calculates total correctly', () => {
const result = calculateTotal([
{ price: 10, qty: 2 },
{ price: 5, qty: 1 }
]);
expect(result).toBe(25);
});
Debugging Failed Tests
// Add debugging output
test('complex calculation', () => {
const input = [1, 2, 3, 4, 5];
const result = complexFunction(input);
console.log('Input:', input);
console.log('Result:', result);
console.log('Expected:', 15);
expect(result).toBe(15);
});
// Use test.only to run single test
test.only('the failing test', () => {
// Focus on just this one
});
// Use test.skip to temporarily disable
test.skip('not ready yet', () => {
// Won't run
});
// Run tests in watch mode
// npm test -- --watch
// Run with verbose output
// npm test -- --verbose
// Debug in VS Code
// Add breakpoint and use "Debug Test" option
Continuous Integration Best Practices
// .github/workflows/test.yml - GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test -- --coverage
- name: Run integration tests
run: npm run test:integration
- name: Run E2E tests
run: npm run test:e2e
- name: Upload coverage
uses: codecov/codecov-action@v3
- name: Upload screenshots on failure
if: failure()
uses: actions/upload-artifact@v3
with:
name: test-screenshots
path: screenshots/
🚀 CI/CD Testing Strategy:
- • Run unit tests on every commit (fast feedback)
- • Run integration tests on PR (catch integration issues)
- • Run E2E tests before deployment (final check)
- • Fail the build if tests fail
- • Track coverage over time
- • Save artifacts (screenshots, videos) on failure
🎯 Testing Golden Rules
- 1. Test behavior, not implementation - Tests should survive refactoring
- 2. Each test should test one thing - Easier to understand and debug
- 3. Tests should be independent - Run in any order, don't share state
- 4. Fast tests get run more - Keep unit tests under 1 second
- 5. Clear failure messages - Should explain what went wrong
- 6. Write tests as you code - Don't save testing for later
- 7. Red-Green-Refactor - TDD when it makes sense
- 8. Test edge cases - Bugs live on the boundaries
- 9. Mock external dependencies - Control what you test
- 10. Tests are documentation - Write them clearly
💡 Key Takeaways
- ✓ Write testable code with loose coupling and dependency injection
- ✓ Organize tests in logical groups with clear naming
- ✓ Always test edge cases and error conditions
- ✓ Fix flaky tests immediately - they erode confidence
- ✓ Coverage is a tool, not a goal - aim for meaningful tests
- ✓ Debug systematically with console.log and test.only
- ✓ Integrate testing into your CI/CD pipeline
- ✓ Balance speed, cost, and confidence across test types
Learn More
📚 More Testing Topics
Explore all 6 testing topics to build a comprehensive understanding of software testing.
View All Topics