找回密码
 立即注册
首页 业界区 业界 为React组件库引入自动化测试:从零到完善的实践之路 ...

为React组件库引入自动化测试:从零到完善的实践之路

凤患更 2025-6-2 23:10:00
为什么我们需要测试?

我们的 React+TypeScript 业务组件库已经稳定运行了一段时间,主要承载各类UI展示组件,如卡片、通知等。项目初期,迫于紧张的开发周期,我们暂时搁置了自动化测试的引入。当时团队成员对组件逻辑了如指掌,即便没有测试也能游刃有余。
然而随着时间推移,问题逐渐显现。当新成员加入或老组件需要迭代时,我们常常陷入两难:修改代码可能破坏原有功能,但不修改又无法满足新需求。特别是在处理那些具有多种交互状态的复杂组件时,手动测试变得既耗时又不可靠。这时,引入自动化测试的必要性就凸显出来了。
搭建测试环境

依赖安装

我们首先从安装核心测试依赖开始,这些工具将构成我们测试体系的基础框架:

  • 测试运行核心:jest和jsdom环境包
  • TypeScript支持:确保类型安全的测试环境
  • React测试工具:专门为React组件设计的测试工具链
  1. npm install jest jest-environment-jsdom @types/jest ts-jest @testing-library/react @testing-library/jest-dom @testing-library/user-event --save-dev
复制代码
配置Jest

创建jest.config.ts配置文件时,有几个关注点:

  • 针对TypeScript项目的特殊处理
  • 浏览器环境的模拟
  • 测试初始化流程
  • 文件转换规则
  1. module.exports = {
  2.   preset: "ts-jest", // 为 TypeScript 项目准备的 Jest 配置预设
  3.   testEnvironment: "jsdom", // 测试运行在模拟的浏览器环境中
  4.   setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"], // 指定在测试环境初始化后立即执行的文件
  5.   transform: {
  6.     "^.+\\.(ts|tsx)$": "ts-jest", // 使用 ts-jest 处理所有 .ts 和 .tsx 文件
  7.   },
  8.   testPathIgnorePatterns: ["/node_modules/", "/dist/"], // 忽略指定目录下的测试文件
  9.   moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], // 定义 Jest 能识别的模块文件扩展名
  10. };
  11. export {}; // 使文件成为模块
复制代码
创建jest.setup.ts文件引入断言库:
  1. import "@testing-library/jest-dom";
复制代码
TypeScript配置

修改tsconfig.json包含测试相关文件:
  1. {
  2.   "include": [
  3.     "src",
  4.     "jest.config.ts",
  5.     "jest.setup.ts",
  6.     "__mocks__/**/*.ts"
  7.   ]
  8. }
复制代码
测试用例编写

我们以一个通知组件为例,该组件有两种UI形态:

  • 标题和描述组合的时间内容文案提示
  • 带有喇叭图标的提示,点击关闭按钮时调用接口保存用户状态
    1.png

特殊依赖处理

组件中有三类特殊引入需要处理:
  1. import './index.less';
  2. import { noticeIcon, closeIcon } from "$src/common/icon";
  3. import request from "$src/request";
复制代码
1、处理 CSS/LESS 资源
Jest 默认无法解析 CSS/LESS 文件,我们可以通过配置将其模拟为空对象:
  1. // jest.config.js
  2. module.exports = {
  3.   moduleNameMapper: {
  4.     "\\.(less|css)$': '<rootDir>/__mocks__/styleMock.ts", // 指向一个空文件
  5.   },
  6. };
  7. // __mocks__/styleMock.ts
  8. module.exports = {};
复制代码
2、配置路径别名
对于 $src 这样的路径别名,需要在 Jest 配置中映射:
  1. // jest.config.js
  2. module.exports = {
  3.   moduleNameMapper: {
  4.     '^\\$src/(.*)$': '<rootDir>/src/$1',
  5.   },
  6. };
复制代码
3、模拟图标资源
对于图标这类静态资源,我们可以在测试文件中直接模拟:
  1. // __tests__/index.test.tsx
  2. jest.mock('$src/common/icon', () => ({
  3.   noticeIcon: 'notice-icon-path',
  4.   closeIcon: 'close-icon-path',
  5. }));
复制代码
4、模拟 API 请求
对于网络请求模块,我们可以将其转换为 Jest 模拟函数:
  1. // __tests__/index.test.tsx
  2. import request from '$src/request';
  3. const mockedRequest = request as jest.MockedFunction<typeof request>;
  4. jest.mock('$src/request', () => ({
  5.   __esModule: true, // 标识这是 ES Module
  6.   default: jest.fn(() => Promise.resolve({ data: {} })),
  7. }));
