Testing Best Practices
Writing good tests is a skill that takes practice. In this final lesson, we'll cover best practices that will help you write tests that are maintainable, reliable, and valuable.
The FIRST Principles
Good tests are:
Fast
Tests should run quickly. Slow tests discourage frequent running.
// Bad: Uses real delays
it('waits for response', async () => {
await new Promise(r => setTimeout(r, 5000));
expect(result).toBeDefined();
});
// Good: Mock timers
it('waits for response', async () => {
jest.useFakeTimers();
const promise = waitForResponse();
jest.advanceTimersByTime(5000);
await expect(promise).resolves.toBeDefined();
});
Independent
Tests shouldn't depend on each other or run order.
// Bad: Tests depend on shared state
let counter = 0;
it('increments', () => {
counter++;
expect(counter).toBe(1);
});
it('increments again', () => {
counter++;
expect(counter).toBe(2); // Fails if run alone!
});
// Good: Each test is independent
it('increments', () => {
const counter = new Counter();
counter.increment();
expect(counter.value).toBe(1);
});
Repeatable
Tests should pass regardless of environment.
// Bad: Depends on current date
it('formats today', () => {
expect(formatDate(new Date())).toBe('January 10, 2026');
});
// Good: Use fixed dates
it('formats date correctly', () => {
const date = new Date('2026-01-10');
expect(formatDate(date)).toBe('January 10, 2026');
});
Self-Validating
Tests should have clear pass/fail outcomes.
// Bad: Requires manual verification
it('logs data', () => {
console.log(processData(input)); // Check output manually?
});
// Good: Automatic validation
it('processes data correctly', () => {
expect(processData(input)).toEqual(expectedOutput);
});
Timely
Write tests close to when you write the code.
Test Structure: Arrange-Act-Assert
Every test should follow the AAA pattern:
it('calculates discount for premium users', () => {
// Arrange - Set up test data
const user = { type: 'premium', purchases: 1000 };
const order = { total: 100 };
// Act - Execute the code being tested
const discount = calculateDiscount(user, order);
// Assert - Verify the result
expect(discount).toBe(10);
});
Keep the three sections clearly separated for readability.
Write Descriptive Test Names
Good test names describe the scenario and expected outcome:
// Bad: Vague names
it('works', () => {});
it('test add', () => {});
it('should be correct', () => {});
// Good: Descriptive names
it('returns zero for empty array', () => {});
it('adds two positive numbers correctly', () => {});
it('throws error when user is not authenticated', () => {});
// Great: Scenario-based names
it('should apply 10% discount when order exceeds $100', () => {});
it('should reject passwords shorter than 8 characters', () => {});
Pattern: it('should [expected behavior] when [condition]')
Test Behavior, Not Implementation
Tests should verify what code does, not how it does it.
// Bad: Testing implementation details
it('uses the cache map', () => {
const service = new DataService();
expect(service._cache).toBeInstanceOf(Map);
});
it('calls internal helper', () => {
const spy = jest.spyOn(service, '_formatData');
service.getData();
expect(spy).toHaveBeenCalled();
});
// Good: Testing behavior
it('returns cached data on second call', () => {
const service = new DataService();
const first = service.getData('key');
const second = service.getData('key');
expect(first).toBe(second);
});
This allows refactoring internal implementation without breaking tests.
Keep Tests Simple
Each test should verify one thing:
// Bad: Testing too much at once
it('validates user registration', () => {
expect(validate({ name: '' })).toContain('Name required');
expect(validate({ name: 'a' })).toContain('Name too short');
expect(validate({ email: '' })).toContain('Email required');
expect(validate({ email: 'bad' })).toContain('Invalid email');
expect(validate({ password: '123' })).toContain('Password too short');
expect(validate({ name: 'John', email: 'john@test.com', password: 'secure123' }))
.toEqual([]);
});
// Good: One concept per test
describe('user validation', () => {
it('requires name', () => {
expect(validate({ name: '' })).toContain('Name required');
});
it('requires name minimum length', () => {
expect(validate({ name: 'a' })).toContain('Name too short');
});
it('requires email', () => {
expect(validate({ email: '' })).toContain('Email required');
});
// ... etc
});
Use Test Helpers and Factories
Reduce duplication with helper functions:
// Bad: Repeated setup
it('test 1', () => {
const user = { id: 1, name: 'Alice', email: 'alice@test.com', role: 'admin' };
// test...
});
it('test 2', () => {
const user = { id: 2, name: 'Bob', email: 'bob@test.com', role: 'admin' };
// test...
});
// Good: Factory function
function createUser(overrides = {}) {
return {
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides
};
}
it('test 1', () => {
const user = createUser({ name: 'Alice', role: 'admin' });
// test...
});
it('test 2', () => {
const user = createUser({ name: 'Bob', role: 'admin' });
// test...
});
Don't Mock Everything
Mock only what you need to:
// Over-mocking: Hard to trust the test
jest.mock('./userService');
jest.mock('./database');
jest.mock('./cache');
jest.mock('./logger');
jest.mock('./validator');
it('creates user', async () => {
// Everything is fake - what are we really testing?
});
// Better: Mock external dependencies, test real logic
jest.mock('./database'); // External I/O
it('creates user with correct data', async () => {
database.insert.mockResolvedValue({ id: 1 });
const user = await userService.create({ name: 'Alice' });
// Tests real validation, transformation, etc.
expect(user.name).toBe('Alice');
expect(database.insert).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Alice' })
);
});
Test Edge Cases
Don't just test the happy path:
describe('divide', () => {
// Happy path
it('divides two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
// Edge cases
it('handles division by zero', () => {
expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
});
it('handles negative numbers', () => {
expect(divide(-10, 2)).toBe(-5);
expect(divide(10, -2)).toBe(-5);
});
it('handles decimals', () => {
expect(divide(7, 2)).toBe(3.5);
});
it('handles zero numerator', () => {
expect(divide(0, 5)).toBe(0);
});
});
Clean Up After Tests
Always clean up resources:
describe('Timer tests', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers(); // Clean up!
});
// tests...
});
describe('Database tests', () => {
let connection;
beforeAll(async () => {
connection = await createConnection();
});
afterAll(async () => {
await connection.close(); // Clean up!
});
afterEach(async () => {
await connection.clearAll(); // Reset between tests
});
// tests...
});
Common Anti-Patterns to Avoid
1. The Flaky Test
// Bad: Depends on timing
it('loads eventually', async () => {
await new Promise(r => setTimeout(r, 100));
expect(element).toBeVisible();
});
// Good: Wait for specific condition
it('loads eventually', async () => {
await waitFor(() => expect(element).toBeVisible());
});
2. The Test That Tests the Framework
// Bad: Testing that Array.push works
it('adds to array', () => {
const arr = [];
arr.push(1);
expect(arr).toContain(1); // Testing JavaScript itself
});
3. Excessive Setup
// Bad: 50 lines of setup for 2 lines of test
it('updates user name', async () => {
const db = new MockDatabase();
const cache = new MockCache();
const logger = new MockLogger();
const validator = new Validator();
// ... 40 more lines ...
user.setName('New Name');
expect(user.name).toBe('New Name');
});
// Good: Use factories and beforeEach
4. Testing Private Methods
// Bad: Testing internal implementation
it('should call _privateMethod', () => {
const spy = jest.spyOn(obj, '_privateMethod');
obj.publicMethod();
expect(spy).toHaveBeenCalled();
});
// Good: Test through public interface
it('should produce expected result', () => {
const result = obj.publicMethod();
expect(result).toBe(expectedValue);
});
Checklist for Good Tests
Before committing, ask yourself:
- Does each test have a clear purpose?
- Are test names descriptive?
- Does each test verify one thing?
- Are tests independent of each other?
- Are external dependencies mocked?
- Are edge cases covered?
- Is cleanup handled properly?
- Will tests pass regardless of run order?
- Can someone understand the test without reading the implementation?
Key Takeaways
- Follow FIRST principles: Fast, Independent, Repeatable, Self-validating, Timely
- Use Arrange-Act-Assert structure
- Write descriptive test names
- Test behavior, not implementation
- Keep tests focused on one thing
- Use factories to reduce duplication
- Mock external dependencies, not everything
- Always test edge cases
- Clean up after tests
- Avoid common anti-patterns
Course Summary
Congratulations! You've learned:
- Why testing matters - Confidence, documentation, faster development
- Jest setup - Installation, configuration, running tests
- Writing tests - test/it, expect, basic assertions
- Test structure - describe, beforeEach, afterEach
- Matchers - toBe, toEqual, toThrow, and more
- Testing functions - Pure functions, edge cases, errors
- Async testing - Promises, async/await, timers
- Mocking - Mock functions, modules, spies
- React testing - Components, user events, queries
- Code coverage - Metrics, thresholds, interpretation
- TDD - Red-Green-Refactor workflow
- Best practices - Patterns and anti-patterns
Now go write some tests! Start with the code you're working on today. Even one test is better than none, and each test you write makes you a better developer.
Happy testing!

