Automated Testing Complete Guide
Introduction
Automated testing ensures code quality and prevents regressions. This guide covers unit testing with Jest, integration testing, E2E testing with Cypress and Playwright, test strategies, mocking, and CI/CD integration.
1. Unit Testing with Jest
// Installation
npm install --save-dev jest @types/jest
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
// Basic test
// math.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = { add, subtract };
// math.test.js
const { add, subtract } = require('./math');
describe('Math functions', () => {
test('adds two numbers', () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
});
test('subtracts two numbers', () => {
expect(subtract(5, 3)).toBe(2);
});
});
// Testing async code
// api.js
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// api.test.js
test('fetches user data', async () => {
const user = await fetchUser(1);
expect(user).toHaveProperty('id');
expect(user).toHaveProperty('name');
});
// Or use resolves
test('fetches user data', () => {
return expect(fetchUser(1)).resolves.toHaveProperty('id');
});
// Testing promises
test('promise resolves', () => {
return expect(Promise.resolve('success')).resolves.toBe('success');
});
test('promise rejects', () => {
return expect(Promise.reject('error')).rejects.toBe('error');
});
// Matchers
test('common matchers', () => {
expect(2 + 2).toBe(4); // Exact equality
expect({ name: 'John' }).toEqual({ name: 'John' }); // Deep equality
expect(4).toBeGreaterThan(3);
expect(4).toBeLessThanOrEqual(4);
expect('team').toMatch(/tea/);
expect(['apple', 'banana']).toContain('apple');
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(true).toBeTruthy();
expect(false).toBeFalsy();
});
// Setup and teardown
describe('Database tests', () => {
beforeAll(async () => {
// Runs once before all tests
await db.connect();
});
afterAll(async () => {
// Runs once after all tests
await db.disconnect();
});
beforeEach(async () => {
// Runs before each test
await db.clear();
});
afterEach(() => {
// Runs after each test
jest.clearAllMocks();
});
test('creates user', async () => {
const user = await db.createUser({ name: 'John' });
expect(user.id).toBeDefined();
});
});
2. Mocking & Spies
// Mock functions
const mockFn = jest.fn();
mockFn('hello');
mockFn('world');
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenLastCalledWith('world');
// Mock return values
const mockCalculate = jest.fn();
mockCalculate.mockReturnValue(42);
mockCalculate.mockReturnValueOnce(10).mockReturnValueOnce(20);
expect(mockCalculate()).toBe(10);
expect(mockCalculate()).toBe(20);
expect(mockCalculate()).toBe(42);
// Mock implementations
const mockFilter = jest.fn((arr, predicate) => arr.filter(predicate));
const result = mockFilter([1, 2, 3, 4], x => x > 2);
expect(result).toEqual([3, 4]);
// Spy on methods
// user.js
class User {
constructor(name) {
this.name = name;
}
greet() {
return `Hello, ${this.name}`;
}
}
// user.test.js
test('spy on greet method', () => {
const user = new User('John');
const spy = jest.spyOn(user, 'greet');
user.greet();
expect(spy).toHaveBeenCalled();
expect(spy).toHaveReturnedWith('Hello, John');
spy.mockRestore(); // Restore original implementation
});
// Mock modules
// emailService.js
async function sendEmail(to, subject, body) {
// Real email sending logic
}
// user.test.js
jest.mock('./emailService');
const emailService = require('./emailService');
test('sends welcome email', async () => {
emailService.sendEmail.mockResolvedValue({ success: true });
const result = await emailService.sendEmail('test@example.com', 'Welcome', 'Hello!');
expect(emailService.sendEmail).toHaveBeenCalledWith(
'test@example.com',
'Welcome',
'Hello!'
);
expect(result).toEqual({ success: true });
});
// Partial mocks
jest.mock('./api', () => ({
...jest.requireActual('./api'),
fetchUser: jest.fn()
}));
// Mock timers
test('delays execution', () => {
jest.useFakeTimers();
const callback = jest.fn();
setTimeout(callback, 1000);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
jest.useRealTimers();
});
// Mock dates
test('uses current date', () => {
jest.useFakeTimers().setSystemTime(new Date('2024-01-01'));
const now = new Date();
expect(now.getFullYear()).toBe(2024);
jest.useRealTimers();
});
3. React Component Testing
// Install testing library
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
// Button.jsx
function Button({ onClick, children, disabled = false }) {
return (
);
}
// Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';
test('renders button with text', () => {
render();
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('calls onClick when clicked', async () => {
const handleClick = jest.fn();
render();
await userEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render();
expect(screen.getByText('Click me')).toBeDisabled();
});
// Counter component
function Counter() {
const [count, setCount] = useState(0);
return (
Count: {count}
);
}
// Counter.test.jsx
test('increments count', async () => {
render( );
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await userEvent.click(screen.getByText('Increment'));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
await userEvent.click(screen.getByText('Increment'));
expect(screen.getByText('Count: 2')).toBeInTheDocument();
});
// Form testing
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ email, password });
};
return (
);
}
test('submits form with credentials', async () => {
const handleSubmit = jest.fn();
render( );
await userEvent.type(screen.getByPlaceholderText('Email'), 'test@example.com');
await userEvent.type(screen.getByPlaceholderText('Password'), 'password123');
await userEvent.click(screen.getByText('Login'));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
// Testing async components
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return Loading...;
if (!user) return User not found;
return Name: {user.name};
}
test('loads and displays user', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ id: 1, name: 'John' })
})
);
render( );
expect(screen.getByText('Loading...')).toBeInTheDocument();
const userName = await screen.findByText('Name: John');
expect(userName).toBeInTheDocument();
expect(fetch).toHaveBeenCalledWith('/api/users/1');
});
4. Integration Testing
// API integration tests
const request = require('supertest');
const app = require('./app');
const db = require('./db');
describe('User API', () => {
beforeAll(async () => {
await db.connect();
});
afterAll(async () => {
await db.disconnect();
});
beforeEach(async () => {
await db.clear();
});
test('POST /api/users creates user', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'John', email: 'john@example.com' })
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe('John');
// Verify in database
const user = await db.users.findOne({ email: 'john@example.com' });
expect(user).toBeTruthy();
});
test('GET /api/users/:id returns user', async () => {
const user = await db.users.create({ name: 'Jane', email: 'jane@example.com' });
const response = await request(app)
.get(`/api/users/${user.id}`)
.expect(200);
expect(response.body.name).toBe('Jane');
});
test('GET /api/users/:id returns 404 for non-existent user', async () => {
await request(app)
.get('/api/users/999999')
.expect(404);
});
test('authenticated endpoint requires token', async () => {
await request(app)
.get('/api/profile')
.expect(401);
const token = 'valid_jwt_token';
await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200);
});
});
// Database integration tests
describe('User model', () => {
test('creates user with hashed password', async () => {
const user = await User.create({
email: 'test@example.com',
password: 'password123'
});
expect(user.password).not.toBe('password123');
expect(await user.comparePassword('password123')).toBe(true);
});
test('enforces unique email constraint', async () => {
await User.create({ email: 'test@example.com', password: 'pass1' });
await expect(
User.create({ email: 'test@example.com', password: 'pass2' })
).rejects.toThrow();
});
});
// Testing with test database
// jest.config.js
module.exports = {
testEnvironment: 'node',
setupFilesAfterEnv: ['./tests/setup.js']
};
// tests/setup.js
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/testdb';
process.env.NODE_ENV = 'test';
5. E2E Testing with Cypress
// Installation
npm install --save-dev cypress
// package.json
{
"scripts": {
"cypress:open": "cypress open",
"cypress:run": "cypress run"
}
}
// cypress/e2e/login.cy.js
describe('Login flow', () => {
beforeEach(() => {
cy.visit('http://localhost:3000');
});
it('logs in successfully', () => {
cy.get('input[name="email"]').type('user@example.com');
cy.get('input[name="password"]').type('password123');
cy.get('button[type="submit"]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome back').should('be.visible');
});
it('shows error for invalid credentials', () => {
cy.get('input[name="email"]').type('wrong@example.com');
cy.get('input[name="password"]').type('wrongpass');
cy.get('button[type="submit"]').click();
cy.contains('Invalid credentials').should('be.visible');
});
});
// Custom commands
// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login');
cy.get('input[name="email"]').type(email);
cy.get('input[name="password"]').type(password);
cy.get('button[type="submit"]').click();
});
// Use custom command
cy.login('user@example.com', 'password123');
// API intercepts
cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers');
cy.visit('/users');
cy.wait('@getUsers');
// Mock API responses
cy.intercept('POST', '/api/orders', {
statusCode: 201,
body: { id: 1, total: 99.99 }
}).as('createOrder');
cy.get('button.checkout').click();
cy.wait('@createOrder');
// File uploads
cy.get('input[type="file"]').selectFile('cypress/fixtures/image.png');
// Testing forms
describe('Contact form', () => {
it('submits form successfully', () => {
cy.visit('/contact');
cy.get('#name').type('John Doe');
cy.get('#email').type('john@example.com');
cy.get('#message').type('Hello, this is a test message');
cy.get('button[type="submit"]').click();
cy.contains('Message sent successfully').should('be.visible');
});
it('validates required fields', () => {
cy.visit('/contact');
cy.get('button[type="submit"]').click();
cy.contains('Name is required').should('be.visible');
cy.contains('Email is required').should('be.visible');
});
});
6. E2E Testing with Playwright
// Installation
npm install --save-dev @playwright/test
// playwright.config.js
module.exports = {
testDir: './tests',
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
],
};
// tests/login.spec.js
const { test, expect } = require('@playwright/test');
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.locator('text=Welcome back')).toBeVisible();
});
// Multiple browsers
test.describe('Cross-browser tests', () => {
test('works in all browsers', async ({ page, browserName }) => {
console.log(`Testing in ${browserName}`);
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});
});
// Mobile testing
test('mobile menu works', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
await page.click('.mobile-menu-button');
await expect(page.locator('.mobile-menu')).toBeVisible();
});
// API testing
test('API returns correct data', async ({ request }) => {
const response = await request.get('/api/users/1');
expect(response.ok()).toBeTruthy();
const data = await response.json();
expect(data).toHaveProperty('id', 1);
expect(data).toHaveProperty('name');
});
// Screenshots and videos
test('capture screenshot', async ({ page }) => {
await page.goto('/');
await page.screenshot({ path: 'homepage.png' });
});
7. Test Coverage & CI/CD
// Jest coverage
// package.json
{
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx}",
"!src/index.js",
"!src/**/*.test.{js,jsx}"
],
"coverageThresholds": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
// Run coverage
npm run test:coverage
// GitHub Actions CI
// .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
- name: Run integration tests
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
run: npm run test:integration
- name: Run E2E tests
run: |
npm run build
npm run start &
npx wait-on http://localhost:3000
npm run cypress:run
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
// Test parallelization
// package.json
{
"scripts": {
"test": "jest --maxWorkers=4",
"test:ci": "jest --ci --maxWorkers=2"
}
}
8. Best Practices
β Testing Best Practices:
- β Follow Arrange-Act-Assert (AAA) pattern
- β Write tests that are independent and isolated
- β Use descriptive test names that explain behavior
- β Test behavior, not implementation details
- β Aim for high code coverage (80%+ recommended)
- β Mock external dependencies (APIs, databases)
- β Use test fixtures for consistent test data
- β Run tests in CI/CD pipeline on every commit
- β Separate unit, integration, and E2E tests
- β Keep tests fast (unit < 1s, integration < 10s)
- β Test edge cases and error conditions
- β Use snapshot testing sparingly (can be brittle)
- β Clean up test data after each test
- β Run E2E tests against production-like environment
- β Monitor and maintain test suites regularly
Conclusion
Automated testing is essential for maintainable software. Implement unit tests for individual functions, integration tests for API endpoints, and E2E tests for critical user flows. Use mocking to isolate dependencies, maintain high code coverage, and run tests in CI/CD pipelines. Well-tested code gives confidence for refactoring and prevents regressions.
π‘ Pro Tip: Use Test-Driven Development (TDD) for critical features: write failing tests first, then implement code to make them pass. This ensures testable code design and prevents untested paths. For complex business logic, consider property-based testing with libraries like fast-check to automatically generate test cases. Set up mutation testing with Stryker to verify your tests actually catch bugsβit modifies your code and checks if tests fail appropriately.