Skip to content

集成测试策略

概述

集成测试验证多个模块或组件协同工作的正确性。与单元测试专注于单个模块不同,集成测试关注组件间的交互、数据流和业务流程。本文将介绍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应用各部分协同工作的关键。通过合理的测试策略和工具选择,可以有效验证组件集成、数据流和业务流程,提升应用质量。