复制代码
通过以上配置,我们能够有效地隔离组件测试环境,专注于组件逻辑本身的测试,而不受样式、静态资源和网络请求等外部因素的影响。
基础测试框架搭建

我们首先建立测试的基本结构:
  1. describe("Notification组件", () => {
  2.   // 公共props定义
  3.   const baseProps = {
  4.     body: {},
  5.     tokenId: "test-token",
  6.     urlPrefix: "https://api.example.com",
  7.   };
  8.   // 每个测试用例前的清理工作
  9.   beforeEach(() => {
  10.     jest.clearAllMocks();
  11.     mockedRequest.mockReset();
  12.   });
  13. });
复制代码
核心测试场景覆盖

在配置好 Jest 测试环境后,我们将针对通知组件编写全面的测试用例。该组件具有两种展示形态和交互逻辑,我们将从四个关键维度进行测试覆盖:
1、边界情况测试
我们首先考虑最极端的场景——当传入无效props时,组件是否能够优雅处理:
  1. it("当传入无效body时应安全地返回null", () => {
  2.   const { container } = render(<Notification {...baseProps} body={null} />);
  3.   expect(container.firstChild).toBeNull();
  4. });
复制代码
2、日期类型展示验证
对于日期类型的通知,我们需要确认:

  • 关键文本是否正确渲染
  • DOM结构是否符合预期
  • 样式类是否准确应用
  1. it("应正确渲染日期类型通知", () => {
  2.   render(<Notification {...dateProps} />);
  3.   
  4.   expect(screen.getByText("今日公告")).toBeInTheDocument();
  5.   expect(screen.getByText("2023-06-15")).toBeInTheDocument();
  6.   
  7.   const dateContainer = screen.getByText("今日公告").parentElement;
  8.   expect(dateContainer).toHaveClass("notice-header-date");
  9. });
复制代码
3、广播类型交互测试
广播通知的测试更加复杂,需要验证:

  • 初始状态下的元素展示
  • 图标资源是否正确加载
  • 点击关闭后的行为
  1. describe("BROADCAST_TYPE 类型", () => {
  2.   const broadcastProps = {
  3.     ...baseProps,
  4.     body: {
  5.       type: BROADCAST_TYPE,
  6.       content: "重要通知内容",
  7.       closeUrl: "/close-notice",
  8.     },
  9.   };
  10.   it("初始状态下应该显示广播内容", () => {
  11.     render(<Notification {...broadcastProps} />);
  12.     // 验证内容
  13.     expect(screen.getByText("重要通知内容")).toBeInTheDocument();
  14.     // 验证图片
  15.     const images = screen.getAllByRole("img");
  16.     expect(images[0]).toHaveAttribute("src", "notice-icon-path");
  17.     expect(images[1]).toHaveAttribute("src", "close-icon-path");
  18.     // 验证类名
  19.     const broadcastContainer = screen
  20.       .getByText("重要通知内容")
  21.       .closest(".notice-header-broadcast");
  22.     expect(broadcastContainer).toBeInTheDocument();
  23.   });
  24.   it("点击关闭按钮后应该隐藏广播内容", () => {
  25.     render(<Notification {...broadcastProps} />);
  26.     // 找到关闭按钮(假设是最后一个img元素)
  27.     const closeButton = screen.getAllByRole("img")[1].parentElement;
  28.     fireEvent.click(closeButton!);
  29.     expect(screen.queryByText("重要通知内容")).not.toBeInTheDocument();
  30.   });
  31. });
