Testing Strategies: Unit, Integration và E2E Testing
Chiến lược testing toàn diện cho frontend applications
Testing Strategies: Unit, Integration và E2E Testing
Testing là essential cho việc xây dựng ứng dụng reliable và maintainable. Một testing strategy tốt sẽ giúp bạn catch bugs early và confident khi refactor code.
Testing Pyramid
/\
/ \ E2E Tests (Few)
/____\
/ \ Integration Tests (Some)
/________\ Unit Tests (Many)
Test Distribution
Unit Tests: 70% - Fast, Isolated, Cheap
Integration Tests: 20% - Medium speed, Component interaction
E2E Tests: 10% - Slow, Real user scenarios, Expensive
Unit Testing
Jest Setup
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
},
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/index.js',
'!src/serviceWorker.js'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
Testing React Components
// Button.js
import React from 'react';
import PropTypes from 'prop-types';
const Button = ({ onClick, children, variant = 'primary', disabled = false }) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
data-testid="button"
>
{children}
</button>
);
};
Button.propTypes = {
onClick: PropTypes.func.isRequired,
children: PropTypes.node.isRequired,
variant: PropTypes.oneOf(['primary', 'secondary', 'danger']),
disabled: PropTypes.bool
};
export default Button;
// Button.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button Component', () => {
test('renders with correct text', () => {
render(<Button onClick={() => {}}>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('handles click events', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('applies correct variant class', () => {
render(<Button onClick={() => {}} variant="danger">Danger</Button>);
expect(screen.getByTestId('button')).toHaveClass('btn-danger');
});
test('disables button when disabled prop is true', () => {
render(<Button onClick={() => {}} disabled>Disabled</Button>);
expect(screen.getByTestId('button')).toBeDisabled();
});
test('does not call onClick when disabled', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick} disabled>Disabled</Button>);
fireEvent.click(screen.getByText('Disabled'));
expect(handleClick).not.toHaveBeenCalled();
});
});
Testing Custom Hooks
// useCounter.js
import { useState, useCallback } from 'react';
const useCounter = (initialValue = 0) => {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(prev => prev + 1);
}, []);
const decrement = useCallback(() => {
setCount(prev => prev - 1);
}, []);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
return { count, increment, decrement, reset };
};
export default useCounter;
// useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';
describe('useCounter Hook', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(12);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Integration Testing
Testing Component Integration
// UserForm.js
import React, { useState } from 'react';
import Button from './Button';
import Input from './Input';
const UserForm = ({ onSubmit }) => {
const [formData, setFormData] = useState({ name: '', email: '' });
const [errors, setErrors] = useState({});
const validate = () => {
const newErrors = {};
if (!formData.name) newErrors.name = 'Name is required';
if (!formData.email) newErrors.email = 'Email is required';
else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Email is invalid';
}
return newErrors;
};
const handleSubmit = (e) => {
e.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length === 0) {
onSubmit(formData);
setFormData({ name: '', email: '' });
} else {
setErrors(validationErrors);
}
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }));
}
};
return (
<form onSubmit={handleSubmit} data-testid="user-form">
<Input
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Enter your name"
error={errors.name}
data-testid="name-input"
/>
<Input
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Enter your email"
error={errors.email}
data-testid="email-input"
/>
<Button type="submit">Submit</Button>
</form>
);
};
export default UserForm;
// UserForm.test.js
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import UserForm from './UserForm';
describe('UserForm Integration', () => {
test('submits form with valid data', async () => {
const mockSubmit = jest.fn();
render(<UserForm onSubmit={mockSubmit} />);
fireEvent.change(screen.getByTestId('name-input'), {
target: { value: 'John Doe' }
});
fireEvent.change(screen.getByTestId('email-input'), {
target: { value: 'john@example.com' }
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com'
});
});
});
test('shows validation errors for empty fields', async () => {
render(<UserForm onSubmit={jest.fn()} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(screen.getByText('Name is required')).toBeInTheDocument();
expect(screen.getByText('Email is required')).toBeInTheDocument();
});
});
test('shows validation error for invalid email', async () => {
render(<UserForm onSubmit={jest.fn()} />);
fireEvent.change(screen.getByTestId('email-input'), {
target: { value: 'invalid-email' }
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(screen.getByText('Email is invalid')).toBeInTheDocument();
});
});
test('clears error when user starts typing', async () => {
render(<UserForm onSubmit={jest.fn()} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(screen.getByText('Name is required')).toBeInTheDocument();
});
fireEvent.change(screen.getByTestId('name-input'), {
target: { value: 'J' }
});
await waitFor(() => {
expect(screen.queryByText('Name is required')).not.toBeInTheDocument();
});
});
});
Testing API Integration
// userService.js
import axios from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:3000/api';
export const userService = {
async getUsers() {
const response = await axios.get(`${API_BASE_URL}/users`);
return response.data;
},
async createUser(userData) {
const response = await axios.post(`${API_BASE_URL}/users`, userData);
return response.data;
},
async updateUser(id, userData) {
const response = await axios.put(`${API_BASE_URL}/users/${id}`, userData);
return response.data;
},
async deleteUser(id) {
await axios.delete(`${API_BASE_URL}/users/${id}`);
}
};
// userService.test.js
import axios from 'axios';
import { userService } from './userService';
jest.mock('axios');
describe('userService API Integration', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('fetches users successfully', async () => {
const mockUsers = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }
];
axios.get.mockResolvedValue({ data: mockUsers });
const users = await userService.getUsers();
expect(users).toEqual(mockUsers);
expect(axios.get).toHaveBeenCalledWith('http://localhost:3000/api/users');
});
test('creates user successfully', async () => {
const newUser = { name: 'John Doe', email: 'john@example.com' };
const createdUser = { id: 1, ...newUser };
axios.post.mockResolvedValue({ data: createdUser });
const result = await userService.createUser(newUser);
expect(result).toEqual(createdUser);
expect(axios.post).toHaveBeenCalledWith('http://localhost:3000/api/users', newUser);
});
test('handles API errors gracefully', async () => {
const errorMessage = 'Network error';
axios.get.mockRejectedValue(new Error(errorMessage));
await expect(userService.getUsers()).rejects.toThrow(errorMessage);
});
});
End-to-End Testing
Cypress Setup
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: false,
screenshotOnRunFailure: true,
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
});
// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login');
cy.get('[data-testid="email-input"]').type(email);
cy.get('[data-testid="password-input"]').type(password);
cy.get('[data-testid="login-button"]').click();
});
Cypress.Commands.add('createUser', (userData) => {
cy.visit('/users/new');
cy.get('[data-testid="name-input"]').type(userData.name);
cy.get('[data-testid="email-input"]').type(userData.email);
cy.get('[data-testid="submit-button"]').click();
});
E2E Test Examples
// user-management.cy.js
describe('User Management E2E', () => {
beforeEach(() => {
cy.login('admin@example.com', 'password123');
});
it('creates a new user successfully', () => {
cy.visit('/users');
cy.get('[data-testid="add-user-button"]').click();
cy.get('[data-testid="name-input"]').type('John Doe');
cy.get('[data-testid="email-input"]').type('john@example.com');
cy.get('[data-testid="role-select"]').select('admin');
cy.get('[data-testid="submit-button"]').click();
cy.get('[data-testid="success-message"]')
.should('be.visible')
.and('contain', 'User created successfully');
cy.get('[data-testid="users-table"]')
.should('contain', 'John Doe')
.and('contain', 'john@example.com');
});
it('validates user form input', () => {
cy.visit('/users/new');
cy.get('[data-testid="submit-button"]').click();
cy.get('[data-testid="name-error"]')
.should('be.visible')
.and('contain', 'Name is required');
cy.get('[data-testid="email-error"]')
.should('be.visible')
.and('contain', 'Email is required');
});
it('edits existing user', () => {
// Create a user first
cy.createUser({ name: 'Jane Smith', email: 'jane@example.com' });
cy.get('[data-testid="edit-button"]').first().click();
cy.get('[data-testid="name-input"]').clear().type('Jane Johnson');
cy.get('[data-testid="submit-button"]').click();
cy.get('[data-testid="success-message"]')
.should('be.visible')
.and('contain', 'User updated successfully');
cy.get('[data-testid="users-table"]')
.should('contain', 'Jane Johnson')
.and('not.contain', 'Jane Smith');
});
it('deletes user with confirmation', () => {
cy.createUser({ name: 'Test User', email: 'test@example.com' });
cy.get('[data-testid="delete-button"]').first().click();
cy.get('[data-testid="confirm-dialog"]')
.should('be.visible')
.and('contain', 'Are you sure you want to delete this user?');
cy.get('[data-testid="confirm-delete-button"]').click();
cy.get('[data-testid="success-message"]')
.should('be.visible')
.and('contain', 'User deleted successfully');
cy.get('[data-testid="users-table"]')
.should('not.contain', 'Test User');
});
it('searches and filters users', () => {
cy.createUser({ name: 'John Doe', email: 'john@example.com' });
cy.createUser({ name: 'Jane Smith', email: 'jane@example.com' });
cy.get('[data-testid="search-input"]').type('John');
cy.get('[data-testid="search-button"]').click();
cy.get('[data-testid="users-table"]')
.should('contain', 'John Doe')
.and('not.contain', 'Jane Smith');
cy.get('[data-testid="role-filter"]').select('admin');
cy.get('[data-testid="users-table"]')
.should('not.contain', 'John Doe');
});
});
Testing Best Practices
Test Organization
// __tests__/components/
// __tests__/hooks/
// __tests__/services/
// __tests__/utils/
// __tests__/integration/
Test Naming Conventions
// Use descriptive test names
test('calculates total price including tax', () => {
// test implementation
});
// Avoid vague names
test('math works', () => {
// bad example
});
Test Data Management
// factories/userFactory.js
export const createUser = (overrides = {}) => ({
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'user',
...overrides
});
// Use in tests
import { createUser } from '@/factories/userFactory';
test('displays user information', () => {
const user = createUser({ name: 'Jane Smith' });
render(<UserProfile user={user} />);
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
Kết luận
Một testing strategy tốt nên:
- Start with unit tests cho business logic
- Add integration tests cho component interactions
- Include E2E tests cho critical user flows
- Maintain test quality với proper coverage và best practices
- Run tests continuously trong CI/CD pipeline
Bài viết này là phần đầu tiên trong series về testing. Trong các bài tiếp theo, chúng ta sẽ explore test automation và performance testing.