集成测试实战
软件测试理论https://blog.csdn.net/2402_88266590/article/details/160966638?spm1011.2415.3001.5331单元测试实战https://blog.csdn.net/2402_88266590/article/details/161017518?spm1001.2014.3001.5502下面就开始进入集成测试的学习吧1. 集成测试是什么1.1 导入小明造了一辆车每个零件都单独测试过发动机测试一通电就转车轮测试在地上滚得顺方向盘测试左右转动灵活但把零件组装起来后车却开不动。为什么因为零件之间的配合出了问题发动机的传动轴和车轮的接口不匹配方向盘的连杆没接上轮胎单元测试测单个零件发现不了这些问题。集成测试测零件组装才能发现。1.2 三种测试对比测试类型测什么类比速度定位问题单元测试一个函数/组件测发动机很快精确到行集成测试多个模块配合测发动机传动轴车轮较快模块间E2E测试完整用户流程测整车驾驶员慢全流程集成测试的价值在单元测试和 E2E 之间取得平衡。它比单元测试更真实比 E2E 更快、更稳定。1.3 测什么场景示例组件之间通信父组件通过props传数据给子组件子组件通过emit/onClick回传事件条件渲染用户点击“展开”后多出的内容是否显示API 请求 UI点击“加载”显示 loading数据回来后渲染列表路由跳转点击链接页面 URL 改变内容更新状态管理组件 A 修改了 store组件 B 是否响应更新1.4 环境准备基于 Vitest Testing Library1. 安装依赖假设你已有 Vite React 项目npm install -D vitest testing-library/react testing-library/jest-dom testing-library/user-event jsdom2. 配置 Vitest// vitest.config.js import { defineConfig } from vitest/config import react from vitejs/plugin-react export default defineConfig({ plugins: [react()], test: { globals: true, // 全局使用 describe、it、expect environment: jsdom, // 模拟浏览器环境 setupFiles: ./src/test/setup.js, }, })// src/test/setup.js import { expect, afterEach } from vitest import { cleanup } from testing-library/react import testing-library/jest-dom/vitest // 每个测试后自动清理渲染的组件 afterEach(() { cleanup() })3. 第一个集成测试// src/components/Greeting.jsx export function Greeting({ name }) { return h1Hello, {name}!/h1 }// src/components/Greeting.test.jsx import { render, screen } from testing-library/react import { Greeting } from ./Greeting it(显示正确的问候语, () { render(Greeting name张三 /) expect(screen.getByText(Hello, 张三!)).toBeInTheDocument() })运行测试npx vitest2. 测试组件之间的通信2.1 父子组件 props 传递父组件给子组件传数据集成测试验证子组件收到了正确的值。// Parent.jsx import { Child } from ./Child export function Parent() { const user { name: 张三, age: 18 } return Child user{user} / } // Child.jsx export function Child({ user }) { return ( div span>// Parent.test.jsx import { render, screen } from testing-library/react import { Parent } from ./Parent it(向子组件传递了正确的 user, () { render(Parent /) expect(screen.getByTestId(name)).toHaveTextContent(张三) expect(screen.getByTestId(age)).toHaveTextContent(18) })2.2 子组件 emit 事件子组件通过调用父组件传来的回调函数来通知父组件。// Parent.jsx import { useState } from react import { Child } from ./Child export function Parent() { const [count, setCount] useState(0) return ( div p>// Parent.test.jsx import { render, screen } from testing-library/react import userEvent from testing-library/user-event import { Parent } from ./Parent it(点击子组件的按钮后父组件的计数增加, async () { render(Parent /) const button screen.getByRole(button, { name: 1 }) await userEvent.click(button) expect(screen.getByTestId(count)).toHaveTextContent(1) })2.3 测试组件的条件渲染// Toggle.jsx export function Toggle() { const [show, setShow] useState(false) return ( div button onClick{() setShow(!show)}切换/button {show p>// Toggle.test.jsx import { render, screen } from testing-library/react import userEvent from testing-library/user-event import { Toggle } from ./Toggle it(点击按钮后显示/隐藏内容, async () { render(Toggle /) const button screen.getByRole(button, { name: 切换 }) // 初始状态内容不可见 expect(screen.queryByTestId(content)).not.toBeInTheDocument() // 第一次点击显示 await userEvent.click(button) expect(screen.getByTestId(content)).toBeInTheDocument() // 第二次点击隐藏 await userEvent.click(button) expect(screen.queryByTestId(content)).not.toBeInTheDocument() })注意用queryByTestId而不是getByTestId因为元素不存在时getByTestId会抛错。2.4 模拟子组件简化测试当子组件很复杂时可以在集成测试里用vi.mock模拟它只测父组件的组装逻辑。// ProductList.jsx import { ProductCard } from ./ProductCard export function ProductList({ products }) { return ( div {products.map(p ( ProductCard key{p.id} product{p} / ))} /div ) }// ProductList.test.jsx import { render, screen } from testing-library/react import { vi } from vitest import { ProductList } from ./ProductList // 模拟 ProductCard 组件 vi.mock(./ProductCard, () ({ ProductCard: ({ product }) div>// UserProfile.jsx import { useEffect, useState } from react export function UserProfile({ userId }) { const [user, setUser] useState(null) const [loading, setLoading] useState(true) const [error, setError] useState(null) useEffect(() { fetch(/api/users/${userId}) .then(res res.json()) .then(data { setUser(data) setLoading(false) }) .catch(err { setError(err.message) setLoading(false) }) }, [userId]) if (loading) return div加载中.../div if (error) return div错误{error}/div return div>// UserProfile.test.jsx import { render, screen, waitFor } from testing-library/react import { UserProfile } from ./UserProfile // 模拟全局 fetch beforeEach(() { global.fetch vi.fn() }) afterEach(() { vi.resetAllMocks() }) it(成功加载用户数据, async () { const mockUser { name: 张三 } global.fetch.mockResolvedValue({ ok: true, json: async () mockUser, }) render(UserProfile userId{1} /) // 等待加载完成 await waitFor(() { expect(screen.getByTestId(user-name)).toHaveTextContent(张三) }) expect(global.fetch).toHaveBeenCalledWith(/api/users/1) })2. 模拟 axios// 使用 axios 的版本 import axios from axios vi.mock(axios) const mockedAxios axios mockedAxios.get.mockResolvedValue({ data: { name: 李四 } })3.2 测试加载状态 loading组件在请求发出后、数据返回前应展示 loading 指示。it(展示加载状态, () { // 让请求不立即完成模拟 pending 状态 global.fetch.mockImplementation(() new Promise(() {})) render(UserProfile userId{1} /) expect(screen.getByText(加载中...)).toBeInTheDocument() })3.3 测试错误状态 error模拟网络失败或服务端返回错误。it(展示错误信息, async () { global.fetch.mockRejectedValue(new Error(网络错误)) render(UserProfile userId{1} /) await waitFor(() { expect(screen.getByText(/错误网络错误/)).toBeInTheDocument() }) })3.4 测试成功渲染数据前面已示例关键是用waitFor等待异步数据渲染。it(成功渲染用户数据, async () { global.fetch.mockResolvedValue({ ok: true, json: async () ({ name: 王五 }), }) render(UserProfile userId{2} /) await waitFor(() { expect(screen.getByTestId(user-name)).toHaveTextContent(王五) }) })4. 测试路由跳转4.1 集成测试中的路由模拟测试路由跳转需要提供一个内存路由避免真实浏览器 URL 变化。使用MemoryRouter或类似工具。npm install -D react-router-dom// NavBar.jsx import { Link } from react-router-dom export function NavBar() { return ( nav Link to/首页/Link Link to/about关于/Link /nav ) }// NavBar.test.jsx import { render, screen } from testing-library/react import { MemoryRouter } from react-router-dom import userEvent from testing-library/user-event import { NavBar } from ./NavBar it(点击链接后 URL 变化, async () { render( MemoryRouter initialEntries{[/]} NavBar / /MemoryRouter ) const aboutLink screen.getByRole(link, { name: 关于 }) await userEvent.click(aboutLink) // 验证当前 URL 是否是 /about需要配合 Routes 才能看到效果 // 更常见是检查页面内容变化 })4.2 点击链接后 URL 变化要验证 URL 变化通常检查对应的页面组件是否被渲染。// AppRoutes.jsx import { Routes, Route } from react-router-dom import { Home } from ./Home import { About } from ./About export function AppRoutes() { return ( Routes Route path/ element{Home /} / Route path/about element{About /} / /Routes ) }// AppRoutes.test.jsx import { render, screen } from testing-library/react import { MemoryRouter } from react-router-dom import userEvent from testing-library/user-event import { AppRoutes } from ./AppRoutes it(点击关于链接后显示 About 页面, async () { render( MemoryRouter initialEntries{[/]} AppRoutes / nav a href/about关于/a /nav /MemoryRouter ) const aboutLink screen.getByRole(link, { name: 关于 }) await userEvent.click(aboutLink) expect(screen.getByText(/关于页面/)).toBeInTheDocument() })4.3 编程式导航 useNavigate测试useNavigate需模拟navigate函数或验证组件行为。// LoginButton.jsx import { useNavigate } from react-router-dom export function LoginButton() { const navigate useNavigate() const handleLogin () { // 登录逻辑... navigate(/dashboard) } return button onClick{handleLogin}登录/button }测试方法不直接断言navigate被调用而是验证点击后页面渲染了目标内容需要配合 Router。import { render, screen } from testing-library/react import { MemoryRouter } from react-router-dom import userEvent from testing-library/user-event import { LoginButton } from ./LoginButton import { Dashboard } from ./Dashboard it(登录后跳转到仪表盘, async () { render( MemoryRouter initialEntries{[/login]} Routes Route path/login element{LoginButton /} / Route path/dashboard element{Dashboard /} / /Routes /MemoryRouter ) const loginBtn screen.getByRole(button, { name: 登录 }) await userEvent.click(loginBtn) expect(screen.getByText(/仪表盘/)).toBeInTheDocument() })4.4 路由参数与查询参数测试带参数的路由组件从useParams或useSearchParams读取数据。// UserDetail.jsx import { useParams } from react-router-dom export function UserDetail() { const { id } useParams() return div用户ID{id}/div }测试时通过initialEntries传入带参数的路径。import { render, screen } from testing-library/react import { MemoryRouter } from react-router-dom it(显示正确的用户 ID, () { render( MemoryRouter initialEntries{[/user/123]} UserDetail / /MemoryRouter ) expect(screen.getByText(用户ID123)).toBeInTheDocument() })测试查询参数// SearchPage.jsx import { useSearchParams } from react-router-dom export function SearchPage() { const [searchParams] useSearchParams() const keyword searchParams.get(q) return div搜索关键词{keyword}/div } it(显示查询参数中的关键词, () { render( MemoryRouter initialEntries{[/search?qvue]} SearchPage / /MemoryRouter ) expect(screen.getByText(搜索关键词vue)).toBeInTheDocument() })5. 测试状态管理Pinia / Zustand / Redux5.1 测试 Store 的 action核心原则单独测试 Store 的逻辑不需要渲染组件。1. 以 Pinia 为例Vue// stores/counter.js import { defineStore } from pinia export const useCounterStore defineStore(counter, { state: () ({ count: 0 }), actions: { increment() { this.count }, incrementBy(amount) { this.count amount }, }, })// stores/counter.test.js import { setActivePinia, createPinia } from pinia import { useCounterStore } from ./counter beforeEach(() { setActivePinia(createPinia()) }) it(increment 增加 count, () { const store useCounterStore() store.increment() expect(store.count).toBe(1) }) it(incrementBy 增加指定数量, () { const store useCounterStore() store.incrementBy(5) expect(store.count).toBe(5) })2. 以 Zustand 为例React// stores/useCartStore.js import { create } from zustand const useCartStore create((set) ({ items: [], addItem: (item) set((state) ({ items: [...state.items, item] })), removeItem: (id) set((state) ({ items: state.items.filter(i i.id ! id) })), clearCart: () set({ items: [] }), })) export default useCartStore// stores/useCartStore.test.js import useCartStore from ./useCartStore beforeEach(() { // 每次测试前重置 store 状态 useCartStore.setState({ items: [] }) }) it(addItem 添加商品, () { const { addItem, items } useCartStore.getState() addItem({ id: 1, name: 苹果 }) expect(useCartStore.getState().items).toHaveLength(1) expect(useCartStore.getState().items[0].name).toBe(苹果) })3. 以 Redux Toolkit 为例// store/counterSlice.js import { createSlice } from reduxjs/toolkit const counterSlice createSlice({ name: counter, initialState: { value: 0 }, reducers: { increment: (state) { state.value }, decrement: (state) { state.value-- }, }, }) export const { increment, decrement } counterSlice.actions export default counterSlice.reducer// store/counterSlice.test.js import counterReducer, { increment, decrement } from ./counterSlice it(increment, () { const initialState { value: 0 } const nextState counterReducer(initialState, increment()) expect(nextState.value).toBe(1) })5.2 测试组件中使用 Store验证组件从 Store 读取数据并正确渲染。// CounterDisplay.jsx (Zustand) import useCounterStore from ./stores/useCounterStore export function CounterDisplay() { const count useCounterStore((state) state.count) return div>// CounterDisplay.test.jsx import { render, screen } from testing-library/react import useCounterStore from ./stores/useCounterStore import { CounterDisplay } from ./CounterDisplay // 预设 store 初始值 beforeEach(() { useCounterStore.setState({ count: 10 }) }) it(显示 store 中的 count, () { render(CounterDisplay /) expect(screen.getByTestId(count)).toHaveTextContent(10) })5.3 Mock Store 进行隔离测试当组件依赖复杂的 Store 时可以 Mock Store 只提供测试所需的最小数据。// UserAvatar.jsx import useUserStore from ./stores/useUserStore export function UserAvatar() { const user useUserStore((state) state.user) return img src{user?.avatar} alt{user?.name} / }// UserAvatar.test.jsx import { render, screen } from testing-library/react import { UserAvatar } from ./UserAvatar // 模拟 useUserStore vi.mock(./stores/useUserStore, () ({ default: (selector) selector({ user: { name: 张三, avatar: /avatar.jpg } }) })) it(显示用户头像, () { render(UserAvatar /) const img screen.getByRole(img) expect(img).toHaveAttribute(src, /avatar.jpg) expect(img).toHaveAttribute(alt, 张三) })5.4 测试异步 actionStore 中常见的异步操作如请求用户数据。1. Pinia 异步 actionexport const useUserStore defineStore(user, { state: () ({ user: null, loading: false }), actions: { async fetchUser(id) { this.loading true try { const res await fetch(/api/users/${id}) this.user await res.json() } finally { this.loading false } }, }, })// 测试异步 action import { setActivePinia, createPinia } from pinia import { useUserStore } from ./user beforeEach(() { setActivePinia(createPinia()) }) it(fetchUser 成功获取用户, async () { const store useUserStore() global.fetch vi.fn().mockResolvedValue({ json: async () ({ id: 1, name: 张三 }), }) await store.fetchUser(1) expect(store.user).toEqual({ id: 1, name: 张三 }) expect(store.loading).toBe(false) })6. 测试表单与用户输入6.1 填写表单字段使用userEvent.type模拟用户输入。// LoginForm.jsx export function LoginForm({ onSubmit }) { return ( form onSubmit{(e) { e.preventDefault() const formData new FormData(e.target) onSubmit({ username: formData.get(username), password: formData.get(password), }) }} input nameusername placeholder用户名 / input namepassword typepassword placeholder密码 / button typesubmit登录/button /form ) }// LoginForm.test.jsx import { render, screen } from testing-library/react import userEvent from testing-library/user-event import { LoginForm } from ./LoginForm it(填写表单并提交, async () { const mockSubmit vi.fn() render(LoginForm onSubmit{mockSubmit} /) await userEvent.type(screen.getByPlaceholderText(用户名), admin) await userEvent.type(screen.getByPlaceholderText(密码), 123456) await userEvent.click(screen.getByRole(button, { name: 登录 })) expect(mockSubmit).toHaveBeenCalledWith({ username: admin, password: 123456, }) })6.2 提交表单与验证测试表单字段的校验逻辑非空、格式等。// RegistrationForm.jsx import { useState } from react export function RegistrationForm({ onSubmit }) { const [errors, setErrors] useState({}) const handleSubmit (e) { e.preventDefault() const formData new FormData(e.target) const email formData.get(email) const password formData.get(password) const newErrors {} if (!email) newErrors.email 邮箱不能为空 if (!password) newErrors.password 密码不能为空 if (Object.keys(newErrors).length 0) { setErrors(newErrors) return } onSubmit({ email, password }) } return ( form onSubmit{handleSubmit} input nameemail placeholder邮箱 / {errors.email span>// RegistrationForm.test.jsx it(提交空表单显示错误, async () { render(RegistrationForm onSubmit{vi.fn()} /) const submitBtn screen.getByRole(button, { name: 注册 }) await userEvent.click(submitBtn) expect(screen.getByTestId(email-error)).toHaveTextContent(邮箱不能为空) expect(screen.getByTestId(password-error)).toHaveTextContent(密码不能为空) })6.3 测试表单错误提示除了前端校验还要测试后端返回的错误如“用户名已存在”。// 提交时调用 onSubmit如果返回错误则显示 export function RegisterForm({ onSubmit }) { const [serverError, setServerError] useState() const handleSubmit async (e) { e.preventDefault() const formData new FormData(e.target) const result await onSubmit(formData) if (result?.error) { setServerError(result.error) } } return ( form onSubmit{handleSubmit} input nameusername placeholder用户名 / button typesubmit注册/button {serverError div>it(显示服务器返回的错误, async () { const mockOnSubmit vi.fn().mockResolvedValue({ error: 用户名已被占用 }) render(RegisterForm onSubmit{mockOnSubmit} /) await userEvent.type(screen.getByPlaceholderText(用户名), existingUser) await userEvent.click(screen.getByRole(button, { name: 注册 })) expect(await screen.findByTestId(server-error)).toHaveTextContent(用户名已被占用) })6.4 测试自定义校验规则如密码强度、邮箱格式等可以单独测试校验函数。// validators.js export function validateEmail(email) { if (!email) return 邮箱不能为空 if (!email.includes()) return 邮箱格式不正确 return null } export function validatePassword(password) { if (!password) return 密码不能为空 if (password.length 6) return 密码至少6位 return null }// validators.test.js import { validateEmail, validatePassword } from ./validators it(邮箱校验, () { expect(validateEmail()).toBe(邮箱不能为空) expect(validateEmail(abc)).toBe(邮箱格式不正确) expect(validateEmail(ab.com)).toBe(null) }) it(密码校验, () { expect(validatePassword()).toBe(密码不能为空) expect(validatePassword(123)).toBe(密码至少6位) expect(validatePassword(123456)).toBe(null) })7. Mock 全局依赖7.1 模拟 localStoragelocalStorage是浏览器 API测试环境中不存在。需要手动模拟或在setup文件中注入。1. 简单手动模拟// 在测试文件内模拟 beforeEach(() { let store {} global.localStorage { getItem: vi.fn((key) store[key] || null), setItem: vi.fn((key, value) { store[key] value.toString() }), removeItem: vi.fn((key) { delete store[key] }), clear: vi.fn(() { store {} }), } }) afterEach(() { vi.restoreAllMocks() }) it(存储并读取 token, () { localStorage.setItem(token, abc123) expect(localStorage.getItem(token)).toBe(abc123) expect(localStorage.setItem).toHaveBeenCalledWith(token, abc123) })2. 测试使用 localStorage 的组件// useTheme.jsx import { useEffect, useState } from react export function useTheme() { const [theme, setTheme] useState(() localStorage.getItem(theme) || light) useEffect(() { localStorage.setItem(theme, theme) }, [theme]) return [theme, setTheme] }// useTheme.test.jsx import { renderHook, act } from testing-library/react import { useTheme } from ./useTheme describe(useTheme, () { let store {} beforeEach(() { store {} global.localStorage { getItem: vi.fn((key) store[key] || null), setItem: vi.fn((key, value) { store[key] value }), } }) it(初始值从 localStorage 读取, () { store.theme dark const { result } renderHook(() useTheme()) expect(result.current[0]).toBe(dark) }) it(切换主题后保存到 localStorage, () { const { result } renderHook(() useTheme()) act(() { result.current[1](dark) }) expect(localStorage.setItem).toHaveBeenCalledWith(theme, dark) }) })7.2 模拟 window.locationlocation是只读属性直接赋值会出错。需要先删除再模拟。1. 模拟 href 跳转// 模拟 window.location beforeEach(() { delete window.location window.location { href: , assign: vi.fn(), replace: vi.fn() } }) it(点击链接后跳转, async () { render(RedirectButton /) const btn screen.getByRole(button) await userEvent.click(btn) expect(window.location.href).toBe(/target) })2. 测试路由跳转不触发页面刷新使用remix-run/router或MemoryRouter是更好的做法但如果必须测试window.location.href可以用上述方法。7.3 模拟定时器与日期1. 模拟 setTimeout / setIntervalimport { vi, it, expect, beforeEach, afterEach } from vitest function debounce(fn, delay) { let timer return (...args) { clearTimeout(timer) timer setTimeout(() fn(...args), delay) } } it(防抖函数在延迟后执行, () { vi.useFakeTimers() const fn vi.fn() const debounced debounce(fn, 1000) debounced() expect(fn).not.toHaveBeenCalled() vi.advanceTimersByTime(500) expect(fn).not.toHaveBeenCalled() vi.advanceTimersByTime(500) expect(fn).toHaveBeenCalledTimes(1) vi.useRealTimers() })2. 模拟日期it(固定当前时间, () { vi.useFakeTimers() vi.setSystemTime(new Date(2024-01-01T00:00:00Z)) const now new Date() expect(now.toISOString()).toBe(2024-01-01T00:00:00.000Z) vi.useRealTimers() })7.4 模拟第三方 SDK如 Google Analytics不真的发送统计请求只验证调用了正确的 API。// analytics.js export const ga { sendEvent: (category, action, label) { if (typeof gtag ! undefined) { gtag(event, action, { event_category: category, event_label: label }) } }, }// analytics.test.js import { ga } from ./analytics // 模拟全局 gtag beforeEach(() { window.gtag vi.fn() }) it(调用 gtag 发送事件, () { ga.sendEvent(button, click, login) expect(window.gtag).toHaveBeenCalledWith(event, click, { event_category: button, event_label: login, }) })模拟整个 SDK 模块vi.mock(analytics/google-analytics, () ({ init: vi.fn(), track: vi.fn(), }))8. 附录8.1 Testing Library 常用查询速查表查询方法返回找不到时getByText元素抛错getByRole元素抛错getByLabelText元素抛错getByPlaceholderText元素抛错getByTestId元素抛错queryByText元素或 null返回 nullfindByTextPromise超时抛错用于异步优先级推荐getByRole最符合无障碍getByLabelText表单字段getByPlaceholderTextgetByTextgetByTestId最后的选择8.2 常见异步等待场景场景写法等待元素出现await screen.findByText(加载完成)等待元素消失await waitForElementToBeRemoved(() screen.getByText(加载中))等待自定义条件await waitFor(() expect(fn).toHaveBeenCalled())用户操作后等待 UIawait userEvent.click(btn); await screen.findByText(成功)8.3 常见错误与解决错误原因解决Unable to find rolebutton元素没有正确的可访问性角色给按钮添加aria-label或使用更宽松的查询The element(s) given to waitForElementToBeRemoved元素已经不存在了先检查元素是否真的存在Timed out after 1000ms异步操作太慢增加timeout或检查 mocklocalStorage is not defined测试环境无 localStorage手动 mockwindow.location is not configurablelocation 只读先delete window.location再赋值8.4 推荐资源1. 官方文档Testing Library: https://testing-library.com/Vitest: https://vitest.dev/Jest: https://jestjs.io/2. 视频/文章Common mistakes with React Testing Library – Kent C. DoddsTesting Implementation Details – 文章讨论为什么测试用户行为而不是实现细节3. 练习项目官方 Testing Playground: https://testing-playground.com/GitHub 上搜索vitest或testing-library的示例项目