复制代码
4、网络请求场景全覆盖
对于涉及API调用的场景,我们设计了多维度测试:

  • 正常请求流程
  • 无请求场景请求
  • 失败处理
  • 请求中的状态管理
  1. describe("网络请求测试", () => {
  2.   const broadcastPropsWithCloseUrl = {
  3.     ...baseProps,
  4.     body: {
  5.       type: BROADCAST_TYPE,
  6.       content: "重要通知内容",
  7.       closeUrl: "/close-notice",
  8.     },
  9.   };
  10.   const broadcastPropsWithoutCloseUrl = {
  11.     ...baseProps,
  12.     body: {
  13.       type: BROADCAST_TYPE,
  14.       content: "重要通知内容",
  15.       // 没有closeUrl
  16.     },
  17.   };
  18.   it("点击关闭时应该发送请求", async () => {
  19.     // 模拟请求成功
  20.     mockedRequest.mockResolvedValue({ data: {} });
  21.     render(<Notification {...broadcastPropsWithCloseUrl} />);
  22.     const closeButton = screen.getAllByRole("img")[1].parentElement;
  23.     await act(async () => {
  24.       fireEvent.click(closeButton!);
  25.     });
  26.     //验证请求参数
  27.     expect(request).toHaveBeenCalledWith({
  28.       url: "https://api.example.com/close-notice",
  29.       method: "post",
  30.       data: {},
  31.       headers: {
  32.         tokenId: "test-token",
  33.       },
  34.     });
  35.     // 验证UI更新
  36.     expect(screen.queryByText("重要通知内容")).not.toBeInTheDocument();
  37.   });
  38.   it("当没有closeUrl时不发送请求", async () => {
  39.     render(<Notification {...broadcastPropsWithoutCloseUrl} />);
  40.     const closeButton = screen.getAllByRole("img")[1].parentElement;
  41.     await act(async () => {
  42.       fireEvent.click(closeButton!);
  43.     });
  44.     expect(request).not.toHaveBeenCalled();
  45.     // 验证UI仍然会更新
  46.     expect(screen.queryByText("重要通知内容")).not.toBeInTheDocument();
  47.   });
  48.   it("请求失败时仍然关闭通知", async () => {
  49.     // 模拟请求失败
  50.     mockedRequest.mockResolvedValue(new Error("Request failed"));
  51.     render(<Notification {...broadcastPropsWithCloseUrl} />);
  52.     const closeButton = screen.getAllByRole("img")[1].parentElement;
  53.     await act(async () => {
  54.       fireEvent.click(closeButton!);
  55.     });
  56.     // 验证即使请求失败,UI也会更新
  57.     expect(screen.queryByText("重要通知内容")).not.toBeInTheDocument();
  58.     expect(request).toHaveBeenCalled();
  59.   });
  60.   it("请求期间UI应保持响应", async () => {
  61.     // 创建一个未立即resolve的Promise
  62.     let resolveRequest: any;
  63.     const promise = new Promise((resolve) => {
  64.       resolveRequest = resolve;
  65.     });
  66.     mockedRequest.mockReturnValue(promise);
  67.     render(<Notification {...broadcastPropsWithCloseUrl} />);
  68.     const closeButton = screen.getAllByRole("img")[1].parentElement;
  69.     // 第一次点击
  70.     fireEvent.click(closeButton!);
  71.     // 验证UI已立即更新
  72.     expect(screen.queryByText("重要通知内容")).not.toBeInTheDocument();
  73.     // 完成请求
  74.     await act(async () => {
  75.       resolveRequest({ data: {} });
  76.     });
  77. });
复制代码
测试执行与覆盖率

基础测试执行

在完成通知组件的测试用例编写后,可以在 package.json 中配置测试脚本:
  1. {
  2.   "scripts": {
  3.     "test": "jest"
  4.   }
  5. }
复制代码
执行 npm run test 命令后,如下图所示,Jest 会在终端输出测试结果,包括:

  • 测试文件数量
  • 通过的测试用例数
  • 失败的测试用例详情(包含错误堆栈信息)
2.webp

覆盖率报告配置

为了更全面地评估测试质量,可以通过修改 jest.config.ts 启用覆盖率统计:
  1. module.exports = {
  2.   collectCoverage: true, // 启用覆盖率收集
  3.   coverageDirectory: "coverage", // 指定覆盖率报告的输出目录
  4.   coverageReporters: ["text", "html", "lcov", "clover"], //指定生成的覆盖率报告格式
  5.   coverageThreshold: {
  6.     // 设置覆盖率的最低阈值,如果未达标,Jest 会报错
  7.     global: {
  8.       // 全局覆盖率要求
  9.       branches: 80,
  10.       functions: 80,
  11.       lines: 80,
  12.       statements: 80,
  13.     },
  14.     "./src/components/**/*.tsx": {
  15.       // 针对特定目录/文件设置更高要求
  16.       branches: 90,
  17.       functions: 90,
  18.       lines: 90,
  19.       statements: 90,
  20.     },
  21.   },
  22. }  
复制代码
执行测试后:终端会显示各维度的覆盖率百分比,在 coverage/ 目录下生成详细报告:index.html 提供可视化分析可逐层查看未覆盖的代码路径。
3.webp

示例输出中显示 common/util.ts 仅 32.39% 覆盖率,低于预设阈值。此时应该优先补充核心工具函数的测试用例。通过持续完善测试覆盖,可以有效提升组件迭代的可靠性,并为后续重构提供安全保障。
通过引入自动化测试,我们实现了从"人肉测试"到系统化保障的转变。精心设计的测试用例覆盖了各种边界情况,配合覆盖率分析,构建了多层次的质量防护体系。
如果你对前端工程化有兴趣,或者想了解更多相关的内容,欢迎查看我的其他文章,这些内容将持续更新,希望能给你带来更多的灵感和技术分享~

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册