π Lesson 9.5: Integration and E2E Testing
Take your testing skills to the next level. Learn to test how multiple components work together, test routing and navigation flows, verify state management across components, and write end-to-end tests that simulate real user journeys through your entire application.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Understand the difference between unit, integration, and E2E tests
- Write integration tests for multiple connected components
- Test React Router navigation and routing logic
- Test global state management (Context, Redux, Zustand)
- Set up and write E2E tests with Playwright or Cypress
- Test complete user workflows from start to finish
- Implement CI/CD testing strategies
- Choose the right test type for different scenarios
Estimated Time: 90-120 minutes
Project: Add comprehensive integration and E2E tests to an e-commerce application
π In This Lesson
π Understanding Test Types
You've already learned about unit tests (testing individual functions) and basic component tests. Now it's time to level up and understand the full spectrum of testing approaches available to you.
π The Testing Spectrum
Testing exists on a spectrum from small, fast, isolated tests (unit tests) to large, slower, realistic tests (E2E tests). Each type serves a different purpose and provides different value.
Visualizing the Test Types
Many
Isolated] C --> E[Slow
Few
Realistic] B --> F[Balanced
Moderate
Connected] style A fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff style B fill:#339af0,stroke:#1971c2,stroke-width:2px,color:#fff style C fill:#f59f00,stroke:#e67700,stroke-width:2px,color:#fff style D fill:#e8f5e9 style E fill:#fff3e0 style F fill:#e3f2fd
Comparing Test Types
| Aspect | Unit Tests | Integration Tests | E2E Tests |
|---|---|---|---|
| Scope | Single function or component | Multiple components working together | Entire application from user perspective |
| Speed | Very fast (milliseconds) | Fast to moderate (seconds) | Slow (seconds to minutes) |
| Cost to maintain | Low | Moderate | High |
| Confidence level | Low to moderate | Moderate to high | Very high |
| What to test | Logic, calculations, edge cases | Component interactions, data flow | User workflows, critical paths |
| How many? | Many (70-80%) | Moderate (15-25%) | Few (5-10%) |
The Testing Pyramid
The testing pyramid is a classic model that helps you balance your testing strategy:
/\
/ \ E2E Tests (5-10%)
/____\ Few, slow, high confidence
/ \
/ \ Integration Tests (15-25%)
/__________\ Moderate number, moderate speed
/ \
/ \
/ \
/ \ Unit Tests (70-80%)
/____________________\ Many, fast, focused
π‘ Why This Balance?
The pyramid shape represents:
- Base (Unit Tests): Quick feedback during development, catch bugs early
- Middle (Integration Tests): Verify components work together correctly
- Top (E2E Tests): Ensure critical user paths work end-to-end
This balance gives you fast, reliable feedback while still maintaining confidence that your application works as a whole.
When to Use Each Test Type
β Use Unit Tests For:
- Pure functions and utility functions
- Complex calculations and algorithms
- Business logic
- Edge cases and error handling
- Custom hooks
π Use Integration Tests For:
- Parent and child component interactions
- Form submissions with multiple fields
- Routing and navigation flows
- State management across components
- API integration with multiple requests
π Use E2E Tests For:
- Critical user workflows (login, checkout, signup)
- Multi-page user journeys
- Authentication flows
- Payment processing
- Happy path scenarios
π¬ Testing Philosophy: "Write tests that give you confidence without becoming a burden. The goal isn't 100% coverageβit's having the right tests that catch real bugs and allow you to move fast."
π Integration Testing Basics
Integration tests verify that multiple pieces of your application work together correctly. Unlike unit tests that isolate components, integration tests let components interact naturally.
What Makes a Test an Integration Test?
An integration test typically involves:
- Multiple components: Parent and child components rendering together
- Real interactions: User events triggering state changes across components
- Data flow: Props, callbacks, and context passing data between components
- Side effects: API calls, routing, or state management updates
Simple Integration Test Example
Let's test a parent component that manages state for its children:
// ProductList.tsx
interface Product {
id: string;
name: string;
price: number;
}
function ProductList() {
const [products, setProducts] = React.useState<Product[]>([
{ id: '1', name: 'Laptop', price: 999 },
{ id: '2', name: 'Mouse', price: 29 }
]);
const [cart, setCart] = React.useState<string[]>([]);
const addToCart = (productId: string) => {
setCart([...cart, productId]);
};
return (
<div>
<h1>Products</h1>
{products.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={addToCart}
/>
))}
<CartSummary cart={cart} products={products} />
</div>
);
}
// ProductCard.tsx
function ProductCard({ product, onAddToCart }: {
product: Product;
onAddToCart: (id: string) => void;
}) {
return (
<div>
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => onAddToCart(product.id)}>
Add to Cart
</button>
</div>
);
}
// CartSummary.tsx
function CartSummary({ cart, products }: {
cart: string[];
products: Product[];
}) {
const total = cart.reduce((sum, id) => {
const product = products.find(p => p.id === id);
return sum + (product?.price || 0);
}, 0);
return (
<div>
<h2>Cart ({cart.length} items)</h2>
<p>Total: ${total}</p>
</div>
);
}
Integration Test for the Complete Flow
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { ProductList } from './ProductList';
describe('ProductList Integration', () => {
it('adds products to cart and updates total', async () => {
const user = userEvent.setup();
render(<ProductList />);
// Initial state
expect(screen.getByText('Cart (0 items)')).toBeInTheDocument();
expect(screen.getByText('Total: $0')).toBeInTheDocument();
// Find and click "Add to Cart" for Laptop
const laptopButtons = screen.getAllByText('Add to Cart');
await user.click(laptopButtons[0]);
// Cart updates
expect(screen.getByText('Cart (1 items)')).toBeInTheDocument();
expect(screen.getByText('Total: $999')).toBeInTheDocument();
// Add mouse
await user.click(laptopButtons[1]);
// Cart updates again
expect(screen.getByText('Cart (2 items)')).toBeInTheDocument();
expect(screen.getByText('Total: $1028')).toBeInTheDocument();
});
it('allows adding same product multiple times', async () => {
const user = userEvent.setup();
render(<ProductList />);
const laptopButton = screen.getAllByText('Add to Cart')[0];
// Add laptop twice
await user.click(laptopButton);
await user.click(laptopButton);
expect(screen.getByText('Cart (2 items)')).toBeInTheDocument();
expect(screen.getByText('Total: $1998')).toBeInTheDocument();
});
});
π‘ What Makes This Integration Testing?
Notice how we're testing:
- Multiple components: ProductList, ProductCard, and CartSummary work together
- Data flow: Callback from child to parent updates state
- State propagation: State changes in parent affect multiple children
- Calculations: Cart total calculated from multiple pieces of data
- User workflow: Complete "add to cart" flow from button click to total update
We're not testing components in isolationβwe're testing how they work together as a system.
Testing Complex Parent-Child Interactions
// SearchableList.tsx
function SearchableList() {
const [items] = React.useState([
'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'
]);
const [searchTerm, setSearchTerm] = React.useState('');
const [selectedItems, setSelectedItems] = React.useState<string[]>([]);
const filteredItems = items.filter(item =>
item.toLowerCase().includes(searchTerm.toLowerCase())
);
const toggleItem = (item: string) => {
setSelectedItems(prev =>
prev.includes(item)
? prev.filter(i => i !== item)
: [...prev, item]
);
};
return (
<div>
<SearchInput value={searchTerm} onChange={setSearchTerm} />
<ItemList
items={filteredItems}
selectedItems={selectedItems}
onToggle={toggleItem}
/>
<SelectionSummary selected={selectedItems} />
</div>
);
}
// Integration test
describe('SearchableList Integration', () => {
it('filters items and maintains selection', async () => {
const user = userEvent.setup();
render(<SearchableList />);
// Initially shows all items
expect(screen.getByText('Apple')).toBeInTheDocument();
expect(screen.getByText('Banana')).toBeInTheDocument();
// Select Apple
await user.click(screen.getByText('Apple'));
expect(screen.getByText('Selected: 1')).toBeInTheDocument();
// Filter for 'a'
await user.type(screen.getByRole('textbox'), 'a');
// Should show only items with 'a'
expect(screen.getByText('Apple')).toBeInTheDocument();
expect(screen.getByText('Banana')).toBeInTheDocument();
expect(screen.queryByText('Cherry')).not.toBeInTheDocument();
// Apple should still be selected after filtering
expect(screen.getByText('Selected: 1')).toBeInTheDocument();
// Select Banana
await user.click(screen.getByText('Banana'));
expect(screen.getByText('Selected: 2')).toBeInTheDocument();
// Clear filter
await user.clear(screen.getByRole('textbox'));
// All items visible, both still selected
expect(screen.getByText('Cherry')).toBeInTheDocument();
expect(screen.getByText('Selected: 2')).toBeInTheDocument();
});
});
Testing Form Integration
// RegistrationForm.tsx
function RegistrationForm() {
const [formData, setFormData] = React.useState({
username: '',
email: '',
password: '',
confirmPassword: ''
});
const [errors, setErrors] = React.useState<Record<string, string>>({});
const [submitted, setSubmitted] = React.useState(false);
const validate = () => {
const newErrors: Record<string, string> = {};
if (formData.username.length < 3) {
newErrors.username = 'Username must be at least 3 characters';
}
if (!formData.email.includes('@')) {
newErrors.email = 'Invalid email';
}
if (formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords must match';
}
return newErrors;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors = validate();
setErrors(newErrors);
if (Object.keys(newErrors).length === 0) {
setSubmitted(true);
}
};
if (submitted) {
return <div>Registration successful!</div>;
}
return (
<form onSubmit={handleSubmit}>
<InputField
label="Username"
value={formData.username}
onChange={value => setFormData({...formData, username: value})}
error={errors.username}
/>
<InputField
label="Email"
type="email"
value={formData.email}
onChange={value => setFormData({...formData, email: value})}
error={errors.email}
/>
<InputField
label="Password"
type="password"
value={formData.password}
onChange={value => setFormData({...formData, password: value})}
error={errors.password}
/>
<InputField
label="Confirm Password"
type="password"
value={formData.confirmPassword}
onChange={value => setFormData({...formData, confirmPassword: value})}
error={errors.confirmPassword}
/>
<button type="submit">Register</button>
</form>
);
}
// Integration test
describe('RegistrationForm Integration', () => {
it('validates entire form and shows all errors', async () => {
const user = userEvent.setup();
render(<RegistrationForm />);
// Submit without filling anything
await user.click(screen.getByText('Register'));
// All validation errors appear
expect(screen.getByText('Username must be at least 3 characters')).toBeInTheDocument();
expect(screen.getByText('Invalid email')).toBeInTheDocument();
expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument();
expect(screen.getByText('Passwords must match')).toBeInTheDocument();
});
it('clears errors as user fixes them', async () => {
const user = userEvent.setup();
render(<RegistrationForm />);
// Submit to trigger errors
await user.click(screen.getByText('Register'));
expect(screen.getByText('Username must be at least 3 characters')).toBeInTheDocument();
// Fix username
await user.type(screen.getByLabelText('Username'), 'john_doe');
await user.click(screen.getByText('Register'));
// Username error should be gone
expect(screen.queryByText('Username must be at least 3 characters')).not.toBeInTheDocument();
// But other errors remain
expect(screen.getByText('Invalid email')).toBeInTheDocument();
});
it('successfully submits with valid data', async () => {
const user = userEvent.setup();
render(<RegistrationForm />);
// Fill form correctly
await user.type(screen.getByLabelText('Username'), 'john_doe');
await user.type(screen.getByLabelText('Email'), 'john@example.com');
await user.type(screen.getByLabelText('Password'), 'SecurePass123');
await user.type(screen.getByLabelText('Confirm Password'), 'SecurePass123');
// Submit
await user.click(screen.getByText('Register'));
// Success message appears
expect(screen.getByText('Registration successful!')).toBeInTheDocument();
});
});
β Integration Testing Best Practices
- Test user workflows: Follow the steps a real user would take
- Test state changes: Verify state updates propagate correctly
- Test error states: Ensure error handling works across components
- Use realistic data: Don't oversimplify test data
- Test happy and sad paths: Both success and failure scenarios
- Don't mock too much: Let components interact naturally
π£οΈ Testing Routing and Navigation
React Router is a critical part of most React applications. Integration tests should verify that routing works correctly and that navigation flows make sense.
Setting Up Router for Tests
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
function renderWithRouter(
ui: React.ReactElement,
{ initialEntries = ['/'] } = {}
) {
return render(
<MemoryRouter initialEntries={initialEntries}>
{ui}
</MemoryRouter>
);
}
π‘ Why MemoryRouter?
MemoryRouter keeps routing history in memory instead of in the browser's address bar. This is perfect for tests because:
- No interaction with actual browser history
- Can set initial route with
initialEntries - Tests remain isolated and don't affect each other
- Faster than using BrowserRouter
Testing Basic Navigation
// App.tsx
function App() {
return (
<div>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/contact">Contact</Link>
</nav>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
</Routes>
</div>
);
}
// Test
describe('App Routing', () => {
it('navigates between pages', async () => {
const user = userEvent.setup();
renderWithRouter(<App />);
// Starts on home page
expect(screen.getByText('Welcome to Home')).toBeInTheDocument();
// Navigate to About
await user.click(screen.getByText('About'));
expect(screen.getByText('About Us')).toBeInTheDocument();
// Navigate to Contact
await user.click(screen.getByText('Contact'));
expect(screen.getByText('Contact Us')).toBeInTheDocument();
// Navigate back to Home
await user.click(screen.getByText('Home'));
expect(screen.getByText('Welcome to Home')).toBeInTheDocument();
});
it('renders correct page based on initial route', () => {
// Start directly on About page
renderWithRouter(<App />, { initialEntries: ['/about'] });
expect(screen.getByText('About Us')).toBeInTheDocument();
});
});
Testing Dynamic Routes
// BlogPost.tsx
function BlogPost() {
const { id } = useParams<{ id: string }>();
const [post, setPost] = React.useState<Post | null>(null);
React.useEffect(() => {
fetch(`/api/posts/${id}`)
.then(res => res.json())
.then(setPost);
}, [id]);
if (!post) return <div>Loading...</div>;
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
// Test
describe('BlogPost Routing', () => {
it('fetches and displays correct post based on route param', async () => {
server.use(
rest.get('/api/posts/:id', (req, res, ctx) => {
const { id } = req.params;
return res(
ctx.json({
id,
title: `Post ${id}`,
content: `Content for post ${id}`
})
);
})
);
renderWithRouter(
<Routes>
<Route path="/posts/:id" element={<BlogPost />} />
</Routes>,
{ initialEntries: ['/posts/123'] }
);
expect(await screen.findByText('Post 123')).toBeInTheDocument();
expect(screen.getByText('Content for post 123')).toBeInTheDocument();
});
});
Testing Programmatic Navigation
// LoginForm.tsx
function LoginForm() {
const navigate = useNavigate();
const [credentials, setCredentials] = React.useState({
username: '',
password: ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
});
if (response.ok) {
navigate('/dashboard');
}
};
return (
<form onSubmit={handleSubmit}>
<input
placeholder="Username"
value={credentials.username}
onChange={e => setCredentials({...credentials, username: e.target.value})}
/>
<input
type="password"
placeholder="Password"
value={credentials.password}
onChange={e => setCredentials({...credentials, password: e.target.value})}
/>
<button type="submit">Login</button>
</form>
);
}
// Test
describe('LoginForm Navigation', () => {
it('navigates to dashboard after successful login', async () => {
const user = userEvent.setup();
server.use(
rest.post('/api/login', (req, res, ctx) => {
return res(ctx.status(200));
})
);
renderWithRouter(
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route path="/dashboard" element={<div>Dashboard</div>} />
</Routes>,
{ initialEntries: ['/login'] }
);
// Fill and submit form
await user.type(screen.getByPlaceholderText('Username'), 'john');
await user.type(screen.getByPlaceholderText('Password'), 'password123');
await user.click(screen.getByText('Login'));
// Should navigate to dashboard
expect(await screen.findByText('Dashboard')).toBeInTheDocument();
});
it('stays on login page after failed login', async () => {
const user = userEvent.setup();
server.use(
rest.post('/api/login', (req, res, ctx) => {
return res(ctx.status(401));
})
);
renderWithRouter(
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route path="/dashboard" element={<div>Dashboard</div>} />
</Routes>,
{ initialEntries: ['/login'] }
);
await user.type(screen.getByPlaceholderText('Username'), 'john');
await user.type(screen.getByPlaceholderText('Password'), 'wrong');
await user.click(screen.getByText('Login'));
// Should still be on login page
await waitFor(() => {
expect(screen.getByText('Login')).toBeInTheDocument();
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
});
});
});
Testing Protected Routes
// ProtectedRoute.tsx
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// Test
describe('ProtectedRoute', () => {
it('redirects to login when not authenticated', () => {
renderWithRouter(
<AuthProvider value={{ isAuthenticated: false }}>
<Routes>
<Route path="/login" element={<div>Login Page</div>} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<div>Dashboard</div>
</ProtectedRoute>
}
/>
</Routes>
</AuthProvider>,
{ initialEntries: ['/dashboard'] }
);
// Should redirect to login
expect(screen.getByText('Login Page')).toBeInTheDocument();
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
});
it('renders protected content when authenticated', () => {
renderWithRouter(
<AuthProvider value={{ isAuthenticated: true }}>
<Routes>
<Route path="/login" element={<div>Login Page</div>} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<div>Dashboard</div>
</ProtectedRoute>
}
/>
</Routes>
</AuthProvider>,
{ initialEntries: ['/dashboard'] }
);
// Should show dashboard
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.queryByText('Login Page')).not.toBeInTheDocument();
});
});
Testing Search Parameters
// ProductSearch.tsx
function ProductSearch() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || '';
const category = searchParams.get('category') || 'all';
return (
<div>
<input
value={query}
onChange={e => setSearchParams({ q: e.target.value, category })}
placeholder="Search..."
/>
<select
value={category}
onChange={e => setSearchParams({ q: query, category: e.target.value })}
>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="books">Books</option>
</select>
<div>Query: {query}</div>
<div>Category: {category}</div>
</div>
);
}
// Test
describe('ProductSearch URL Parameters', () => {
it('reads initial search params from URL', () => {
renderWithRouter(
<Routes>
<Route path="/search" element={<ProductSearch />} />
</Routes>,
{ initialEntries: ['/search?q=laptop&category=electronics'] }
);
expect(screen.getByDisplayValue('laptop')).toBeInTheDocument();
expect(screen.getByDisplayValue('electronics')).toBeInTheDocument();
expect(screen.getByText('Query: laptop')).toBeInTheDocument();
expect(screen.getByText('Category: electronics')).toBeInTheDocument();
});
it('updates URL when search changes', async () => {
const user = userEvent.setup();
renderWithRouter(
<Routes>
<Route path="/search" element={<ProductSearch />} />
</Routes>,
{ initialEntries: ['/search'] }
);
// Type in search
await user.type(screen.getByPlaceholderText('Search...'), 'phone');
expect(screen.getByText('Query: phone')).toBeInTheDocument();
// Change category
await user.selectOptions(screen.getByRole('combobox'), 'books');
expect(screen.getByText('Category: books')).toBeInTheDocument();
});
});
β Routing Testing Tips
- Use MemoryRouter: Keeps tests isolated and fast
- Set initialEntries: Start tests on specific routes
- Test navigation: Verify users can move between pages
- Test params: Check dynamic routes load correct data
- Test redirects: Ensure protected routes redirect properly
- Test search params: Verify URL state management works
ποΈ Testing State Management
Global state management is a crucial part of modern React apps. Whether you're using Context, Redux, or Zustand, you need to test that state updates correctly across components.
Testing Context-Based State
// CartContext.tsx
interface CartItem {
id: string;
name: string;
quantity: number;
}
interface CartContextType {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
}
const CartContext = React.createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = React.useState<CartItem[]>([]);
const addItem = (item: CartItem) => {
setItems(prev => {
const existing = prev.find(i => i.id === item.id);
if (existing) {
return prev.map(i =>
i.id === item.id
? { ...i, quantity: i.quantity + item.quantity }
: i
);
}
return [...prev, item];
});
};
const removeItem = (id: string) => {
setItems(prev => prev.filter(i => i.id !== id));
};
const clearCart = () => {
setItems([]);
};
return (
<CartContext.Provider value={{ items, addItem, removeItem, clearCart }}>
{children}
</CartContext.Provider>
);
}
export const useCart = () => {
const context = React.useContext(CartContext);
if (!context) throw new Error('useCart must be used within CartProvider');
return context;
};
// Components using the context
function AddToCartButton({ product }: { product: { id: string; name: string } }) {
const { addItem } = useCart();
return (
<button onClick={() => addItem({ ...product, quantity: 1 })}>
Add {product.name} to Cart
</button>
);
}
function CartDisplay() {
const { items, removeItem, clearCart } = useCart();
return (
<div>
<h2>Cart ({items.length} items)</h2>
{items.map(item => (
<div key={item.id}>
{item.name} (x{item.quantity})
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
))}
{items.length > 0 && (
<button onClick={clearCart}>Clear Cart</button>
)}
</div>
);
}
Integration Test for Context State
describe('Cart Context Integration', () => {
function renderWithCart(ui: React.ReactElement) {
return render(
<CartProvider>
{ui}
</CartProvider>
);
}
it('adds items to cart from multiple components', async () => {
const user = userEvent.setup();
renderWithCart(
<>
<AddToCartButton product={{ id: '1', name: 'Laptop' }} />
<AddToCartButton product={{ id: '2', name: 'Mouse' }} />
<CartDisplay />
</>
);
// Initially empty
expect(screen.getByText('Cart (0 items)')).toBeInTheDocument();
// Add laptop
await user.click(screen.getByText('Add Laptop to Cart'));
expect(screen.getByText('Cart (1 items)')).toBeInTheDocument();
expect(screen.getByText('Laptop (x1)')).toBeInTheDocument();
// Add mouse
await user.click(screen.getByText('Add Mouse to Cart'));
expect(screen.getByText('Cart (2 items)')).toBeInTheDocument();
expect(screen.getByText('Mouse (x1)')).toBeInTheDocument();
// Add laptop again (should increase quantity)
await user.click(screen.getByText('Add Laptop to Cart'));
expect(screen.getByText('Cart (2 items)')).toBeInTheDocument();
expect(screen.getByText('Laptop (x2)')).toBeInTheDocument();
});
it('removes items from cart', async () => {
const user = userEvent.setup();
renderWithCart(
<>
<AddToCartButton product={{ id: '1', name: 'Laptop' }} />
<AddToCartButton product={{ id: '2', name: 'Mouse' }} />
<CartDisplay />
</>
);
// Add items
await user.click(screen.getByText('Add Laptop to Cart'));
await user.click(screen.getByText('Add Mouse to Cart'));
expect(screen.getByText('Cart (2 items)')).toBeInTheDocument();
// Remove laptop
const removeButtons = screen.getAllByText('Remove');
await user.click(removeButtons[0]);
expect(screen.getByText('Cart (1 items)')).toBeInTheDocument();
expect(screen.queryByText('Laptop (x1)')).not.toBeInTheDocument();
expect(screen.getByText('Mouse (x1)')).toBeInTheDocument();
});
it('clears entire cart', async () => {
const user = userEvent.setup();
renderWithCart(
<>
<AddToCartButton product={{ id: '1', name: 'Laptop' }} />
<AddToCartButton product={{ id: '2', name: 'Mouse' }} />
<CartDisplay />
</>
);
// Add items
await user.click(screen.getByText('Add Laptop to Cart'));
await user.click(screen.getByText('Add Mouse to Cart'));
expect(screen.getByText('Cart (2 items)')).toBeInTheDocument();
// Clear cart
await user.click(screen.getByText('Clear Cart'));
expect(screen.getByText('Cart (0 items)')).toBeInTheDocument();
expect(screen.queryByText('Laptop (x1)')).not.toBeInTheDocument();
expect(screen.queryByText('Mouse (x1)')).not.toBeInTheDocument();
});
});
Testing Redux/Redux Toolkit
// store.ts
import { configureStore, createSlice } from '@reduxjs/toolkit';
interface Todo {
id: string;
text: string;
completed: boolean;
}
const todosSlice = createSlice({
name: 'todos',
initialState: [] as Todo[],
reducers: {
addTodo: (state, action) => {
state.push({
id: Date.now().toString(),
text: action.payload,
completed: false
});
},
toggleTodo: (state, action) => {
const todo = state.find(t => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
deleteTodo: (state, action) => {
return state.filter(t => t.id !== action.payload);
}
}
});
export const { addTodo, toggleTodo, deleteTodo } = todosSlice.actions;
export function createTestStore() {
return configureStore({
reducer: {
todos: todosSlice.reducer
}
});
}
// Test helper
import { Provider } from 'react-redux';
function renderWithRedux(
ui: React.ReactElement,
{ store = createTestStore() } = {}
) {
return {
...render(<Provider store={store}>{ui}</Provider>),
store
};
}
// TodoList component
function TodoList() {
const dispatch = useDispatch();
const todos = useSelector((state: RootState) => state.todos);
const [input, setInput] = React.useState('');
const handleAdd = () => {
if (input.trim()) {
dispatch(addTodo(input));
setInput('');
}
};
return (
<div>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Add todo..."
/>
<button onClick={handleAdd}>Add</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch(toggleTodo(todo.id))}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => dispatch(deleteTodo(todo.id))}>Delete</button>
</li>
))}
</ul>
</div>
);
}
// Test
describe('Redux TodoList Integration', () => {
it('adds and manages todos', async () => {
const user = userEvent.setup();
renderWithRedux(<TodoList />);
// Add first todo
await user.type(screen.getByPlaceholderText('Add todo...'), 'Buy milk');
await user.click(screen.getByText('Add'));
expect(screen.getByText('Buy milk')).toBeInTheDocument();
// Add second todo
await user.type(screen.getByPlaceholderText('Add todo...'), 'Walk dog');
await user.click(screen.getByText('Add'));
expect(screen.getByText('Walk dog')).toBeInTheDocument();
// Toggle first todo
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[0]);
const buyMilkText = screen.getByText('Buy milk');
expect(buyMilkText).toHaveStyle({ textDecoration: 'line-through' });
// Delete second todo
const deleteButtons = screen.getAllByText('Delete');
await user.click(deleteButtons[1]);
expect(screen.queryByText('Walk dog')).not.toBeInTheDocument();
expect(screen.getByText('Buy milk')).toBeInTheDocument();
});
});
Testing Zustand Store
// store.ts
import { create } from 'zustand';
interface CounterStore {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 })
}));
// Counter component
function Counter() {
const { count, increment, decrement, reset } = useCounterStore();
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
}
// DisplayCount component (separate component using same store)
function DisplayCount() {
const count = useCounterStore(state => state.count);
return <div>Display: {count}</div>;
}
// Test
describe('Zustand Counter Integration', () => {
beforeEach(() => {
// Reset store before each test
useCounterStore.setState({ count: 0 });
});
it('updates count across multiple components', async () => {
const user = userEvent.setup();
render(
<>
<Counter />
<DisplayCount />
</>
);
// Initial state
expect(screen.getByText('Count: 0')).toBeInTheDocument();
expect(screen.getByText('Display: 0')).toBeInTheDocument();
// Increment
await user.click(screen.getByText('+'));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
expect(screen.getByText('Display: 1')).toBeInTheDocument();
// Increment again
await user.click(screen.getByText('+'));
expect(screen.getByText('Count: 2')).toBeInTheDocument();
expect(screen.getByText('Display: 2')).toBeInTheDocument();
// Decrement
await user.click(screen.getByText('-'));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
expect(screen.getByText('Display: 1')).toBeInTheDocument();
// Reset
await user.click(screen.getByText('Reset'));
expect(screen.getByText('Count: 0')).toBeInTheDocument();
expect(screen.getByText('Display: 0')).toBeInTheDocument();
});
});
π‘ State Management Testing Tips
- Test state updates: Verify state changes propagate to all consumers
- Test multiple components: Ensure different components see same state
- Reset state: Clean slate for each test (especially Zustand)
- Test actions/reducers: Verify state logic works correctly
- Use test stores: Create fresh stores for Redux tests
- Test selectors: Ensure derived state computes correctly
π§© Testing Component Integration
Real applications have complex component hierarchies with data flowing in multiple directions. Integration tests should verify these interactions work correctly.
Testing Parent-Multiple Children Flow
// Dashboard.tsx
function Dashboard() {
const [user, setUser] = React.useState<User | null>(null);
const [notifications, setNotifications] = React.useState<Notification[]>([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/notifications').then(r => r.json())
])
.then(([userData, notificationData]) => {
setUser(userData);
setNotifications(notificationData);
setLoading(false);
});
}, []);
const markAsRead = (id: string) => {
fetch(`/api/notifications/${id}/read`, { method: 'POST' })
.then(() => {
setNotifications(prev =>
prev.map(n => n.id === id ? { ...n, read: true } : n)
);
});
};
if (loading) return <div>Loading dashboard...</div>;
return (
<div>
<UserProfile user={user} />
<NotificationList
notifications={notifications}
onMarkAsRead={markAsRead}
/>
<ActivityFeed userId={user?.id} />
</div>
);
}
// Test
describe('Dashboard Integration', () => {
it('loads and displays all dashboard components', async () => {
server.use(
rest.get('/api/user', (req, res, ctx) => {
return res(
ctx.json({
id: '123',
name: 'John Doe',
email: 'john@example.com'
})
);
}),
rest.get('/api/notifications', (req, res, ctx) => {
return res(
ctx.json([
{ id: '1', message: 'New message', read: false },
{ id: '2', message: 'Update available', read: true }
])
);
}),
rest.get('/api/users/123/activity', (req, res, ctx) => {
return res(
ctx.json([
{ id: '1', action: 'Posted comment' }
])
);
})
);
render(<Dashboard />);
// Shows loading
expect(screen.getByText('Loading dashboard...')).toBeInTheDocument();
// All components load
expect(await screen.findByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
expect(screen.getByText('New message')).toBeInTheDocument();
expect(screen.getByText('Update available')).toBeInTheDocument();
expect(screen.getByText('Posted comment')).toBeInTheDocument();
});
it('marks notification as read and updates UI', async () => {
const user = userEvent.setup();
server.use(
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.json({ id: '123', name: 'John' }));
}),
rest.get('/api/notifications', (req, res, ctx) => {
return res(
ctx.json([
{ id: '1', message: 'New message', read: false }
])
);
}),
rest.post('/api/notifications/:id/read', (req, res, ctx) => {
return res(ctx.status(200));
})
);
render(<Dashboard />);
// Wait for notification to appear
const notification = await screen.findByText('New message');
expect(notification).toHaveClass('unread'); // Assuming this class exists
// Mark as read
await user.click(screen.getByText('Mark as Read'));
// UI updates
await waitFor(() => {
expect(notification).toHaveClass('read');
});
});
});
Testing Complex Data Flow
// FilterableProductList.tsx
function FilterableProductList() {
const [products, setProducts] = React.useState<Product[]>([]);
const [filters, setFilters] = React.useState({
category: 'all',
minPrice: 0,
maxPrice: 1000,
inStock: false
});
const [sortBy, setSortBy] = React.useState('name');
React.useEffect(() => {
fetch('/api/products')
.then(r => r.json())
.then(setProducts);
}, []);
const filteredProducts = products
.filter(p => {
if (filters.category !== 'all' && p.category !== filters.category) {
return false;
}
if (p.price < filters.minPrice || p.price > filters.maxPrice) {
return false;
}
if (filters.inStock && !p.inStock) {
return false;
}
return true;
})
.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
if (sortBy === 'price') return a.price - b.price;
return 0;
});
return (
<div>
<FilterPanel filters={filters} onFiltersChange={setFilters} />
<SortControls sortBy={sortBy} onSortChange={setSortBy} />
<ProductGrid products={filteredProducts} />
<ResultCount count={filteredProducts.length} total={products.length} />
</div>
);
}
// Test
describe('FilterableProductList Integration', () => {
it('filters and sorts products correctly', async () => {
const user = userEvent.setup();
server.use(
rest.get('/api/products', (req, res, ctx) => {
return res(
ctx.json([
{ id: '1', name: 'Laptop', price: 999, category: 'electronics', inStock: true },
{ id: '2', name: 'Book', price: 20, category: 'books', inStock: true },
{ id: '3', name: 'Phone', price: 699, category: 'electronics', inStock: false },
{ id: '4', name: 'Desk', price: 300, category: 'furniture', inStock: true }
])
);
})
);
render(<FilterableProductList />);
// Wait for products to load
await screen.findByText('Laptop');
expect(screen.getByText('Showing 4 of 4 products')).toBeInTheDocument();
// Filter by category
await user.selectOptions(screen.getByLabelText('Category'), 'electronics');
expect(screen.getByText('Showing 2 of 4 products')).toBeInTheDocument();
expect(screen.getByText('Laptop')).toBeInTheDocument();
expect(screen.getByText('Phone')).toBeInTheDocument();
expect(screen.queryByText('Book')).not.toBeInTheDocument();
// Filter by in stock
await user.click(screen.getByLabelText('In Stock Only'));
expect(screen.getByText('Showing 1 of 4 products')).toBeInTheDocument();
expect(screen.getByText('Laptop')).toBeInTheDocument();
expect(screen.queryByText('Phone')).not.toBeInTheDocument();
// Change sort
await user.selectOptions(screen.getByLabelText('Sort by'), 'price');
// Products should be sorted (verify order if needed)
const products = screen.getAllByRole('article');
expect(products[0]).toHaveTextContent('Laptop');
});
it('clears filters and shows all products', async () => {
const user = userEvent.setup();
// ... setup
render(<FilterableProductList />);
await screen.findByText('Laptop');
// Apply filters
await user.selectOptions(screen.getByLabelText('Category'), 'electronics');
expect(screen.getByText('Showing 2 of 4 products')).toBeInTheDocument();
// Clear filters
await user.click(screen.getByText('Clear Filters'));
expect(screen.getByText('Showing 4 of 4 products')).toBeInTheDocument();
});
});
Testing Modal and Dialog Interactions
// UserSettings.tsx
function UserSettings() {
const [user, setUser] = React.useState({ name: 'John', email: 'john@example.com' });
const [isEditing, setIsEditing] = React.useState(false);
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const handleSave = (newData: typeof user) => {
fetch('/api/user', {
method: 'PUT',
body: JSON.stringify(newData)
})
.then(r => r.json())
.then(data => {
setUser(data);
setIsEditing(false);
});
};
const handleDelete = () => {
fetch('/api/user', { method: 'DELETE' })
.then(() => {
// Redirect or show success
});
};
return (
<div>
<h1>User Settings</h1>
{!isEditing ? (
<div>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
<button onClick={() => setIsEditing(true)}>Edit</button>
<button onClick={() => setShowDeleteModal(true)}>Delete Account</button>
</div>
) : (
<EditForm
user={user}
onSave={handleSave}
onCancel={() => setIsEditing(false)}
/>
)}
{showDeleteModal && (
<ConfirmDialog
title="Delete Account"
message="Are you sure? This cannot be undone."
onConfirm={handleDelete}
onCancel={() => setShowDeleteModal(false)}
/>
)}
</div>
);
}
// Test
describe('UserSettings Integration', () => {
it('edits user information', async () => {
const user = userEvent.setup();
server.use(
rest.put('/api/user', async (req, res, ctx) => {
const body = await req.json();
return res(ctx.json(body));
})
);
render(<UserSettings />);
// Initial view
expect(screen.getByText('Name: John')).toBeInTheDocument();
// Enter edit mode
await user.click(screen.getByText('Edit'));
// Form appears
expect(screen.getByLabelText('Name')).toBeInTheDocument();
// Change name
const nameInput = screen.getByLabelText('Name');
await user.clear(nameInput);
await user.type(nameInput, 'Jane Doe');
// Save
await user.click(screen.getByText('Save'));
// Returns to view mode with updated data
expect(await screen.findByText('Name: Jane Doe')).toBeInTheDocument();
expect(screen.queryByLabelText('Name')).not.toBeInTheDocument();
});
it('shows confirmation before deleting account', async () => {
const user = userEvent.setup();
server.use(
rest.delete('/api/user', (req, res, ctx) => {
return res(ctx.status(200));
})
);
render(<UserSettings />);
// Click delete
await user.click(screen.getByText('Delete Account'));
// Modal appears
expect(screen.getByText('Are you sure? This cannot be undone.')).toBeInTheDocument();
// Cancel
await user.click(screen.getByText('Cancel'));
// Modal closes, still on settings
expect(screen.queryByText('Are you sure?')).not.toBeInTheDocument();
expect(screen.getByText('Name: John')).toBeInTheDocument();
// Try again and confirm
await user.click(screen.getByText('Delete Account'));
await user.click(screen.getByText('Confirm'));
// Account deleted (verify redirect or message)
});
});
β Component Integration Testing Tips
- Test complete flows: From user action to final UI update
- Test data propagation: Verify data flows through component tree
- Test conditional rendering: Different UI states based on data
- Test error boundaries: Error handling across components
- Test loading states: Multiple components loading simultaneously
- Test user workflows: Multi-step processes across components
π Introduction to E2E Testing
End-to-End (E2E) testing is the highest level of testing. Unlike unit and integration tests that run in Node.js, E2E tests run in a real browser and test your entire application from the user's perspective.
π What is E2E Testing?
End-to-End testing simulates real user scenarios by automating browser interactions. Tests click buttons, fill forms, navigate pages, and verify the application behaves correctlyβjust like a real user would.
Why E2E Tests Matter
E2E tests provide the highest confidence that your application works because they test:
- Real browser environment: Actual rendering, JavaScript execution, CSS layout
- Complete workflows: Multi-page journeys from start to finish
- Network interactions: Real API calls (or realistic mocks)
- Browser APIs: localStorage, cookies, navigation, history
- User experience: Visual layout, responsive design, animations
E2E vs Integration Tests
| Aspect | Integration Tests | E2E Tests |
|---|---|---|
| Environment | jsdom (simulated browser) | Real browser (Chrome, Firefox, etc.) |
| Network | Mocked with MSW | Real or mocked at network level |
| Routing | MemoryRouter | Real browser routing |
| CSS/Layout | Not tested | Fully tested |
| Speed | Fast (1-2 seconds) | Slower (5-30 seconds) |
| Use case | Feature-level testing | Critical user workflows |
Popular E2E Testing Tools
Multiple Browsers
TypeScript First] C --> F[Developer Friendly
Great DX
Time Travel Debug] D --> G[Industry Standard
Wide Support
Older Tech] style B fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff style C fill:#339af0,stroke:#1971c2,stroke-width:2px,color:#fff style D fill:#868e96,stroke:#495057,stroke-width:2px,color:#fff
Choosing Between Playwright and Cypress
| Feature | Playwright | Cypress |
|---|---|---|
| Browser Support | Chrome, Firefox, Safari, Edge | Chrome, Firefox, Edge (Safari experimental) |
| Speed | Very fast, parallel execution | Fast, but slower than Playwright |
| TypeScript | Excellent, built-in | Good, needs setup |
| API Testing | Native support | Via plugins |
| Learning Curve | Moderate | Easy |
| Debugging | Good (trace viewer) | Excellent (time travel) |
| Mobile Testing | Device emulation | Viewport only |
π‘ Which Should You Choose?
Choose Playwright if:
- You need to test across multiple browsers
- You want the fastest test execution
- You need mobile device testing
- You're starting a new project
Choose Cypress if:
- You value developer experience and debugging tools
- You want the easiest learning curve
- You're already using Cypress
- Chrome/Firefox support is sufficient
What to Test with E2E
E2E tests are expensive, so focus on:
β Critical User Paths
- Authentication: Login, signup, logout, password reset
- Core features: The main value proposition of your app
- Payment flows: Checkout, subscriptions, billing
- Data creation: Creating posts, products, profiles
- Happy paths: The most common user journeys
β Don't Test with E2E
- Edge cases (use unit tests)
- Error handling (use integration tests)
- Complex calculations (use unit tests)
- Every possible path (too expensive)
- UI variations (use visual regression tests)
π¬ E2E Testing Philosophy: "Test the critical paths that, if broken, would prevent users from getting value from your application. Everything else should be covered by faster, cheaper tests."
π Playwright Setup and Basics
Playwright is a modern E2E testing framework created by Microsoft. It's fast, reliable, and has excellent TypeScript support out of the box.
Installing Playwright
# Install Playwright
npm init playwright@latest
# Or with existing project
npm install -D @playwright/test
# Install browsers
npx playwright install
Project Structure
my-app/
βββ e2e/
β βββ auth.spec.ts
β βββ shopping.spec.ts
β βββ profile.spec.ts
βββ playwright.config.ts
βββ package.json
Basic Playwright Configuration
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
// Run tests in parallel
fullyParallel: true,
// Fail the build on CI if you accidentally left test.only
forbidOnly: !!process.env.CI,
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Reporter
reporter: 'html',
use: {
// Base URL for your app
baseURL: 'http://localhost:5173',
// Collect trace on failure
trace: 'on-first-retry',
// Screenshot on failure
screenshot: 'only-on-failure',
},
// Configure projects for different browsers
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
// Start dev server before tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
Your First Playwright Test
// e2e/example.spec.ts
import { test, expect } from '@playwright/test';
test('homepage has title and heading', async ({ page }) => {
// Navigate to the page
await page.goto('/');
// Check page title
await expect(page).toHaveTitle(/My App/);
// Check for heading
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
Basic Playwright Actions
test('user can interact with the page', async ({ page }) => {
await page.goto('/');
// Click a button
await page.getByRole('button', { name: 'Click me' }).click();
// Fill an input
await page.getByLabel('Email').fill('user@example.com');
// Select from dropdown
await page.getByLabel('Country').selectOption('US');
// Check a checkbox
await page.getByLabel('I agree').check();
// Wait for element to appear
await page.getByText('Success!').waitFor();
// Check element is visible
await expect(page.getByText('Welcome!')).toBeVisible();
// Check input value
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
});
Playwright Locators
Playwright has powerful locator strategies that prioritize accessibility:
// By role (BEST - most accessible)
page.getByRole('button', { name: 'Submit' })
page.getByRole('textbox', { name: 'Username' })
page.getByRole('link', { name: 'Contact Us' })
// By label (GOOD - accessible)
page.getByLabel('Email address')
// By placeholder
page.getByPlaceholder('Enter your email')
// By text
page.getByText('Welcome back')
// By test ID (use when others don't work)
page.getByTestId('submit-button')
// By CSS selector (AVOID - brittle)
page.locator('.submit-btn')
Testing a Login Flow
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('user can login successfully', async ({ page }) => {
// Navigate to login page
await page.goto('/login');
// Fill in credentials
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('SecurePass123');
// Submit form
await page.getByRole('button', { name: 'Login' }).click();
// Wait for navigation to dashboard
await page.waitForURL('/dashboard');
// Verify we're logged in
await expect(page.getByText('Welcome back, User!')).toBeVisible();
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
});
test('shows error with invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrongpass');
await page.getByRole('button', { name: 'Login' }).click();
// Should stay on login page
await expect(page).toHaveURL('/login');
// Should show error message
await expect(page.getByText('Invalid email or password')).toBeVisible();
});
test('validates required fields', async ({ page }) => {
await page.goto('/login');
// Try to submit without filling fields
await page.getByRole('button', { name: 'Login' }).click();
// Should show validation errors
await expect(page.getByText('Email is required')).toBeVisible();
await expect(page.getByText('Password is required')).toBeVisible();
});
});
Testing Multi-Step Forms
test('completes multi-step registration', async ({ page }) => {
await page.goto('/register');
// Step 1: Personal info
await page.getByLabel('First Name').fill('John');
await page.getByLabel('Last Name').fill('Doe');
await page.getByLabel('Email').fill('john@example.com');
await page.getByRole('button', { name: 'Next' }).click();
// Wait for step 2
await expect(page.getByText('Step 2: Account Details')).toBeVisible();
// Step 2: Account details
await page.getByLabel('Username').fill('johndoe');
await page.getByLabel('Password').fill('SecurePass123');
await page.getByLabel('Confirm Password').fill('SecurePass123');
await page.getByRole('button', { name: 'Next' }).click();
// Wait for step 3
await expect(page.getByText('Step 3: Preferences')).toBeVisible();
// Step 3: Preferences
await page.getByLabel('Newsletter').check();
await page.getByLabel('Language').selectOption('en');
await page.getByRole('button', { name: 'Complete Registration' }).click();
// Success!
await page.waitForURL('/dashboard');
await expect(page.getByText('Registration successful!')).toBeVisible();
});
Testing Navigation
test('navigates through the app', async ({ page }) => {
await page.goto('/');
// Check we're on home page
await expect(page).toHaveURL('/');
await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();
// Navigate to products
await page.getByRole('link', { name: 'Products' }).click();
await expect(page).toHaveURL('/products');
await expect(page.getByRole('heading', { name: 'Our Products' })).toBeVisible();
// Navigate to specific product
await page.getByRole('link', { name: 'Laptop' }).click();
await expect(page).toHaveURL(/\/products\/\d+/);
await expect(page.getByRole('heading', { name: 'Laptop' })).toBeVisible();
// Use browser back button
await page.goBack();
await expect(page).toHaveURL('/products');
// Use browser forward button
await page.goForward();
await expect(page).toHaveURL(/\/products\/\d+/);
});
Working with API Requests
test('mocks API responses', async ({ page }) => {
// Mock the API call
await page.route('/api/products', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Laptop', price: 999 },
{ id: 2, name: 'Mouse', price: 29 }
])
});
});
await page.goto('/products');
// Products should be displayed from mock data
await expect(page.getByText('Laptop')).toBeVisible();
await expect(page.getByText('$999')).toBeVisible();
});
test('waits for API response', async ({ page }) => {
await page.goto('/products');
// Wait for API call to complete
const response = await page.waitForResponse('/api/products');
expect(response.status()).toBe(200);
// Products should be loaded
await expect(page.getByRole('article').first()).toBeVisible();
});
β Playwright Best Practices
- Use role-based selectors: More accessible and stable
- Wait automatically: Playwright auto-waits, don't add manual waits
- Use page.goto() carefully: Only navigate when needed
- Mock external APIs: Make tests deterministic
- Use test.describe(): Group related tests
- Take screenshots: On failure for debugging
π² Cypress Introduction
Cypress is another popular E2E testing framework known for its excellent developer experience and powerful debugging capabilities.
Installing Cypress
# Install Cypress
npm install -D cypress
# Open Cypress for first time
npx cypress open
Project Structure
my-app/
βββ cypress/
β βββ e2e/
β β βββ auth.cy.ts
β β βββ shopping.cy.ts
β βββ fixtures/
β β βββ users.json
β βββ support/
β β βββ commands.ts
β β βββ e2e.ts
βββ cypress.config.ts
βββ package.json
Basic Cypress Configuration
// cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:5173',
viewportWidth: 1280,
viewportHeight: 720,
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
});
Your First Cypress Test
// cypress/e2e/example.cy.ts
describe('Homepage', () => {
it('displays welcome message', () => {
cy.visit('/');
cy.contains('h1', 'Welcome').should('be.visible');
});
});
Basic Cypress Commands
describe('User interactions', () => {
it('can interact with elements', () => {
cy.visit('/');
// Click a button
cy.contains('button', 'Click me').click();
// Fill an input
cy.get('input[name="email"]').type('user@example.com');
// Select from dropdown
cy.get('select[name="country"]').select('US');
// Check a checkbox
cy.get('input[type="checkbox"]').check();
// Assert element is visible
cy.contains('Success!').should('be.visible');
// Assert input value
cy.get('input[name="email"]').should('have.value', 'user@example.com');
});
});
Cypress Selectors
// By text content
cy.contains('Login')
cy.contains('button', 'Submit')
// By CSS selector
cy.get('.submit-button')
cy.get('#email-input')
// By attribute
cy.get('[data-cy="submit"]')
cy.get('[type="email"]')
// By multiple criteria
cy.get('button').contains('Submit')
Testing a Login Flow with Cypress
// cypress/e2e/auth.cy.ts
describe('Authentication', () => {
beforeEach(() => {
cy.visit('/login');
});
it('logs in successfully', () => {
cy.get('[data-cy="email"]').type('user@example.com');
cy.get('[data-cy="password"]').type('SecurePass123');
cy.contains('button', 'Login').click();
// Should redirect to dashboard
cy.url().should('include', '/dashboard');
cy.contains('Welcome back').should('be.visible');
});
it('shows error with invalid credentials', () => {
cy.get('[data-cy="email"]').type('wrong@example.com');
cy.get('[data-cy="password"]').type('wrongpass');
cy.contains('button', 'Login').click();
// Should stay on login
cy.url().should('include', '/login');
cy.contains('Invalid credentials').should('be.visible');
});
});
Custom Cypress Commands
// cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>
logout(): Chainable<void>
}
}
}
Cypress.Commands.add('login', (email: string, password: string) => {
cy.visit('/login');
cy.get('[data-cy="email"]').type(email);
cy.get('[data-cy="password"]').type(password);
cy.contains('button', 'Login').click();
cy.url().should('include', '/dashboard');
});
Cypress.Commands.add('logout', () => {
cy.contains('button', 'Logout').click();
cy.url().should('include', '/login');
});
// Use in tests
describe('Protected pages', () => {
it('requires authentication', () => {
cy.login('user@example.com', 'SecurePass123');
// Now logged in, can access protected page
cy.visit('/profile');
cy.contains('My Profile').should('be.visible');
cy.logout();
});
});
Working with API in Cypress
describe('Products', () => {
it('loads products from API', () => {
// Intercept API call
cy.intercept('GET', '/api/products', {
statusCode: 200,
body: [
{ id: 1, name: 'Laptop', price: 999 },
{ id: 2, name: 'Mouse', price: 29 }
]
}).as('getProducts');
cy.visit('/products');
// Wait for API call
cy.wait('@getProducts');
// Products should be displayed
cy.contains('Laptop').should('be.visible');
cy.contains('$999').should('be.visible');
});
it('adds product to cart', () => {
cy.intercept('POST', '/api/cart', {
statusCode: 201,
body: { success: true }
}).as('addToCart');
cy.visit('/products/1');
cy.contains('button', 'Add to Cart').click();
cy.wait('@addToCart');
cy.contains('Added to cart').should('be.visible');
});
});
π‘ Playwright vs Cypress: Quick Comparison
| Feature | Playwright | Cypress |
|---|---|---|
| Syntax | await page.click() |
cy.get().click() |
| Auto-wait | Built-in | Built-in |
| Debugging | Trace viewer | Time travel, better DX |
| Speed | Faster | Slower |
π― E2E Testing Patterns
Let's explore common patterns and best practices for writing maintainable E2E tests.
Page Object Model (POM)
The Page Object Model organizes selectors and actions into reusable classes:
// pages/LoginPage.ts
import { Page } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
// Selectors
get emailInput() {
return this.page.getByLabel('Email');
}
get passwordInput() {
return this.page.getByLabel('Password');
}
get submitButton() {
return this.page.getByRole('button', { name: 'Login' });
}
get errorMessage() {
return this.page.getByRole('alert');
}
// Actions
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async getErrorText() {
return await this.errorMessage.textContent();
}
}
// Use in test
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('user can login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'SecurePass123');
await expect(page).toHaveURL('/dashboard');
});
Test Fixtures for Setup
// fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
type AuthFixtures = {
authenticatedPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'SecurePass123');
await use(page);
},
});
// Use in test
test('authenticated user can access profile', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/profile');
await expect(authenticatedPage.getByText('My Profile')).toBeVisible();
});
Testing Complete User Journeys
test('complete shopping journey', async ({ page }) => {
// 1. Browse products
await page.goto('/products');
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
// 2. Search for product
await page.getByPlaceholder('Search products').fill('laptop');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText('Gaming Laptop')).toBeVisible();
// 3. View product details
await page.getByText('Gaming Laptop').click();
await expect(page).toHaveURL(/\/products\/\d+/);
await expect(page.getByRole('heading', { name: 'Gaming Laptop' })).toBeVisible();
// 4. Add to cart
await page.getByRole('button', { name: 'Add to Cart' }).click();
await expect(page.getByText('Added to cart')).toBeVisible();
// 5. Go to cart
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page).toHaveURL('/cart');
await expect(page.getByText('Gaming Laptop')).toBeVisible();
// 6. Proceed to checkout
await page.getByRole('button', { name: 'Checkout' }).click();
await expect(page).toHaveURL('/checkout');
// 7. Fill shipping info
await page.getByLabel('Full Name').fill('John Doe');
await page.getByLabel('Address').fill('123 Main St');
await page.getByLabel('City').fill('New York');
await page.getByLabel('Zip Code').fill('10001');
// 8. Fill payment info
await page.getByLabel('Card Number').fill('4111111111111111');
await page.getByLabel('Expiry').fill('12/25');
await page.getByLabel('CVV').fill('123');
// 9. Complete order
await page.getByRole('button', { name: 'Place Order' }).click();
// 10. Verify success
await expect(page).toHaveURL(/\/orders\/\d+/);
await expect(page.getByText('Order confirmed!')).toBeVisible();
await expect(page.getByText('Order #')).toBeVisible();
});
Testing Responsive Design
test.describe('Responsive navigation', () => {
test('shows desktop menu on large screen', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto('/');
// Desktop menu visible
await expect(page.getByRole('navigation')).toBeVisible();
await expect(page.getByRole('link', { name: 'Products' })).toBeVisible();
// Mobile menu not visible
await expect(page.getByRole('button', { name: 'Menu' })).not.toBeVisible();
});
test('shows mobile menu on small screen', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
// Mobile menu button visible
await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
// Menu items hidden initially
await expect(page.getByRole('link', { name: 'Products' })).not.toBeVisible();
// Open mobile menu
await page.getByRole('button', { name: 'Menu' }).click();
// Menu items now visible
await expect(page.getByRole('link', { name: 'Products' })).toBeVisible();
});
});
Testing File Uploads
test('uploads profile picture', async ({ page }) => {
await page.goto('/profile');
// Prepare file to upload
const filePath = path.join(__dirname, 'fixtures', 'avatar.png');
// Upload file
await page.getByLabel('Profile Picture').setInputFiles(filePath);
// Submit
await page.getByRole('button', { name: 'Save' }).click();
// Verify upload
await expect(page.getByText('Profile updated')).toBeVisible();
// Check image is displayed
const img = page.getByRole('img', { name: 'Profile picture' });
await expect(img).toBeVisible();
});
β E2E Testing Patterns Best Practices
- Use Page Object Model: Encapsulate page logic
- Create reusable fixtures: DRY principle for setup
- Test complete journeys: End-to-end user flows
- Test responsive behavior: Multiple viewport sizes
- Use test data factories: Generate realistic test data
- Clean up after tests: Delete created data
π CI/CD Testing Strategies
Running tests in Continuous Integration ensures your application stays healthy as the codebase grows.
GitHub Actions Example
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit-and-integration:
runs-on: ubuntu-latest
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 run test:unit
- name: Run integration tests
run: npm run test:integration
- name: Upload coverage
uses: codecov/codecov-action@v3
e2e:
runs-on: ubuntu-latest
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: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/
Test Pyramid in CI/CD
Parallel Test Execution
// playwright.config.ts
export default defineConfig({
// Run tests in parallel
workers: process.env.CI ? 2 : undefined,
// Shard tests across machines in CI
shard: process.env.CI
? { current: 1, total: 4 }
: undefined,
// Retry failed tests
retries: process.env.CI ? 2 : 0,
});
Test Reporting
// playwright.config.ts
export default defineConfig({
reporter: [
['html'],
['json', { outputFile: 'test-results.json' }],
['junit', { outputFile: 'test-results.xml' }],
process.env.CI ? ['github'] : ['list']
],
});
Managing Test Data in CI
// Use environment-specific test data
test.beforeEach(async ({ page }) => {
// Seed database with test data
if (process.env.CI) {
await fetch(`${process.env.API_URL}/test/seed`, {
method: 'POST',
headers: { 'X-Test-Token': process.env.TEST_TOKEN }
});
}
});
test.afterEach(async () => {
// Clean up test data
if (process.env.CI) {
await fetch(`${process.env.API_URL}/test/cleanup`, {
method: 'POST',
headers: { 'X-Test-Token': process.env.TEST_TOKEN }
});
}
});
π‘ CI/CD Testing Best Practices
- Fast feedback: Run unit tests first, E2E last
- Parallel execution: Speed up test runs
- Retry flaky tests: Auto-retry up to 2 times
- Cache dependencies: Faster pipeline runs
- Record artifacts: Screenshots, videos, traces
- Monitor test health: Track flaky tests
ποΈ Hands-on Exercises
Practice your integration and E2E testing skills with these comprehensive exercises.
Exercise 1: Test a Shopping Cart Feature
Task: Write integration tests for a shopping cart that spans multiple components.
Requirements:
- Test adding products to cart from product list
- Test updating quantities in cart
- Test removing items from cart
- Test cart total calculation
- Test cart persistence (Context or Redux)
- Test empty cart state
π‘ Hint
Create a test helper that renders your components within the cart provider. Test the complete flow from product list β add to cart β cart summary β remove items. Verify state updates across all components.
β Solution Approach
describe('Shopping Cart Integration', () => {
function renderWithCart(ui: React.ReactElement) {
return render(
<CartProvider>
{ui}
</CartProvider>
);
}
it('adds products and updates cart across components', async () => {
const user = userEvent.setup();
renderWithCart(
<>
<ProductList />
<Cart />
</>
);
// Add product
await user.click(screen.getAllByText('Add to Cart')[0]);
// Verify cart updates
expect(screen.getByText('1 items')).toBeInTheDocument();
// Update quantity
await user.click(screen.getByLabelText('Increase quantity'));
expect(screen.getByText('2 items')).toBeInTheDocument();
// Remove item
await user.click(screen.getByText('Remove'));
expect(screen.getByText('0 items')).toBeInTheDocument();
expect(screen.getByText('Your cart is empty')).toBeInTheDocument();
});
});
Exercise 2: Test Multi-Page User Journey with E2E
Task: Write a Playwright test for a complete user registration and profile setup flow.
User Journey:
- User visits homepage
- Clicks "Sign Up"
- Fills registration form
- Confirms email (mock)
- Completes profile setup
- Uploads profile picture
- Views completed profile
π‘ Hint
Use Page Object Model to organize each page's actions. Mock the email confirmation endpoint. Test each step of the journey, verifying navigation and data persistence between pages.
β Solution Approach
test('complete user registration journey', async ({ page }) => {
// 1. Homepage
await page.goto('/');
await page.getByRole('link', { name: 'Sign Up' }).click();
// 2. Registration
await expect(page).toHaveURL('/register');
await page.getByLabel('Email').fill('newuser@example.com');
await page.getByLabel('Password').fill('SecurePass123');
await page.getByLabel('Confirm Password').fill('SecurePass123');
await page.getByRole('button', { name: 'Create Account' }).click();
// 3. Email confirmation (mock)
await page.route('/api/auth/verify', route => {
route.fulfill({ status: 200, body: JSON.stringify({ verified: true }) });
});
await expect(page).toHaveURL('/verify-email');
// 4. Profile setup
await expect(page).toHaveURL('/profile/setup');
await page.getByLabel('Display Name').fill('John Doe');
await page.getByLabel('Bio').fill('Software developer');
// 5. Upload picture
await page.getByLabel('Profile Picture').setInputFiles('fixtures/avatar.png');
await page.getByRole('button', { name: 'Complete Setup' }).click();
// 6. View profile
await expect(page).toHaveURL('/profile');
await expect(page.getByText('John Doe')).toBeVisible();
await expect(page.getByText('Software developer')).toBeVisible();
});
Exercise 3: Test Authentication Flow with Protected Routes
Task: Write integration tests for authentication with route protection.
Requirements:
- Test login redirects to dashboard
- Test logout redirects to login
- Test protected routes redirect unauthenticated users
- Test authenticated users can access protected routes
- Test auth state persists across page navigation
- Test session expiration
π‘ Hint
Use MemoryRouter with AuthProvider. Test both successful and failed login attempts. Verify that navigation to protected routes triggers appropriate redirects based on auth state.
Exercise 4: Test Real-Time Features
Task: Write tests for a chat application with real-time messages.
Requirements:
- Test sending messages
- Test receiving messages (mock WebSocket)
- Test message list updates
- Test typing indicators
- Test online/offline status
π‘ Hint
Mock WebSocket connection. Use fake timers for typing indicators. Test that messages appear in the correct order and that UI updates reflect connection status.
β Best Practices
Follow these guidelines to write maintainable, reliable integration and E2E tests.
1. Follow the Testing Pyramid
β Do This
- 70-80% unit tests (fast, focused)
- 15-25% integration tests (component interactions)
- 5-10% E2E tests (critical user paths)
2. Test User Behavior, Not Implementation
β Do This
// Test what the user sees and does
test('user can submit form', async () => {
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByText('Success!')).toBeVisible();
});
β Don't Do This
// Don't test implementation details
test('form submission calls handleSubmit', () => {
const handleSubmit = vi.fn();
render(<Form onSubmit={handleSubmit} />);
// Testing internal function calls
});
3. Use Realistic Test Data
// β
Good - realistic data
const testUser = {
name: 'John Doe',
email: 'john.doe@example.com',
age: 32,
address: {
street: '123 Main St',
city: 'New York',
zip: '10001'
}
};
// β Bad - oversimplified
const testUser = {
name: 'test',
email: 'test@test.com'
};
4. Keep Tests Independent
// β
Good - each test sets up its own state
describe('Todo List', () => {
test('adds todo', async () => {
render(<TodoList />);
// Fresh state, independent test
});
test('deletes todo', async () => {
render(<TodoList />);
// Fresh state, independent test
});
});
// β Bad - tests depend on each other
describe('Todo List', () => {
let todoId: string;
test('adds todo', async () => {
// Sets todoId for next test
});
test('deletes todo', async () => {
// Depends on previous test
});
});
5. Use Page Object Model for E2E
// β
Good - encapsulated page logic
class LoginPage {
constructor(private page: Page) {}
async login(email: string, password: string) {
await this.page.getByLabel('Email').fill(email);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Login' }).click();
}
}
test('user can login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('user@example.com', 'pass123');
});
// β Bad - repeated selectors
test('user can login', async ({ page }) => {
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('pass123');
await page.getByRole('button', { name: 'Login' }).click();
});
6. Mock External Dependencies
// β
Good - mock external APIs
test('loads user data', async () => {
server.use(
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.json({ name: 'John' }));
})
);
render(<UserProfile />);
expect(await screen.findByText('John')).toBeVisible();
});
7. Use Descriptive Test Names
// β
Good - clear what's being tested
test('displays validation error when email is invalid', () => {});
test('redirects to dashboard after successful login', () => {});
test('shows loading spinner while fetching products', () => {});
// β Bad - vague test names
test('works', () => {});
test('test login', () => {});
test('should render', () => {});
8. Clean Up After Tests
describe('Database tests', () => {
beforeEach(async () => {
await seedDatabase();
});
afterEach(async () => {
await cleanDatabase();
});
test('creates user', async () => {
// Test runs with clean database
});
});
9. Handle Async Operations Properly
// β
Good - wait for async operations
test('loads data', async () => {
render(<DataComponent />);
expect(await screen.findByText('Loaded')).toBeVisible();
});
// β Bad - no async handling
test('loads data', () => {
render(<DataComponent />);
expect(screen.getByText('Loaded')).toBeVisible(); // Will fail!
});
10. Test Accessibility
test('form is accessible', async () => {
render(<ContactForm />);
// Can use labels to find inputs
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Message')).toBeInTheDocument();
// Can use button role
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument();
// Test keyboard navigation
await user.tab();
expect(screen.getByLabelText('Email')).toHaveFocus();
});
π Testing Checklist
- β Tests are independent and can run in any order
- β Test names clearly describe what's being tested
- β Using realistic test data
- β Mocking external dependencies (APIs, time, randomness)
- β Testing user behavior, not implementation
- β Proper async handling with await
- β Cleaning up after each test
- β Tests run quickly (< 30 seconds for integration, < 5 min for E2E)
- β Following the testing pyramid
- β Tests are maintainable and easy to update
π Summary
Congratulations! You've mastered integration and end-to-end testing for React applications. You now understand how to test components working together and complete user workflows.
Key Takeaways
π― Core Concepts
- Test Types Hierarchy
- Unit tests: Fast, focused, many
- Integration tests: Moderate speed, component interactions
- E2E tests: Slow, realistic, critical paths only
- Integration Testing
- Test multiple components working together
- Test data flow through component trees
- Test routing and navigation
- Test state management (Context, Redux, Zustand)
- E2E Testing
- Test in real browsers with Playwright or Cypress
- Test complete user journeys from start to finish
- Test critical workflows (auth, checkout, core features)
- Use Page Object Model for maintainable tests
- Tools and Frameworks
- Playwright: Fast, modern, multi-browser support
- Cypress: Great DX, time travel debugging
- MSW: Network mocking for both integration and E2E
- Testing Library: User-centric testing utilities
- CI/CD Integration
- Run tests in pipeline (unit β integration β E2E)
- Parallel test execution for speed
- Retry flaky tests automatically
- Generate test reports and artifacts
The Testing Pyramid Recap
70-80%
Fast, Many] A --> C[Integration Tests
15-25%
Moderate, Some] A --> D[E2E Tests
5-10%
Slow, Few] B --> E[Functions
Hooks
Utils] C --> F[Components
Routes
State] D --> G[User Flows
Critical Paths
Auth] style B fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff style C fill:#339af0,stroke:#1971c2,stroke-width:2px,color:#fff style D fill:#f59f00,stroke:#e67700,stroke-width:2px,color:#fff
Integration vs E2E: Quick Reference
| When to Use | Integration Tests | E2E Tests |
|---|---|---|
| Component interactions | β Perfect | β Overkill |
| Routing flows | β Good | β Good |
| State management | β Perfect | β Too slow |
| Form validation | β Perfect | β Too slow |
| Critical user journeys | β Not realistic enough | β Perfect |
| Authentication flows | β Good for logic | β Good for full flow |
| Payment processing | β Not realistic | β Perfect |
| Responsive design | β Can't test | β Perfect |
Common Pitfalls to Avoid
| Pitfall | Problem | Solution |
|---|---|---|
| Too many E2E tests | Slow test suite, high maintenance | Follow testing pyramid, focus on critical paths |
| Testing implementation | Brittle tests that break on refactoring | Test user behavior and outcomes |
| Dependent tests | Tests fail in isolation or random order | Make each test independent with own setup |
| Not mocking external APIs | Flaky tests, slow execution | Mock with MSW or route handlers |
| Poor selectors in E2E | Tests break with styling changes | Use role-based and accessible selectors |
| No CI/CD integration | Tests not run regularly | Set up automated testing pipeline |
Tools Summary
π οΈ Your Testing Toolkit
- Vitest: Fast unit test runner
- React Testing Library: User-centric component testing
- MSW: Network request mocking
- Playwright: Modern E2E testing framework
- Cypress: Developer-friendly E2E alternative
- GitHub Actions: CI/CD automation
Next Steps
Now that you've mastered testing, you're ready to:
- Add comprehensive test coverage to your projects
- Set up CI/CD pipelines with automated testing
- Write tests that give you confidence to refactor
- Choose the right test type for each scenario
- Build reliable, maintainable applications
π Congratulations!
You've completed Lesson 9.5: Integration and E2E Testing! You now have the skills to:
- β Understand the difference between test types
- β Write integration tests for component interactions
- β Test routing and navigation flows
- β Test state management across components
- β Write E2E tests with Playwright or Cypress
- β Test complete user workflows
- β Set up CI/CD testing pipelines
- β Choose the right test type for each scenario
π‘ Remember: "The goal of testing isn't 100% coverageβit's having confidence that your application works correctly. Focus on testing the things that matter most to your users."
π Module 9 Complete!
You've finished the Testing React Applications module! You've learned:
- Testing fundamentals and why testing matters
- React Testing Library and component testing
- Testing user interactions and forms
- Testing asynchronous code and API calls
- Integration testing and E2E testing
Next up: Module 10 - Advanced Topics and Deployment