Automated Testing: Unit, Integration and E2E with Vitest and Playwright
Complete guide to automated testing. Unit tests with Vitest, integration tests, E2E with Playwright.
Introduction to Automated Testing
Automated testing is the foundation of reliable software development. Tests catch bugs before they reach users, enable confident refactoring, and serve as living documentation for how your code should behave. This comprehensive guide covers testing strategies, tools, and best practices for modern development.
The Testing Pyramid
The testing pyramid is a framework for balancing different types of tests:
Unit Tests (Base - Most Tests)
Test individual functions or components in isolation:
- Fast: Run in milliseconds
- Isolated: No external dependencies
- Focused: Test one thing at a time
- Abundant: Should make up 70% of tests
Integration Tests (Middle)
Test how components work together:
- Database interactions
- API endpoints
- Service communication
- Make up about 20% of tests
End-to-End Tests (Top - Fewest Tests)
Test complete user workflows:
- Browser automation
- Full system integration
- Slowest but highest confidence
- Make up about 10% of tests
Unit Testing with Jest
Basic Test Structure
// sum.js
export function sum(a, b) {
return a + b;
}
// sum.test.js
import { sum } from './sum';
describe('sum', () => {
test('adds two positive numbers', () => {
expect(sum(1, 2)).toBe(3);
});
test('handles negative numbers', () => {
expect(sum(-1, 1)).toBe(0);
});
test('handles zero', () => {
expect(sum(0, 5)).toBe(5);
});
});
Common Matchers
// Equality
expect(value).toBe(3); // Exact equality
expect(value).toEqual({a: 1}); // Deep equality
expect(value).not.toBe(4); // Negation
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeLessThanOrEqual(10);
expect(value).toBeCloseTo(0.3, 5); // Floating point
// Strings
expect(string).toMatch(/pattern/);
expect(string).toContain('substring');
// Arrays
expect(array).toContain(item);
expect(array).toHaveLength(3);
// Objects
expect(object).toHaveProperty('key');
expect(object).toHaveProperty('key', 'value');
// Exceptions
expect(() => throwingFunction()).toThrow();
expect(() => throwingFunction()).toThrow('specific message');
Testing Async Code
// Async/await
test('fetches user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('John');
});
// Promises
test('fetches user data with promise', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('John');
});
});
// Callbacks (use done)
test('calls callback with data', done => {
fetchUserCallback(1, (err, user) => {
expect(err).toBeNull();
expect(user.name).toBe('John');
done();
});
});
Mocking
// Mock functions
const mockCallback = jest.fn();
mockCallback('arg1', 'arg2');
expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockCallback).toHaveBeenCalledTimes(1);
// Mock return values
const mock = jest.fn()
.mockReturnValueOnce(10)
.mockReturnValueOnce(20)
.mockReturnValue(30);
// Mock implementations
jest.fn().mockImplementation((a, b) => a + b);
// Mock modules
jest.mock('./api');
import { fetchUser } from './api';
fetchUser.mockResolvedValue({ id: 1, name: 'John' });
// Spy on methods
const spy = jest.spyOn(object, 'method');
spy.mockReturnValue('mocked');
Integration Testing
Testing API Endpoints
// Using supertest with Express
import request from 'supertest';
import app from './app';
describe('User API', () => {
test('GET /users returns list of users', async () => {
const response = await request(app)
.get('/users')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveLength(3);
expect(response.body[0]).toHaveProperty('name');
});
test('POST /users creates a user', async () => {
const newUser = { name: 'John', email: 'john@example.com' };
const response = await request(app)
.post('/users')
.send(newUser)
.expect(201);
expect(response.body.id).toBeDefined();
expect(response.body.name).toBe('John');
});
test('GET /users/:id returns 404 for non-existent user', async () => {
await request(app)
.get('/users/999')
.expect(404);
});
});
Database Testing
// Setup and teardown
beforeAll(async () => {
await database.connect();
});
afterAll(async () => {
await database.disconnect();
});
beforeEach(async () => {
await database.clear();
await database.seed();
});
test('creates user in database', async () => {
const user = await userRepository.create({
name: 'John',
email: 'john@example.com'
});
const found = await userRepository.findById(user.id);
expect(found.name).toBe('John');
});
End-to-End Testing with Playwright
Basic E2E Test
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test('user can log in successfully', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'user@example.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('.welcome-message')).toContainText('Welcome');
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'wrong@example.com');
await page.fill('[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toBeVisible();
await expect(page.locator('.error-message')).toContainText('Invalid credentials');
});
});
Page Object Model
// pages/LoginPage.ts
export class LoginPage {
constructor(private page: Page) {}
async navigate() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.page.fill('[name="email"]', email);
await this.page.fill('[name="password"]', password);
await this.page.click('button[type="submit"]');
}
async getErrorMessage() {
return this.page.locator('.error-message').textContent();
}
}
// tests/login.spec.ts
import { LoginPage } from '../pages/LoginPage';
test('user can log in', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
Test-Driven Development (TDD)
The Red-Green-Refactor Cycle
- Red: Write a failing test for the new feature
- Green: Write minimal code to make the test pass
- Refactor: Improve the code while keeping tests green
// 1. Red - Write failing test
test('validates email format', () => {
expect(validateEmail('invalid')).toBe(false);
expect(validateEmail('valid@example.com')).toBe(true);
});
// 2. Green - Minimal implementation
function validateEmail(email) {
return email.includes('@');
}
// 3. Refactor - Better implementation
function validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
Code Coverage
Coverage Metrics
- Statement coverage: Percentage of code statements executed
- Branch coverage: Percentage of decision branches taken
- Function coverage: Percentage of functions called
- Line coverage: Percentage of lines executed
Jest Coverage Configuration
// jest.config.js
module.exports = {
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
collectCoverageFrom: [
'src/**/*.{js,ts}',
'!src/**/*.d.ts',
'!src/**/*.test.{js,ts}'
]
};
Testing React Components
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
test('renders initial count', () => {
render(<Counter initialCount={5} />);
expect(screen.getByText('Count: 5')).toBeInTheDocument();
});
test('increments count on button click', async () => {
render(<Counter initialCount={0} />);
const button = screen.getByRole('button', { name: /increment/i });
await userEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
test('calls onChange when count changes', async () => {
const handleChange = jest.fn();
render(<Counter initialCount={0} onChange={handleChange} />);
await userEvent.click(screen.getByRole('button', { name: /increment/i }));
expect(handleChange).toHaveBeenCalledWith(1);
});
CI/CD Integration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
Testing Best Practices
Write Good Test Names
// Bad
test('test1', () => {...});
// Good
test('returns null when user not found', () => {...});
test('throws ValidationError when email is invalid', () => {...});
Test Behavior, Not Implementation
// Bad - testing implementation details
test('calls setState with new value', () => {...});
// Good - testing behavior
test('displays updated value after increment', () => {...});
Keep Tests Independent
// Each test should set up its own data
beforeEach(() => {
testUser = createTestUser();
});
// Tests shouldn't depend on execution order
Tools Quick Reference
Complement your testing workflow with:
- JSON Formatter for validating test fixtures and API responses
- Regex Tester for testing validation patterns
- Diff Checker for comparing expected vs actual output
- UUID Generator for creating test data
Conclusion
Automated testing is an investment that pays dividends through reduced bugs, easier refactoring, and better documentation. Start with unit tests, add integration tests for critical paths, and use E2E tests sparingly for key user flows.
Key takeaways:
- Follow the testing pyramid: many unit tests, fewer integration, fewest E2E
- Test behavior, not implementation
- Keep tests fast and independent
- Use mocks judiciously
- Integrate tests into CI/CD pipeline
- Aim for meaningful coverage, not 100%
For more developer resources, explore our free online tools. For official documentation, see Jest Documentation and Playwright Documentation.