Appearance
集成测试策略
概述
集成测试验证多个模块或组件协同工作的正确性。与单元测试专注于单个模块不同,集成测试关注组件间的交互、数据流和业务流程。本文将介绍React应用的集成测试策略、工具选择以及最佳实践。
集成测试范围
测试金字塔
/\
/ \ E2E测试(少量)
/----\
/ \ 集成测试(中等)
/--------\
/ \ 单元测试(大量)
/------------\集成测试类型
typescript
// 1. 组件集成测试
<Provider store={store}>
<Router>
<UserProfile userId={1} />
</Router>
</Provider>
// 2. API集成测试
async function createUser(data) {
const response = await api.post('/users', data);
const user = await api.get(`/users/${response.id}`);
return user;
}
// 3. 数据流集成测试
Redux Store -> React Component -> User Action -> State Update
// 4. 路由集成测试
Navigate -> Route Change -> Component Mount -> Data Fetch组件集成测试
父子组件集成
typescript
// ParentComponent.tsx
function ParentComponent() {
const [items, setItems] = useState<Item[]>([]);
const handleAdd = (item: Item) => {
setItems([...items, item]);
};
const handleDelete = (id: string) => {
setItems(items.filter(i => i.id !== id));
};
return (
<div>
<ItemForm onAdd={handleAdd} />
<ItemList items={items} onDelete={handleDelete} />
</div>
);
}
// ItemForm.tsx
function ItemForm({ onAdd }: { onAdd: (item: Item) => void }) {
const [name, setName] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
onAdd({ id: Date.now().toString(), name });
setName('');
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Item name"
/>
<button type="submit">Add</button>
</form>
);
}
// ItemList.tsx
function ItemList({ items, onDelete }: {
items: Item[];
onDelete: (id: string) => void;
}) {
return (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name}
<button onClick={() => onDelete(item.id)}>Delete</button>
</li>
))}
</ul>
);
}
// ParentComponent.test.tsx
describe('ParentComponent Integration', () => {
it('should add and delete items', async () => {
const user = userEvent.setup();
render(<ParentComponent />);
// 添加项目
await user.type(screen.getByPlaceholderText('Item name'), 'Test Item');
await user.click(screen.getByText('Add'));
expect(screen.getByText('Test Item')).toBeInTheDocument();
// 删除项目
await user.click(screen.getByText('Delete'));
expect(screen.queryByText('Test Item')).not.toBeInTheDocument();
});
it('should handle multiple items', async () => {
const user = userEvent.setup();
render(<ParentComponent />);
// 添加多个项目
for (const name of ['Item 1', 'Item 2', 'Item 3']) {
await user.type(screen.getByPlaceholderText('Item name'), name);
await user.click(screen.getByText('Add'));
}
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
});Context集成测试
typescript
// ThemeContext.tsx
const ThemeContext = createContext<ThemeContextType>(null!);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(t => t === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// ThemedButton.tsx
function ThemedButton() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<button
className={`btn-${theme}`}
onClick={toggleTheme}
>
Toggle Theme ({theme})
</button>
);
}
// Integration test
describe('Theme Integration', () => {
it('should toggle theme across components', async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<ThemedButton />
<ThemedContent />
</ThemeProvider>
);
// 初始主题
expect(screen.getByRole('button')).toHaveClass('btn-light');
expect(screen.getByTestId('content')).toHaveClass('content-light');
// 切换主题
await user.click(screen.getByRole('button'));
expect(screen.getByRole('button')).toHaveClass('btn-dark');
expect(screen.getByTestId('content')).toHaveClass('content-dark');
});
});Redux集成测试
Store集成
typescript
// store.ts
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
import cartReducer from './cartSlice';
export const store = configureStore({
reducer: {
user: userReducer,
cart: cartReducer,
},
});
// userSlice.ts
const userSlice = createSlice({
name: 'user',
initialState: { currentUser: null },
reducers: {
setUser: (state, action) => {
state.currentUser = action.payload;
},
},
});
// cartSlice.ts
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
addToCart: (state, action) => {
state.items.push(action.payload);
},
},
});
// ShoppingPage.tsx
function ShoppingPage() {
const dispatch = useDispatch();
const user = useSelector((state: RootState) => state.user.currentUser);
const cart = useSelector((state: RootState) => state.cart.items);
const handleAddToCart = (product: Product) => {
if (!user) {
alert('Please login first');
return;
}
dispatch(addToCart(product));
};
return (
<div>
<p>Cart: {cart.length} items</p>
<button onClick={() => handleAddToCart({ id: 1, name: 'Product' })}>
Add to Cart
</button>
</div>
);
}
// Redux Integration Test
describe('Redux Integration', () => {
function renderWithStore(component: React.ReactElement) {
const store = configureStore({
reducer: {
user: userReducer,
cart: cartReducer,
},
});
return {
...render(<Provider store={store}>{component}</Provider>),
store,
};
}
it('should integrate user and cart state', async () => {
const user = userEvent.setup();
const { store } = renderWithStore(<ShoppingPage />);
// 未登录时不能添加
await user.click(screen.getByText('Add to Cart'));
expect(screen.getByText('Cart: 0 items')).toBeInTheDocument();
// 登录
store.dispatch(setUser({ id: 1, name: 'John' }));
// 登录后可以添加
await user.click(screen.getByText('Add to Cart'));
expect(screen.getByText('Cart: 1 items')).toBeInTheDocument();
});
});Thunk集成测试
typescript
// userThunks.ts
export const fetchUserProfile = createAsyncThunk(
'user/fetchProfile',
async (userId: number) => {
const response = await api.get(`/users/${userId}`);
return response.data;
}
);
// UserProfile.tsx
function UserProfile({ userId }: { userId: number }) {
const dispatch = useDispatch();
const { user, loading, error } = useSelector((state: RootState) => state.user);
useEffect(() => {
dispatch(fetchUserProfile(userId));
}, [userId, dispatch]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return <div>{user.name}</div>;
}
// Thunk Integration Test
describe('Thunk Integration', () => {
it('should fetch user profile', async () => {
const mockApi = {
get: jest.fn().mockResolvedValue({
data: { id: 1, name: 'John' },
}),
};
const store = configureStore({
reducer: { user: userReducer },
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
thunk: { extraArgument: mockApi },
}),
});
render(
<Provider store={store}>
<UserProfile userId={1} />
</Provider>
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
});
expect(mockApi.get).toHaveBeenCalledWith('/users/1');
});
});React Router集成测试
路由导航测试
typescript
// App.tsx
function App() {
return (
<Router>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/users">Users</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/users" element={<UserList />} />
<Route path="/users/:id" element={<UserDetail />} />
</Routes>
</Router>
);
}
// Router Integration Test
describe('Router Integration', () => {
it('should navigate between routes', async () => {
const user = userEvent.setup();
render(<App />, { wrapper: MemoryRouter });
// 初始页面
expect(screen.getByText(/home/i)).toBeInTheDocument();
// 导航到About
await user.click(screen.getByText('About'));
expect(screen.getByText(/about/i)).toBeInTheDocument();
// 导航到Users
await user.click(screen.getByText('Users'));
expect(screen.getByText(/users/i)).toBeInTheDocument();
});
it('should handle URL parameters', async () => {
render(
<MemoryRouter initialEntries={['/users/123']}>
<Routes>
<Route path="/users/:id" element={<UserDetail />} />
</Routes>
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/user 123/i)).toBeInTheDocument();
});
});
});受保护路由测试
typescript
// ProtectedRoute.tsx
function ProtectedRoute({ children }: { children: React.ReactElement }) {
const { user } = useAuth();
if (!user) {
return <Navigate to="/login" replace />;
}
return children;
}
// Protected Route Integration Test
describe('Protected Route Integration', () => {
function renderWithAuth(
component: React.ReactElement,
{ user = null } = {}
) {
return render(
<AuthProvider initialUser={user}>
<MemoryRouter>
{component}
</MemoryRouter>
</AuthProvider>
);
}
it('should redirect to login when not authenticated', () => {
renderWithAuth(
<Routes>
<Route path="/" element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
} />
<Route path="/login" element={<Login />} />
</Routes>
);
expect(screen.getByText(/login/i)).toBeInTheDocument();
});
it('should show protected content when authenticated', () => {
renderWithAuth(
<Routes>
<Route path="/" element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
} />
</Routes>,
{ user: { id: 1, name: 'John' } }
);
expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
});
});React Query集成测试
Query集成
typescript
// UserList.tsx
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>
);
}
// React Query Integration Test
describe('React Query Integration', () => {
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
it('should fetch and display users', async () => {
(fetchUsers as jest.Mock).mockResolvedValue([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
]);
render(<UserList />, { wrapper: createWrapper() });
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
expect(screen.getByText('Jane')).toBeInTheDocument();
});
});
});Mutation集成
typescript
// CreateUser.tsx
function CreateUser() {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setName('');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({ name });
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create'}
</button>
{mutation.isError && <div>Error: {mutation.error.message}</div>}
</form>
);
}
// Mutation Integration Test
describe('Mutation Integration', () => {
it('should create user and update list', async () => {
const user = userEvent.setup();
const queryClient = new QueryClient();
(fetchUsers as jest.Mock).mockResolvedValue([
{ id: 1, name: 'John' },
]);
(createUser as jest.Mock).mockResolvedValue({
id: 2,
name: 'Jane',
});
render(
<QueryClientProvider client={queryClient}>
<UserList />
<CreateUser />
</QueryClientProvider>
);
// 等待初始数据加载
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
});
// 创建新用户
await user.type(screen.getByRole('textbox'), 'Jane');
await user.click(screen.getByText('Create'));
// 验证加载状态
expect(screen.getByText('Creating...')).toBeInTheDocument();
// 验证列表更新
(fetchUsers as jest.Mock).mockResolvedValue([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
]);
await waitFor(() => {
expect(screen.getByText('Jane')).toBeInTheDocument();
});
});
});表单集成测试
复杂表单验证
typescript
// RegistrationForm.tsx
function RegistrationForm({ onSubmit }: {
onSubmit: (data: FormData) => Promise<void>;
}) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
watch,
} = useForm<FormData>();
const password = watch('password');
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email',
},
})}
placeholder="Email"
/>
{errors.email && <span>{errors.email.message}</span>}
<input
type="password"
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
placeholder="Password"
/>
{errors.password && <span>{errors.password.message}</span>}
<input
type="password"
{...register('confirmPassword', {
validate: (value) =>
value === password || 'Passwords do not match',
})}
placeholder="Confirm Password"
/>
{errors.confirmPassword && (
<span>{errors.confirmPassword.message}</span>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Registering...' : 'Register'}
</button>
</form>
);
}
// Form Integration Test
describe('RegistrationForm Integration', () => {
it('should validate and submit form', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn().mockResolvedValue(undefined);
render(<RegistrationForm onSubmit={handleSubmit} />);
// 提交空表单
await user.click(screen.getByText('Register'));
expect(await screen.findByText('Email is required')).toBeInTheDocument();
expect(screen.getByText('Password is required')).toBeInTheDocument();
// 填写无效邮箱
await user.type(screen.getByPlaceholderText('Email'), 'invalid');
await user.click(screen.getByText('Register'));
expect(await screen.findByText('Invalid email')).toBeInTheDocument();
// 填写有效表单
await user.clear(screen.getByPlaceholderText('Email'));
await user.type(screen.getByPlaceholderText('Email'), 'test@example.com');
await user.type(screen.getByPlaceholderText('Password'), 'password123');
await user.type(screen.getByPlaceholderText('Confirm Password'), 'password123');
await user.click(screen.getByText('Register'));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
confirmPassword: 'password123',
}, expect.anything());
});
});
it('should validate password match', async () => {
const user = userEvent.setup();
render(<RegistrationForm onSubmit={jest.fn()} />);
await user.type(screen.getByPlaceholderText('Password'), 'password123');
await user.type(screen.getByPlaceholderText('Confirm Password'), 'different');
await user.click(screen.getByText('Register'));
expect(await screen.findByText('Passwords do not match'))
.toBeInTheDocument();
});
});API集成测试
完整数据流测试
typescript
// UserManagement.tsx
function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchUsers = async () => {
setLoading(true);
setError(null);
try {
const response = await api.get('/users');
setUsers(response.data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const createUser = async (data: CreateUserData) => {
const response = await api.post('/users', data);
setUsers([...users, response.data]);
};
const deleteUser = async (id: number) => {
await api.delete(`/users/${id}`);
setUsers(users.filter(u => u.id !== id));
};
useEffect(() => {
fetchUsers();
}, []);
return (
<div>
{loading && <div>Loading...</div>}
{error && <div>Error: {error}</div>}
<CreateUserForm onSubmit={createUser} />
<ul>
{users.map(user => (
<li key={user.id}>
{user.name}
<button onClick={() => deleteUser(user.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
// API Integration Test
describe('UserManagement API Integration', () => {
beforeEach(() => {
(api.get as jest.Mock).mockClear();
(api.post as jest.Mock).mockClear();
(api.delete as jest.Mock).mockClear();
});
it('should complete full CRUD flow', async () => {
const user = userEvent.setup();
// Mock initial fetch
(api.get as jest.Mock).mockResolvedValue({
data: [{ id: 1, name: 'John' }],
});
render(<UserManagement />);
// 验证初始加载
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
});
// 创建用户
(api.post as jest.Mock).mockResolvedValue({
data: { id: 2, name: 'Jane' },
});
await user.type(screen.getByPlaceholderText('Name'), 'Jane');
await user.click(screen.getByText('Create'));
await waitFor(() => {
expect(screen.getByText('Jane')).toBeInTheDocument();
});
expect(api.post).toHaveBeenCalledWith('/users', { name: 'Jane' });
// 删除用户
(api.delete as jest.Mock).mockResolvedValue({});
const deleteButtons = screen.getAllByText('Delete');
await user.click(deleteButtons[1]);
await waitFor(() => {
expect(screen.queryByText('Jane')).not.toBeInTheDocument();
});
expect(api.delete).toHaveBeenCalledWith('/users/2');
});
});测试辅助工具
自定义渲染函数
typescript
// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
initialState?: Partial<RootState>;
store?: ReturnType<typeof configureStore>;
queryClient?: QueryClient;
}
export function renderWithProviders(
ui: React.ReactElement,
{
initialState = {},
store = configureStore({ reducer, preloadedState: initialState }),
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }),
...renderOptions
}: CustomRenderOptions = {}
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
{children}
</BrowserRouter>
</QueryClientProvider>
</Provider>
);
}
return {
store,
queryClient,
...render(ui, { wrapper: Wrapper, ...renderOptions }),
};
}
// 使用
test('integrated component', () => {
const { store } = renderWithProviders(<App />, {
initialState: {
user: { currentUser: { id: 1, name: 'John' } },
},
});
// 测试逻辑
});最佳实践
隔离测试环境
typescript
// ✅ 每个测试独立的环境
describe('Integration tests', () => {
let store: ReturnType<typeof configureStore>;
let queryClient: QueryClient;
beforeEach(() => {
store = configureStore({ reducer });
queryClient = new QueryClient();
});
afterEach(() => {
queryClient.clear();
});
});真实用户场景
typescript
// ✅ 测试完整的用户流程
test('user shopping flow', async () => {
const user = userEvent.setup();
renderWithProviders(<App />);
// 1. 浏览商品
await user.click(screen.getByText('Products'));
// 2. 添加到购物车
await user.click(screen.getByText('Add to Cart'));
// 3. 查看购物车
await user.click(screen.getByText('Cart'));
// 4. 结账
await user.click(screen.getByText('Checkout'));
// 5. 填写信息
await user.type(screen.getByLabelText('Address'), '123 Main St');
await user.click(screen.getByText('Place Order'));
// 6. 验证订单创建
await waitFor(() => {
expect(screen.getByText(/order confirmed/i)).toBeInTheDocument();
});
});集成测试是确保React应用各部分协同工作的关键。通过合理的测试策略和工具选择,可以有效验证组件集成、数据流和业务流程,提升应用质量。