State Testing and Quality Assurance
1. Unit Testing State Logic with React Testing Library
Testing state logic ensures component behavior is correct across state changes and user interactions.
| Testing Pattern | Description | Use Case |
|---|---|---|
| render() | Render component into testing DOM | Component initialization with state |
| screen.getByRole() | Query elements by accessibility role | Find buttons, inputs, headings |
| fireEvent.click() | Simulate user click events | Test state changes on interaction |
| userEvent.type() | Simulate realistic user typing | Test form input state updates |
| waitFor() | Wait for async state updates | Async operations, API calls |
| act() | Wrap state updates in act | Manual state updates in tests |
Example: Testing Counter State with useState
// Counter.jsx
import { useState } from 'react';
export function Counter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
// Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
test('renders with initial count', () => {
render(<Counter initialCount={5} />);
expect(screen.getByText('Count: 5')).toBeInTheDocument();
});
test('increments count when increment button clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
const incrementBtn = screen.getByRole('button', { name: /increment/i });
await user.click(incrementBtn);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
test('decrements count when decrement button clicked', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
const decrementBtn = screen.getByRole('button', { name: /decrement/i });
await user.click(decrementBtn);
expect(screen.getByText('Count: 4')).toBeInTheDocument();
});
test('resets count to zero', async () => {
const user = userEvent.setup();
render(<Counter initialCount={10} />);
const resetBtn = screen.getByRole('button', { name: /reset/i });
await user.click(resetBtn);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
});
Example: Testing Async State Updates
// UserProfile.jsx
import { useState, useEffect } from 'react';
export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>User: {user?.name}</div>;
}
// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
global.fetch = jest.fn();
describe('UserProfile', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('displays loading state initially', () => {
fetch.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('displays user data after successful fetch', async () => {
const mockUser = { id: 1, name: 'John Doe' };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUser
});
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('User: John Doe')).toBeInTheDocument();
});
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
test('displays error message on fetch failure', async () => {
fetch.mockResolvedValueOnce({
ok: false
});
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(/Error:/)).toBeInTheDocument();
});
});
});
Note: Use
userEvent instead of fireEvent for more realistic user
interactions. Always use async/await with userEvent methods.
2. Testing useReducer and Action Dispatching
Test reducer logic in isolation and verify action dispatching updates state correctly.
| Testing Strategy | Description | Use Case |
|---|---|---|
| Reducer unit tests | Test reducer function independently | Pure logic testing without React |
| Action dispatching | Test dispatch calls update state | Component integration testing |
| Initial state | Verify initial state setup | Component initialization |
| Multiple actions | Test action sequences | Complex state transitions |
| Invalid actions | Test unknown action types | Error handling validation |
Example: Testing Reducer Function in Isolation
// todoReducer.js
export const initialState = { todos: [], filter: 'all' };
export function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, { id: Date.now(), text: action.payload, completed: false }]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload)
};
case 'SET_FILTER':
return { ...state, filter: action.payload };
default:
return state;
}
}
// todoReducer.test.js
import { todoReducer, initialState } from './todoReducer';
describe('todoReducer', () => {
test('returns initial state for unknown action', () => {
const result = todoReducer(initialState, { type: 'UNKNOWN' });
expect(result).toEqual(initialState);
});
test('adds todo on ADD_TODO action', () => {
const action = { type: 'ADD_TODO', payload: 'Learn testing' };
const result = todoReducer(initialState, action);
expect(result.todos).toHaveLength(1);
expect(result.todos[0].text).toBe('Learn testing');
expect(result.todos[0].completed).toBe(false);
});
test('toggles todo completion on TOGGLE_TODO action', () => {
const stateWithTodo = {
todos: [{ id: 1, text: 'Test', completed: false }],
filter: 'all'
};
const action = { type: 'TOGGLE_TODO', payload: 1 };
const result = todoReducer(stateWithTodo, action);
expect(result.todos[0].completed).toBe(true);
});
test('deletes todo on DELETE_TODO action', () => {
const stateWithTodo = {
todos: [
{ id: 1, text: 'First', completed: false },
{ id: 2, text: 'Second', completed: false }
],
filter: 'all'
};
const action = { type: 'DELETE_TODO', payload: 1 };
const result = todoReducer(stateWithTodo, action);
expect(result.todos).toHaveLength(1);
expect(result.todos[0].id).toBe(2);
});
test('sets filter on SET_FILTER action', () => {
const action = { type: 'SET_FILTER', payload: 'completed' };
const result = todoReducer(initialState, action);
expect(result.filter).toBe('completed');
});
});
Example: Testing useReducer Component Integration
// TodoList.jsx
import { useReducer } from 'react';
import { todoReducer, initialState } from './todoReducer';
export function TodoList() {
const [state, dispatch] = useReducer(todoReducer, initialState);
const [input, setInput] = useState('');
const handleAdd = () => {
if (input.trim()) {
dispatch({ type: 'ADD_TODO', payload: input });
setInput('');
}
};
return (
<div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Add todo"
/>
<button onClick={handleAdd}>Add</button>
<ul>
{state.todos.map(todo => (
<li key={todo.id}>
<span>{todo.text}</span>
<button onClick={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}>
Toggle
</button>
<button onClick={() => dispatch({ type: 'DELETE_TODO', payload: todo.id })}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
// TodoList.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TodoList } from './TodoList';
describe('TodoList', () => {
test('adds todo when Add button clicked', async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByPlaceholderText('Add todo');
const addBtn = screen.getByRole('button', { name: /add/i });
await user.type(input, 'Learn React Testing');
await user.click(addBtn);
expect(screen.getByText('Learn React Testing')).toBeInTheDocument();
expect(input).toHaveValue(''); // Input cleared
});
test('toggles todo completion', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Add a todo first
await user.type(screen.getByPlaceholderText('Add todo'), 'Test todo');
await user.click(screen.getByRole('button', { name: /add/i }));
// Toggle it
const toggleBtn = screen.getByRole('button', { name: /toggle/i });
await user.click(toggleBtn);
// Verify state changed (implementation-dependent)
});
test('deletes todo', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Add a todo
await user.type(screen.getByPlaceholderText('Add todo'), 'Delete me');
await user.click(screen.getByRole('button', { name: /add/i }));
// Delete it
const deleteBtn = screen.getByRole('button', { name: /delete/i });
await user.click(deleteBtn);
expect(screen.queryByText('Delete me')).not.toBeInTheDocument();
});
});
Note: Test reducer functions separately from components for easier debugging and better test
coverage. Pure reducer functions don't require React Testing Library.
3. Testing Context Providers and Consumer Components
Test context providers supply correct values and consumers react to context changes.
| Testing Pattern | Description | Use Case |
|---|---|---|
| Custom wrapper | Wrap components with providers | Test components consuming context |
| Mock context values | Provide test-specific values | Control context state in tests |
| Context updates | Test context value changes | Verify consumer re-renders |
| Multiple consumers | Test multiple context users | Shared state consistency |
Example: Testing Context Provider and Consumer
// ThemeContext.jsx
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// ThemeDisplay.jsx
export function ThemeDisplay() {
const { theme, toggleTheme } = useTheme();
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
);
}
// ThemeContext.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, useTheme } from './ThemeContext';
import { ThemeDisplay } from './ThemeDisplay';
describe('ThemeContext', () => {
test('provides default theme value', () => {
render(
<ThemeProvider>
<ThemeDisplay />
</ThemeProvider>
);
expect(screen.getByText('Current theme: light')).toBeInTheDocument();
});
test('toggles theme when button clicked', async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<ThemeDisplay />
</ThemeProvider>
);
const toggleBtn = screen.getByRole('button', { name: /toggle theme/i });
await user.click(toggleBtn);
expect(screen.getByText('Current theme: dark')).toBeInTheDocument();
await user.click(toggleBtn);
expect(screen.getByText('Current theme: light')).toBeInTheDocument();
});
test('throws error when useTheme used outside provider', () => {
// Suppress console.error for this test
const consoleError = jest.spyOn(console, 'error').mockImplementation();
const TestComponent = () => {
useTheme();
return null;
};
expect(() => render(<TestComponent />)).toThrow(
'useTheme must be used within ThemeProvider'
);
consoleError.mockRestore();
});
test('multiple consumers share same context', async () => {
const user = userEvent.setup();
function MultipleDisplays() {
return (
<ThemeProvider>
<ThemeDisplay />
<ThemeDisplay />
</ThemeProvider>
);
}
render(<MultipleDisplays />);
const displays = screen.getAllByText('Current theme: light');
expect(displays).toHaveLength(2);
const toggleBtn = screen.getAllByRole('button', { name: /toggle theme/i })[0];
await user.click(toggleBtn);
const darkDisplays = screen.getAllByText('Current theme: dark');
expect(darkDisplays).toHaveLength(2);
});
});
Example: Custom Render with Context Wrapper
// test-utils.jsx
import { render } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { AuthProvider } from './AuthContext';
export function renderWithProviders(
ui,
{
themeValue = 'light',
authValue = { user: null, login: jest.fn(), logout: jest.fn() },
...renderOptions
} = {}
) {
function Wrapper({ children }) {
return (
<ThemeProvider initialTheme={themeValue}>
<AuthProvider initialAuth={authValue}>
{children}
</AuthProvider>
</ThemeProvider>
);
}
return render(ui, { wrapper: Wrapper, ...renderOptions });
}
// Re-export everything from React Testing Library
export * from '@testing-library/react';
// App.test.jsx
import { screen } from '@testing-library/react';
import { renderWithProviders } from './test-utils';
import { App } from './App';
describe('App', () => {
test('renders with default providers', () => {
renderWithProviders(<App />);
// Test assertions
});
test('renders with custom auth state', () => {
const mockUser = { id: 1, name: 'Test User' };
renderWithProviders(<App />, {
authValue: { user: mockUser, login: jest.fn(), logout: jest.fn() }
});
expect(screen.getByText('Test User')).toBeInTheDocument();
});
});
Note: Create custom render utilities with common providers to reduce test boilerplate and
ensure consistent test setup across your test suite.
4. Mock State for Component Testing
Mock state management hooks and libraries to test components in isolation.
| Mocking Strategy | Description | Use Case |
|---|---|---|
| jest.mock() | Mock entire modules | Replace state management libraries |
| jest.spyOn() | Spy on specific functions | Track hook calls, verify usage |
| Mock return values | Control hook return values | Test component with specific state |
| Mock implementations | Provide custom hook behavior | Simulate complex state scenarios |
Example: Mocking useState and useEffect
// DataFetcher.jsx
import { useState, useEffect } from 'react';
export function DataFetcher({ url }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
});
}, [url]);
if (loading) return <div>Loading...</div>;
return <div>{data?.message}</div>;
}
// DataFetcher.test.jsx
import { render, screen } from '@testing-library/react';
import * as React from 'react';
import { DataFetcher } from './DataFetcher';
describe('DataFetcher with mocked hooks', () => {
test('shows loading state', () => {
// Mock useState to return loading state
const mockSetData = jest.fn();
const mockSetLoading = jest.fn();
jest.spyOn(React, 'useState')
.mockReturnValueOnce([null, mockSetData]) // data state
.mockReturnValueOnce([true, mockSetLoading]); // loading state
jest.spyOn(React, 'useEffect').mockImplementation(f => f());
render(<DataFetcher url="/api/data" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('shows data after loading', () => {
// Mock useState to return loaded state
const mockData = { message: 'Test Message' };
jest.spyOn(React, 'useState')
.mockReturnValueOnce([mockData, jest.fn()]) // data state
.mockReturnValueOnce([false, jest.fn()]); // loading state
jest.spyOn(React, 'useEffect').mockImplementation(() => {});
render(<DataFetcher url="/api/data" />);
expect(screen.getByText('Test Message')).toBeInTheDocument();
});
});
Example: Mocking Redux/Zustand Store
// ProductList.jsx
import { useStore } from './store';
export function ProductList() {
const products = useStore(state => state.products);
const addToCart = useStore(state => state.addToCart);
return (
<ul>
{products.map(product => (
<li key={product.id}>
{product.name}
<button onClick={() => addToCart(product)}>Add to Cart</button>
</li>
))}
</ul>
);
}
// ProductList.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductList } from './ProductList';
import * as store from './store';
jest.mock('./store');
describe('ProductList', () => {
test('renders products from store', () => {
const mockProducts = [
{ id: 1, name: 'Product 1' },
{ id: 2, name: 'Product 2' }
];
store.useStore.mockImplementation(selector =>
selector({ products: mockProducts, addToCart: jest.fn() })
);
render(<ProductList />);
expect(screen.getByText('Product 1')).toBeInTheDocument();
expect(screen.getByText('Product 2')).toBeInTheDocument();
});
test('calls addToCart when button clicked', async () => {
const user = userEvent.setup();
const mockAddToCart = jest.fn();
const mockProducts = [{ id: 1, name: 'Product 1' }];
store.useStore.mockImplementation(selector =>
selector({ products: mockProducts, addToCart: mockAddToCart })
);
render(<ProductList />);
const addBtn = screen.getByRole('button', { name: /add to cart/i });
await user.click(addBtn);
expect(mockAddToCart).toHaveBeenCalledWith(mockProducts[0]);
});
});
Warning: Mocking React hooks can lead to brittle tests. Prefer integration testing with real
hooks when possible. Use mocking primarily for external dependencies or complex state scenarios.
5. Integration Testing with State Management Libraries
Test complete state management flows including store initialization, actions, and selectors.
| Library | Testing Approach | Key Considerations |
|---|---|---|
| Redux | Test store, reducers, actions separately | Use real store in tests, mock API calls |
| Zustand | Create test store instances | Reset store between tests |
| Jotai | Use Provider in tests | Isolated atom state per test |
| React Query | Wrap with QueryClientProvider | Clear cache between tests |
Example: Redux Integration Testing
// store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const createStore = (preloadedState) => {
return configureStore({
reducer: { counter: counterReducer },
preloadedState
});
};
// counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => { state.value += 1; },
decrement: state => { state.value -= 1; },
incrementByAmount: (state, action) => { state.value += action.payload; }
}
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
// Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { createStore } from './store';
import { Counter } from './Counter';
describe('Counter with Redux', () => {
let store;
beforeEach(() => {
store = createStore();
});
const renderWithStore = (component) => {
return render(<Provider store={store}>{component}</Provider>);
};
test('increments counter value', async () => {
const user = userEvent.setup();
renderWithStore(<Counter />);
const incrementBtn = screen.getByRole('button', { name: /increment/i });
await user.click(incrementBtn);
expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});
test('uses preloaded state', () => {
store = createStore({ counter: { value: 10 } });
renderWithStore(<Counter />);
expect(screen.getByText(/count: 10/i)).toBeInTheDocument();
});
test('multiple actions update state correctly', async () => {
const user = userEvent.setup();
renderWithStore(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
await user.click(screen.getByRole('button', { name: /increment/i }));
await user.click(screen.getByRole('button', { name: /decrement/i }));
expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});
});
Example: React Query Integration Testing
// UserList.jsx
import { useQuery } from '@tanstack/react-query';
function fetchUsers() {
return fetch('/api/users').then(res => res.json());
}
export function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// UserList.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { UserList } from './UserList';
global.fetch = jest.fn();
describe('UserList with React Query', () => {
let queryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false, // Disable retries for tests
},
},
});
jest.clearAllMocks();
});
const renderWithClient = (component) => {
return render(
<QueryClientProvider client={queryClient}>
{component}
</QueryClientProvider>
);
};
test('displays loading state', () => {
fetch.mockImplementation(() => new Promise(() => {}));
renderWithClient(<UserList />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('displays users after successful fetch', async () => {
const mockUsers = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
];
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUsers
});
renderWithClient(<UserList />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
});
test('displays error message on fetch failure', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
renderWithClient(<UserList />);
await waitFor(() => {
expect(screen.getByText(/Error: Network error/i)).toBeInTheDocument();
});
});
});
Note: Create fresh store/client instances for each test to ensure test isolation. Configure
libraries to disable retries and caching behaviors that might cause flaky tests.
6. End-to-End State Testing with Cypress/Playwright
Test complete user workflows including state persistence, navigation, and multi-page state management.
| E2E Pattern | Description | Use Case |
|---|---|---|
| User flows | Test complete user journeys | Login, checkout, multi-step forms |
| State persistence | Verify localStorage/sessionStorage | Shopping cart, user preferences |
| Navigation state | Test state across page transitions | Wizard forms, checkout flows |
| API interactions | Test real/mocked API calls | Data fetching, mutations |
| Visual regression | Test UI based on state changes | Theme switching, conditional rendering |
Example: Cypress E2E State Testing
// cypress/e2e/shopping-cart.cy.js
describe('Shopping Cart State Management', () => {
beforeEach(() => {
cy.visit('/products');
cy.clearLocalStorage();
});
it('adds products to cart and persists state', () => {
// Add first product
cy.get('[data-testid="product-1"]').within(() => {
cy.contains('Add to Cart').click();
});
// Verify cart count updated
cy.get('[data-testid="cart-count"]').should('contain', '1');
// Add second product
cy.get('[data-testid="product-2"]').within(() => {
cy.contains('Add to Cart').click();
});
cy.get('[data-testid="cart-count"]').should('contain', '2');
// Verify localStorage persisted cart state
cy.window().then(win => {
const cartState = JSON.parse(win.localStorage.getItem('cart'));
expect(cartState.items).to.have.length(2);
});
// Navigate to cart page
cy.get('[data-testid="view-cart"]').click();
cy.url().should('include', '/cart');
// Verify cart items displayed
cy.get('[data-testid="cart-item"]').should('have.length', 2);
});
it('persists cart across page refresh', () => {
// Add product to cart
cy.get('[data-testid="product-1"]').within(() => {
cy.contains('Add to Cart').click();
});
// Reload page
cy.reload();
// Verify cart persisted
cy.get('[data-testid="cart-count"]').should('contain', '1');
});
it('clears cart on logout', () => {
// Setup: Login and add items
cy.login('test@example.com', 'password');
cy.get('[data-testid="product-1"]').within(() => {
cy.contains('Add to Cart').click();
});
// Logout
cy.get('[data-testid="logout"]').click();
// Verify cart cleared
cy.get('[data-testid="cart-count"]').should('contain', '0');
cy.window().then(win => {
expect(win.localStorage.getItem('cart')).to.be.null;
});
});
it('syncs cart across multiple tabs', () => {
// Add to cart in first tab
cy.get('[data-testid="product-1"]').within(() => {
cy.contains('Add to Cart').click();
});
// Open new tab (simulate with new visit)
cy.visit('/products');
// Verify cart synced
cy.get('[data-testid="cart-count"]').should('contain', '1');
});
});
Example: Playwright E2E State Testing
// tests/auth-state.spec.js
import { test, expect } from '@playwright/test';
test.describe('Authentication State Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('maintains auth state after login', async ({ page }) => {
// Fill login form
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="login-button"]');
// Wait for redirect
await page.waitForURL('/dashboard');
// Verify auth state in UI
await expect(page.locator('[data-testid="user-name"]')).toHaveText('Test User');
// Verify auth token in localStorage
const token = await page.evaluate(() => localStorage.getItem('authToken'));
expect(token).toBeTruthy();
// Navigate to different page
await page.goto('/profile');
// Verify still authenticated
await expect(page.locator('[data-testid="user-name"]')).toHaveText('Test User');
});
test('redirects to login when accessing protected route without auth', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForURL('/login');
await expect(page.locator('h1')).toHaveText('Login');
});
test('clears auth state on logout', async ({ page, context }) => {
// Login first
await page.fill('[data-testid="email-input"]', 'test@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="login-button"]');
await page.waitForURL('/dashboard');
// Logout
await page.click('[data-testid="logout-button"]');
await page.waitForURL('/login');
// Verify auth state cleared
const token = await page.evaluate(() => localStorage.getItem('authToken'));
expect(token).toBeNull();
// Verify redirected when trying to access protected route
await page.goto('/dashboard');
await page.waitForURL('/login');
});
test('handles concurrent auth state in multiple tabs', async ({ context }) => {
// Create two pages (tabs)
const page1 = await context.newPage();
const page2 = await context.newPage();
// Login in first tab
await page1.goto('/login');
await page1.fill('[data-testid="email-input"]', 'test@example.com');
await page1.fill('[data-testid="password-input"]', 'password123');
await page1.click('[data-testid="login-button"]');
await page1.waitForURL('/dashboard');
// Navigate in second tab
await page2.goto('/dashboard');
// Verify authenticated in both tabs
await expect(page1.locator('[data-testid="user-name"]')).toHaveText('Test User');
await expect(page2.locator('[data-testid="user-name"]')).toHaveText('Test User');
// Logout in first tab
await page1.click('[data-testid="logout-button"]');
// Verify second tab also logged out (if cross-tab sync implemented)
await page2.reload();
await page2.waitForURL('/login');
});
});
Note: E2E tests are slower and more expensive than unit tests. Focus on critical user flows
and state persistence scenarios. Use API mocking strategically to improve test speed and reliability.
Section 17 Key Takeaways
- React Testing Library - Test state logic through user interactions, use userEvent for realistic testing
- Reducer testing - Test reducer functions in isolation as pure functions, then integration test with components
- Context testing - Create custom render utilities with providers, test error handling when context missing
- State mocking - Mock external state libraries, prefer real hooks when possible to avoid brittle tests
- Integration testing - Use real store instances in tests, clear state between tests for isolation
- E2E testing - Test complete user flows including state persistence, navigation, and cross-tab synchronization