Testing Best Practices
Master the art of writing maintainable, effective tests through proven best practices, patterns, and anti-patterns to avoid.
Introduction
Writing tests is essential, but writing good tests is an art. Well-crafted tests provide confidence, catch bugs early, and serve as living documentation. Poorly written tests become a maintenance burden and can even provide false confidence.
Good tests should be fast, isolated, repeatable, self-validating, and timely (FIRST principles).
Writing Maintainable Tests
1. Test Organization and Structure
AAA Pattern (Arrange-Act-Assert)
Every test should follow this clear structure:
// ❌ Bad: Unclear structure
it('should work', () => {
const result = add(2, 3);
expect(result).toBe(5);
const result2 = add(10, 20);
expect(result2).toBe(30);
});
// ✅ Good: Clear AAA structure
it('should add two numbers correctly', () => {
// Arrange
const a = 2;
const b = 3;
const expected = 5;
// Act
const result = add(a, b);
// Assert
expect(result).toBe(expected);
});Logical Test Organization
// ✅ Good: Hierarchical organization
describe('UserService', () => {
describe('createUser', () => {
describe('when valid data is provided', () => {
it('should create a new user', () => {});
it('should hash the password', () => {});
it('should return the created user', () => {});
});
describe('when invalid data is provided', () => {
it('should throw error for missing email', () => {});
it('should throw error for invalid email format', () => {});
it('should throw error for weak password', () => {});
});
describe('when user already exists', () => {
it('should throw error for duplicate email', () => {});
});
});
describe('updateUser', () => {
// Similar structure
});
});2. Naming Conventions
Test names should clearly describe what is being tested and what the expected outcome is.
// ❌ Bad: Vague names
it('should work', () => {});
it('test user creation', () => {});
it('should return true', () => {});
// ✅ Good: Descriptive names
it('should create a user with hashed password', () => {});
it('should throw ValidationError when email is missing', () => {});
it('should return true when user has admin role', () => {});
// ✅ Good: BDD-style naming
it('should send welcome email when user is created', () => {});
it('should not allow negative quantities in shopping cart', () => {});Naming Patterns
| Pattern | Example |
|---|---|
| should [expected behavior] | should return user when ID is valid |
| when [condition], should [outcome] | when user is not found, should throw NotFoundError |
| given [context], when [action], then [outcome] | given user exists, when deleting, then user is removed |
3. DRY Principle in Tests
Balance between DRY (Don’t Repeat Yourself) and test readability.
// ❌ Bad: Too much repetition
describe('Calculator', () => {
it('should add positive numbers', () => {
const calc = new Calculator();
calc.initialize();
const result = calc.add(2, 3);
expect(result).toBe(5);
});
it('should add negative numbers', () => {
const calc = new Calculator();
calc.initialize();
const result = calc.add(-2, -3);
expect(result).toBe(-5);
});
});
// ✅ Good: DRY with beforeEach
describe('Calculator', () => {
let calculator: Calculator;
beforeEach(() => {
calculator = new Calculator();
calculator.initialize();
});
it('should add positive numbers', () => {
const result = calculator.add(2, 3);
expect(result).toBe(5);
});
it('should add negative numbers', () => {
const result = calculator.add(-2, -3);
expect(result).toBe(-5);
});
});
// ✅ Good: Helper functions for complex setup
describe('UserService', () => {
function createTestUser(overrides = {}) {
return {
id: '123',
name: 'John Doe',
email: 'john@example.com',
role: 'user',
...overrides
};
}
it('should process admin users differently', () => {
const admin = createTestUser({ role: 'admin' });
const result = processUser(admin);
expect(result.hasAdminAccess).toBe(true);
});
it('should process regular users', () => {
const user = createTestUser();
const result = processUser(user);
expect(result.hasAdminAccess).toBe(false);
});
});Avoid over-abstracting tests. Each test should be readable on its own without jumping between multiple helper functions.
4. Test Data Management
Test Fixtures
// fixtures/users.ts
export const fixtures = {
validUser: {
name: 'John Doe',
email: 'john@example.com',
password: 'SecurePass123!'
},
adminUser: {
name: 'Admin User',
email: 'admin@example.com',
password: 'AdminPass123!',
role: 'admin'
},
invalidUsers: {
missingEmail: {
name: 'John Doe',
password: 'SecurePass123!'
},
invalidEmail: {
name: 'John Doe',
email: 'not-an-email',
password: 'SecurePass123!'
}
}
};
// In tests
import { fixtures } from './fixtures/users';
it('should create user with valid data', () => {
const user = createUser(fixtures.validUser);
expect(user).toBeDefined();
});Factory Functions
// factories/user.factory.ts
export class UserFactory {
private static counter = 0;
static create(overrides: Partial<User> = {}): User {
this.counter++;
return {
id: `user-${this.counter}`,
name: `Test User ${this.counter}`,
email: `user${this.counter}@example.com`,
createdAt: new Date(),
...overrides
};
}
static createMany(count: number, overrides: Partial<User> = {}): User[] {
return Array.from({ length: count }, () => this.create(overrides));
}
static createAdmin(): User {
return this.create({ role: 'admin', permissions: ['read', 'write', 'delete'] });
}
}
// In tests
it('should list all users', () => {
const users = UserFactory.createMany(5);
const result = listUsers(users);
expect(result).toHaveLength(5);
});
it('should filter admin users', () => {
const users = [
UserFactory.create(),
UserFactory.createAdmin(),
UserFactory.create()
];
const admins = filterAdmins(users);
expect(admins).toHaveLength(1);
});Builders Pattern
// builders/user.builder.ts
export class UserBuilder {
private user: Partial<User> = {
id: '123',
name: 'John Doe',
email: 'john@example.com',
role: 'user'
};
withId(id: string): this {
this.user.id = id;
return this;
}
withName(name: string): this {
this.user.name = name;
return this;
}
withEmail(email: string): this {
this.user.email = email;
return this;
}
asAdmin(): this {
this.user.role = 'admin';
return this;
}
build(): User {
return this.user as User;
}
}
// In tests
it('should grant admin access', () => {
const admin = new UserBuilder()
.withEmail('admin@example.com')
.asAdmin()
.build();
expect(hasAdminAccess(admin)).toBe(true);
});When to Mock vs. Real Implementations
Mocking Guidelines
// ✅ Good: Mock external dependencies
describe('EmailService', () => {
it('should send email via SMTP', async () => {
const smtpClient = {
sendMail: jest.fn().mockResolvedValue({ messageId: '123' })
};
const emailService = new EmailService(smtpClient);
await emailService.send('test@example.com', 'Hello');
expect(smtpClient.sendMail).toHaveBeenCalledWith({
to: 'test@example.com',
subject: 'Hello'
});
});
});
// ✅ Good: Mock slow or unreliable services
it('should handle payment processing', async () => {
const paymentGateway = {
charge: jest.fn().mockResolvedValue({ success: true })
};
const result = await processPayment(paymentGateway, 100);
expect(result.success).toBe(true);
});
// ❌ Bad: Mocking too much (testing mocks, not logic)
it('should calculate total', () => {
const calculator = {
add: jest.fn().mockReturnValue(5)
};
const result = calculator.add(2, 3);
expect(result).toBe(5); // This tests the mock, not real logic
});
// ✅ Good: Use real implementation
it('should calculate total', () => {
const calculator = new Calculator();
const result = calculator.add(2, 3);
expect(result).toBe(5);
});When to Mock
| Mock When | Use Real When |
|---|---|
| External API calls | Pure functions |
| Database operations (in unit tests) | Business logic |
| File system operations | Calculations |
| Network requests | Data transformations |
| Time-dependent functions | Validations |
| Random number generation | Simple utilities |
// Example: Mocking time
describe('Subscription', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-01'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should check if subscription is expired', () => {
const subscription = {
expiresAt: new Date('2023-12-31')
};
expect(isExpired(subscription)).toBe(true);
});
});Avoiding Common Anti-Patterns
1. Testing Implementation Details
// ❌ Bad: Testing internal implementation
class Counter {
private count = 0;
increment() {
this.count = this.count + 1;
}
getCount() {
return this.count;
}
}
it('should set count property', () => {
const counter = new Counter();
counter.increment();
expect(counter['count']).toBe(1); // Accessing private property
});
// ✅ Good: Testing public behavior
it('should increment counter', () => {
const counter = new Counter();
counter.increment();
expect(counter.getCount()).toBe(1);
});2. Fragile Selectors (in UI tests)
// ❌ Bad: Brittle CSS selectors
const button = screen.getByRole('button', { name: /submit/i });
// ❌ Bad: Implementation-dependent
const button = container.querySelector('.btn.btn-primary.mt-3');
// ✅ Good: Semantic queries
const button = screen.getByRole('button', { name: /submit/i });
const input = screen.getByLabelText(/email/i);
// ✅ Good: Test IDs for complex cases
const dialog = screen.getByTestId('confirmation-dialog');3. Conditional Test Logic
// ❌ Bad: Conditional logic in tests
it('should validate user', () => {
const user = getUser();
if (user.role === 'admin') {
expect(user.permissions).toContain('delete');
} else {
expect(user.permissions).not.toContain('delete');
}
});
// ✅ Good: Separate tests for each case
describe('User permissions', () => {
it('should grant delete permission to admins', () => {
const admin = createUser({ role: 'admin' });
expect(admin.permissions).toContain('delete');
});
it('should not grant delete permission to regular users', () => {
const user = createUser({ role: 'user' });
expect(user.permissions).not.toContain('delete');
});
});4. Multiple Assertions on Different Concerns
// ❌ Bad: Testing multiple things in one test
it('should handle user registration', async () => {
const user = await registerUser(userData);
expect(user.id).toBeDefined();
expect(user.password).not.toBe(userData.password); // Password hashing
expect(emailService.send).toHaveBeenCalled(); // Email sending
expect(database.save).toHaveBeenCalled(); // Database saving
});
// ✅ Good: Separate concerns
describe('User registration', () => {
it('should create user with generated ID', async () => {
const user = await registerUser(userData);
expect(user.id).toBeDefined();
});
it('should hash user password', async () => {
const user = await registerUser(userData);
expect(user.password).not.toBe(userData.password);
});
it('should send welcome email', async () => {
await registerUser(userData);
expect(emailService.send).toHaveBeenCalledWith(
userData.email,
expect.objectContaining({ subject: 'Welcome' })
);
});
});5. Shared State Between Tests
// ❌ Bad: Shared mutable state
let user: User;
beforeAll(() => {
user = createUser({ name: 'John' });
});
it('should update user name', () => {
user.name = 'Jane';
expect(user.name).toBe('Jane');
});
it('should have original name', () => {
// This test will fail because previous test mutated user
expect(user.name).toBe('John');
});
// ✅ Good: Isolated test state
describe('User updates', () => {
let user: User;
beforeEach(() => {
user = createUser({ name: 'John' });
});
it('should update user name', () => {
user.name = 'Jane';
expect(user.name).toBe('Jane');
});
it('should have original name', () => {
expect(user.name).toBe('John');
});
});Test Performance Optimization
1. Parallel Test Execution
# Jest - run tests in parallel (default)
jest --maxWorkers=4
# Run tests serially (useful for debugging)
jest --runInBand2. Selective Test Running
// Run only specific tests during development
describe.only('Feature under development', () => {
it('should work', () => {});
});
// Skip slow tests in watch mode
describe.skip('Slow integration tests', () => {
it('should run slowly', () => {});
});
// Conditional skipping
const runSlowTests = process.env.RUN_SLOW_TESTS === 'true';
(runSlowTests ? describe : describe.skip)('Slow tests', () => {
it('should take a long time', () => {});
});3. Efficient Setup/Teardown
// ❌ Bad: Creating database connection for each test
describe('UserRepository', () => {
it('should save user', async () => {
const db = await createConnection(); // Slow
const repo = new UserRepository(db);
await repo.save(user);
await db.close();
});
it('should find user', async () => {
const db = await createConnection(); // Slow
const repo = new UserRepository(db);
await repo.findById('123');
await db.close();
});
});
// ✅ Good: Reuse connection
describe('UserRepository', () => {
let db: Database;
let repo: UserRepository;
beforeAll(async () => {
db = await createConnection();
repo = new UserRepository(db);
});
afterAll(async () => {
await db.close();
});
it('should save user', async () => {
await repo.save(user);
});
it('should find user', async () => {
await repo.findById('123');
});
});4. Avoid Unnecessary Async
// ❌ Bad: Unnecessary async/await
it('should add numbers', async () => {
const result = add(2, 3);
expect(result).toBe(5);
});
// ✅ Good: Synchronous when possible
it('should add numbers', () => {
const result = add(2, 3);
expect(result).toBe(5);
});Managing Test Environments
Environment Variables
// config/test.config.ts
export const testConfig = {
database: {
host: process.env.TEST_DB_HOST || 'localhost',
port: parseInt(process.env.TEST_DB_PORT || '5432'),
name: process.env.TEST_DB_NAME || 'test_db'
},
apiUrl: process.env.TEST_API_URL || 'http://localhost:3000'
};
// jest.config.js
module.exports = {
setupFiles: ['<rootDir>/config/test-env.js']
};
// config/test-env.js
process.env.NODE_ENV = 'test';
process.env.API_URL = 'http://localhost:3000';Database Test Isolation
// Setup test database
beforeAll(async () => {
await db.migrate.latest();
});
beforeEach(async () => {
await db.seed.run();
});
afterEach(async () => {
await db('users').truncate();
await db('posts').truncate();
});
afterAll(async () => {
await db.destroy();
});Using Docker for Test Dependencies
# docker-compose.test.yml
version: '3.8'
services:
test-db:
image: postgres:14
environment:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
ports:
- "5433:5432"
tmpfs:
- /var/lib/postgresql/data# Run tests with Docker
docker-compose -f docker-compose.test.yml up -d
npm test
docker-compose -f docker-compose.test.yml downDebugging Failing Tests
1. Isolate the Failing Test
// Use .only to run single test
it.only('should fail', () => {
expect(true).toBe(false);
});2. Add Debug Output
it('should process data', () => {
const input = { value: 10 };
const result = processData(input);
console.log('Input:', input);
console.log('Result:', result);
expect(result.value).toBe(20);
});3. Use Debugger
it('should calculate correctly', () => {
const data = getData();
debugger; // Add breakpoint
const result = calculate(data);
expect(result).toBe(expected);
});
// Run with Node debugger
// node --inspect-brk node_modules/.bin/jest --runInBand4. Check Async Issues
// ❌ Bad: Missing await
it('should save user', () => {
saveUser(user); // Returns promise but not awaited
expect(db.users).toContain(user); // Runs before save completes
});
// ✅ Good: Proper async handling
it('should save user', async () => {
await saveUser(user);
expect(db.users).toContain(user);
});5. Increase Test Timeout
// For slow tests
it('should handle long operation', async () => {
await longRunningOperation();
}, 30000); // 30 second timeout
// Global timeout
jest.setTimeout(30000);Test Patterns Comparison
| Pattern | Use Case | Pros | Cons |
|---|---|---|---|
| Inline Setup | Simple tests | Clear, no abstraction | Repetitive |
| beforeEach | Common setup | DRY, automatic cleanup | Hidden setup |
| Fixtures | Predefined data | Consistent test data | Can become stale |
| Factories | Dynamic data | Flexible, realistic | More complex |
| Builders | Complex objects | Fluent API, readable | Overhead |
Good vs. Bad Practices Summary
Good Practices ✅
- Write tests before or alongside code (TDD)
- Test behavior, not implementation
- One assertion concept per test
- Use descriptive test names
- Keep tests simple and readable
- Isolate tests from each other
- Use appropriate mocking
- Clean up after tests
- Run tests frequently
Bad Practices ❌
- Testing private methods directly
- Multiple unrelated assertions
- Shared mutable state
- Excessive mocking
- Hard-coded test data
- Ignoring failing tests
- Skipping edge cases
- No cleanup after tests
- Flaky tests
Checklist for Quality Tests
/**
* Quality Test Checklist:
*
* □ Does the test name clearly describe what is being tested?
* □ Is the test focused on a single behavior?
* □ Is the test independent and isolated?
* □ Does the test use AAA pattern?
* □ Are assertions clear and specific?
* □ Is test data realistic and meaningful?
* □ Are edge cases covered?
* □ Are error scenarios tested?
* □ Is the test fast enough?
* □ Will the test fail if the code is broken?
* □ Is the test maintainable?
*/Related Topics
- Testing Fundamentals - Core concepts
- Code Coverage - Measuring test quality
- CI/CD Integration - Automated testing
- API Testing - Testing APIs