Testing & QA Practices
Overview
Comprehensive guide for writing effective tests, following TDD practices, and ensuring code quality through automated testing.
When to Use This Skill
- •Writing unit, integration, or E2E tests
- •Implementing test-driven development (TDD)
- •Setting up testing infrastructure
- •Debugging failing tests
- •Improving test coverage
- •Implementing mocking and stubbing strategies
Testing Pyramid
Unit Tests (Base - 70%)
Purpose: Test individual functions/methods in isolation Speed: Very fast (milliseconds) Characteristics:
- •No external dependencies
- •Test one thing at a time
- •Should be deterministic
- •Easy to write and maintain
Integration Tests (Middle - 20%)
Purpose: Test how components work together Speed: Moderate (seconds) Characteristics:
- •Test interactions between modules
- •May use real database/APIs in test mode
- •Test data flow between components
E2E Tests (Top - 10%)
Purpose: Test complete user workflows Speed: Slow (seconds to minutes) Characteristics:
- •Test from user's perspective
- •Use real browser/environment
- •Test critical user journeys only
Unit Testing Best Practices
1. Test Structure (AAA Pattern)
javascript
describe('UserService', () => {
it('should create a new user with hashed password', async () => {
// Arrange - Set up test data and dependencies
const userData = { email: 'test@example.com', password: 'password123' };
const mockHasher = jest.fn().mockResolvedValue('hashed_password');
// Act - Execute the code being tested
const user = await userService.create(userData, mockHasher);
// Assert - Verify the results
expect(user.email).toBe('test@example.com');
expect(user.password).toBe('hashed_password');
expect(mockHasher).toHaveBeenCalledWith('password123');
});
});
2. Test Naming Convention
Use descriptive names that explain what, when, and expected result:
javascript
// ✅ Good
it('should return 404 when user is not found')
it('should throw ValidationError when email is invalid')
it('should calculate total price including tax')
// ❌ Bad
it('works')
it('test user')
it('should pass')
3. One Assertion Per Test (When Possible)
javascript
// ✅ Good - Focused tests
it('should set user email correctly', () => {
const user = new User('test@example.com');
expect(user.email).toBe('test@example.com');
});
it('should set user id automatically', () => {
const user = new User('test@example.com');
expect(user.id).toBeDefined();
});
// ⚠️ Acceptable - Related assertions
it('should create user with valid properties', () => {
const user = new User('test@example.com', 'John');
expect(user.email).toBe('test@example.com');
expect(user.name).toBe('John');
expect(user.createdAt).toBeInstanceOf(Date);
});
4. Test Edge Cases and Error Conditions
javascript
describe('divide', () => {
it('should divide two positive numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('should handle negative numbers', () => {
expect(divide(-10, 2)).toBe(-5);
});
it('should throw error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
it('should handle decimal results', () => {
expect(divide(10, 3)).toBeCloseTo(3.333, 3);
});
});
Mocking & Stubbing
When to Mock
- •External APIs/services
- •Database calls
- •File system operations
- •Time-dependent functions
- •Third-party libraries
Mock Types
1. Function Mocks (Spies)
javascript
// Jest
const mockFn = jest.fn();
mockFn.mockReturnValue(42);
mockFn.mockResolvedValue({ id: 1 }); // For promises
mockFn.mockRejectedValue(new Error('Failed')); // For errors
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledTimes(1);
// Vitest (similar API)
const mockFn = vi.fn();
2. Module Mocks
javascript
// Mock entire module
jest.mock('./userService', () => ({
getUser: jest.fn().mockResolvedValue({ id: 1, name: 'John' }),
createUser: jest.fn()
}));
// Partial mock (keep some real implementations)
jest.mock('./userService', () => ({
...jest.requireActual('./userService'),
getUser: jest.fn() // Only mock this function
}));
3. Dependency Injection for Testability
javascript
// ✅ Good - Easy to test
class UserService {
constructor(database, emailService) {
this.db = database;
this.emailService = emailService;
}
async createUser(userData) {
const user = await this.db.insert(userData);
await this.emailService.sendWelcome(user.email);
return user;
}
}
// Test with mocks
const mockDb = { insert: jest.fn().mockResolvedValue({ id: 1 }) };
const mockEmail = { sendWelcome: jest.fn() };
const service = new UserService(mockDb, mockEmail);
// ❌ Bad - Hard to test
class UserService {
async createUser(userData) {
const user = await database.insert(userData); // Global dependency
await sendEmail(user.email); // Tightly coupled
return user;
}
}
Testing Asynchronous Code
Promises
javascript
// Using async/await
it('should fetch user data', async () => {
const user = await fetchUser(123);
expect(user.id).toBe(123);
});
// Using .resolves/.rejects
it('should fetch user data', () => {
return expect(fetchUser(123)).resolves.toEqual({ id: 123 });
});
it('should handle errors', () => {
return expect(fetchUser(-1)).rejects.toThrow('Invalid ID');
});
Callbacks
javascript
it('should call callback with result', (done) => {
fetchUser(123, (err, user) => {
expect(err).toBeNull();
expect(user.id).toBe(123);
done(); // Signal test completion
});
});
Test-Driven Development (TDD)
Red-Green-Refactor Cycle
1. Red - Write a failing test
javascript
it('should calculate total price with tax', () => {
const cart = new ShoppingCart();
cart.addItem({ price: 100, quantity: 2 });
expect(cart.getTotalWithTax(0.1)).toBe(220); // 200 + 10% tax
});
// Test fails - getTotalWithTax doesn't exist yet
2. Green - Write minimal code to pass
javascript
class ShoppingCart {
constructor() {
this.items = [];
}
addItem(item) {
this.items.push(item);
}
getTotalWithTax(taxRate) {
const subtotal = this.items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0);
return subtotal * (1 + taxRate);
}
}
// Test passes
3. Refactor - Improve code quality
javascript
class ShoppingCart {
constructor() {
this.items = [];
}
addItem(item) {
this.items.push(item);
}
getSubtotal() {
return this.items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0);
}
getTotalWithTax(taxRate) {
return this.getSubtotal() * (1 + taxRate);
}
}
// Tests still pass, code is cleaner
Integration Testing
Database Integration Tests
javascript
describe('UserRepository Integration', () => {
let db;
beforeAll(async () => {
// Set up test database
db = await setupTestDatabase();
});
afterAll(async () => {
await db.close();
});
beforeEach(async () => {
// Clear database before each test
await db.query('DELETE FROM users');
});
it('should save and retrieve user', async () => {
const repo = new UserRepository(db);
const userData = { email: 'test@example.com', name: 'John' };
const savedUser = await repo.save(userData);
expect(savedUser.id).toBeDefined();
const retrievedUser = await repo.findById(savedUser.id);
expect(retrievedUser.email).toBe('test@example.com');
});
});
API Integration Tests
javascript
describe('POST /api/users', () => {
it('should create a new user', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'test@example.com', password: 'password123' })
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(Number),
email: 'test@example.com'
});
expect(response.body.password).toBeUndefined(); // Shouldn't return password
});
it('should return 400 for invalid email', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'invalid-email', password: 'password123' })
.expect(400);
expect(response.body.error).toContain('Invalid email');
});
});
E2E Testing (Playwright/Cypress)
Playwright Example
javascript
import { test, expect } from '@playwright/test';
test('user can sign up and log in', async ({ page }) => {
// Navigate to signup page
await page.goto('/signup');
// Fill out form
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Welcome');
});
test('displays error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'wrong@example.com');
await page.fill('input[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toContainText('Invalid credentials');
});
Best Practices for E2E Tests
- •Test user journeys, not implementation
- •Use data-testid attributes instead of CSS selectors
- •Keep tests independent - each test should work in isolation
- •Clean up test data after each test
- •Test critical paths only - E2E tests are expensive
Code Coverage
What to Aim For
- •Statements: 80%+ coverage
- •Branches: 75%+ coverage
- •Functions: 80%+ coverage
- •Lines: 80%+ coverage
Coverage Commands
bash
# Jest npm test -- --coverage # Vitest npm test -- --coverage # Generate HTML report npm test -- --coverage --coverageReporters=html
Don't Chase 100% Coverage
- •Focus on critical business logic
- •Some code is hard/unnecessary to test (simple getters/setters)
- •Test behavior, not implementation
Common Testing Patterns
1. Test Fixtures (Reusable Test Data)
javascript
// fixtures/users.js
export const validUser = {
email: 'test@example.com',
name: 'John Doe',
password: 'password123'
};
export const adminUser = {
...validUser,
email: 'admin@example.com',
role: 'admin'
};
// In tests
import { validUser, adminUser } from './fixtures/users';
2. Factory Functions
javascript
function createTestUser(overrides = {}) {
return {
id: Math.random(),
email: 'test@example.com',
name: 'John Doe',
createdAt: new Date(),
...overrides
};
}
// Usage
const user1 = createTestUser();
const user2 = createTestUser({ email: 'different@example.com' });
3. Custom Matchers
javascript
expect.extend({
toBeValidEmail(received) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const pass = emailRegex.test(received);
return {
pass,
message: () => `expected ${received} to be a valid email`
};
}
});
// Usage
expect('test@example.com').toBeValidEmail();
Testing Best Practices Checklist
- • Tests are independent (can run in any order)
- • Tests are fast (unit tests < 100ms each)
- • Test names clearly describe what is being tested
- • Each test focuses on one behavior
- • Edge cases and error conditions are tested
- • No hardcoded values (use constants/fixtures)
- • Mocks are used appropriately (not over-mocked)
- • Tests are maintainable (don't test implementation details)
- • Critical code paths have high coverage
- • E2E tests cover main user workflows
Framework-Specific Resources
JavaScript/TypeScript
- •Jest: Unit/Integration testing
- •Vitest: Fast Vite-native testing
- •Playwright: E2E testing (recommended)
- •Cypress: E2E testing (UI-focused)
- •Testing Library: React/Vue component testing
Python
- •pytest: Unit/Integration testing
- •unittest: Built-in testing
- •pytest-mock: Mocking utilities
- •Selenium: E2E testing
Java
- •JUnit 5: Unit testing
- •Mockito: Mocking framework
- •TestContainers: Integration testing with Docker
- •Selenium/Playwright: E2E testing
Common Pitfalls to Avoid
- •Testing implementation details - Test behavior, not internal structure
- •Over-mocking - Balance between isolation and realistic tests
- •Flaky tests - Tests that randomly fail (usually timing issues)
- •Slow tests - Keep unit tests fast
- •Ignoring test maintenance - Treat test code like production code
- •Testing the framework - Don't test library code, test your code
- •No assertions - Every test should have at least one assertion