搭建 react + antd 技术栈的测试框架

本文搭建 react + antd 技术栈的测试框架,其中有一些坑已经踩过了。搭建完成之后,对实际项目中的一个 Modal 组件进行单元测试。并罗列测试过程中出现的问题及解决方案。

1. 环境搭建

搭建这样的环境,我们首先需要在工程目录中对 jest 进行配置。

  1. 假设 React app 是通过 create-react-app 构建的,那么我们首先将工作区清空,然后执行 yarn eject 命令,执行完成之后,可以看到 package.json 中增加了 jest 相关配置,以及根目录下新增的名为 config 的目录。
  2. 来到 package.json 找打配置项 jest.setupFilesAfterEnv, 此时其值为 [], 将其改成 "<rootDir>/src/__test__/setup.js".
  3. 在 src 目录下面创建名为 __test__ 的子目录,然后在这个子目录中创建名为 setup.js 的文件,使之符合第 2 步的配置。
  4. setup.js 中写入下面的内容:
js 复制代码
import "@testing-library/jest-dom";

console.log('Test setup.js is running!');

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(), // deprecated
    removeListener: jest.fn(), // deprecated
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  }),
});

文件中的第一句让我们使用 expect().toBeInTheDocument() 的这类断言能够成立;第二句则是确保我们的 setup.js 文件被执行了。剩下的内容是对 window.matchMedia 全局变量的模拟,如果要测试 ant design 这个库,则必须对此对象进行模拟,否则会出错(这是个大坑)

jest 官方推荐的解决方案是:https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 但是好像是无效的!

接下来,我们只需要执行 yarn test 就可以了。我已经尝试过了,这样的设置对于常用的 antd 中的组件都是能够正常渲染的,见仓库:https://github.com/fszhangYi/basicTestingFramework.

2. 测试练习

本节中,我们对一个真实的组件进行测试,这个组件渲染了 antd 提供的 Modal 组件。

2.1 待测组件

让我们先看一看待测组件 SunModal.js 全部代码:

jsx 复制代码
import React, { useEffect, useState } from 'react';
import { Modal, Button, Input } from 'antd';
import { message } from 'antd';
import classNames from 'classnames';

const SunModal = props => {
  const {
    open = false,
    onClose = () => '',
    dataSource = {},
    changeParentMode = () => '',
    confirmCb = () => '',
    rowData = {},
  } = props;
  const { comment, commentAdminRep, hideReason } = rowData;
  const { title, hint, required, placeholder, mode } = dataSource;
  const [visible, setVisible] = useState(open);
  const [innerMode, setInnerMode] = useState(mode);
  const [innerValue, setInnerValue] = useState(() => {
    let rst;
    switch (mode) {
      case '1':
        rst = undefined;
      case '2':
        rst = undefined;
      case '3':
        rst = commentAdminRep ?? undefined;
      default:
        rst = undefined;
    }

    return rst;
  });

  useEffect(() => {
    setVisible(open);
  }, [open]);

  useEffect(() => {
    setInnerMode(mode);
  }, [mode]);

  useEffect(() => {
    if (mode == '3') {
      setInnerValue(commentAdminRep ?? undefined);
    } else {
      setInnerValue(undefined);
    }
  }, [mode, commentAdminRep]);

  const [messageApi, contextHolder] = message.useMessage();

  const success = () => {
    messageApi.open({
      type: 'success',
      content: 'Success!',
    });
  };

  const handleOnChange = val => {
    const { value } = val.target;
    setInnerValue(value);
  };

  const callbacks = mode => {
    const _a = {
      '1': () => {
        // go public with reply
        changeParentMode('PublicReply');
      },
      '2': () => { },
      '3': () => { },
      '4': () => {
        // go hidden with success
        changeParentMode('HiddenSuccess');
      },
      '5': () => {
        success();
        // go public with failed report
        changeParentMode('PublicFailed');
      },
    };

    return _a[mode] ?? (() => { });
  };

  const submitDisabled =
    innerMode != '3'
      ? !innerValue
      : innerValue === (commentAdminRep ?? undefined) || !innerValue;

  const titleClassName = classNames({
    'title-required': required,
  });

  return (
    <Modal
      wrapClassName="comment-sun-modal-wrap"
      className="comment-sun-modal"
      visible={visible}
      title={title}
      onOk={() => {
        setVisible(false);
        onClose(false);
      }}
      onCancel={() => {
        setVisible(false);
        onClose(false);
      }}
      width={424}
      style={{ top: 20 }}
      bodyStyle={{ height: '232px', padding: '12px 12px 24px' }}
      afterClose={() => {
        if (innerMode != '3') {
          setInnerValue(undefined);
        } else {
          setInnerValue(commentAdminRep ?? undefined);
        }
      }}
      footer={[
        <Button
          key="submit"
          type="primary"
          disabled={submitDisabled}
          onClick={() => {
            setVisible(false);
            onClose(false);
            callbacks(innerMode)();
            if (confirmCb) {
              confirmCb(rowData?.id, innerValue);
              console.log('innerValue:', innerValue);
            }
            setInnerValue(undefined);
          }}
        >
          Confirm
        </Button>,
      ]}
    >
      {contextHolder}
      <div style={{ marginBottom: 10 }} className={titleClassName} data-testId='title'>
        {hint}
      </div>
      <Input.TextArea
        value={innerValue}
        placeholder={placeholder}
        showCount
        maxLength={200}
        style={{ height: 142 }}
        onChange={handleOnChange}
      />
    </Modal>
  );
};

export default SunModal;

这个模态框的作用是当用户点击不同按钮之后弹出来,而点击不同的按钮,弹出来的模态框的内部状态是不确定的。弹出来之后我们可以输入一些内容,然后点击确认按钮,或者什么都不做直接关闭模态框。

这个组件的输入,解释如下:

  1. open: 控制模态框的显示或者隐藏
  2. onClose: 模态框关闭的时候的回调
  3. dataSource: 一部分详情数据
  4. changeParentMode: 调用此函数可以改变父组件中的一些状态
  5. confirmCb: 点击确认按钮之后的回调函数
  6. rowData: 剩余部分数据

2.2 测试设计

  1. 第一个测试,测试当 open 为 true 的时候,此组件是否能够正常渲染(关闭按钮,标题,副标题,文本框及占位符,确认按钮);
  2. 第二个测试,测试点击关闭按钮之后模态框是否会消失;
  3. 第三个测试时交互性测试,打开模态框之后,确认按钮是否置灰,输入文字后,文本框下方的字符统计是否正确,点击确认按钮之后相关函数有没有被调用、模态框是否关闭等。

2.3 测试文件

SunModal.js 的同一级目录中创建名为 SunModal.test.js 的测试文件,让我们先睹为快,看看测试文件的内容:

js 复制代码
/* eslint-disable no-undef */ // 禁用 ESLint 中的 "no-undef" 规则,允许使用未声明的变量

// 导入 React 库,用于渲染组件
import React from "react";
// 导入 @testing-library/react 的函数,用于组件渲染和查询 DOM 元素
import { render, screen, within } from "@testing-library/react";
// 导入 @testing-library/user-event 的 user 对象,用于模拟用户交互
import user from "@testing-library/user-event";
// 导入要测试的 SunModal 组件
import SunModal from "./SunModal";

// 定义测试套件,描述测试的组件或功能
describe("Test Sun Modal", () => {
  // 在每个测试用例执行前定义的变量
  let rowData;
  let dataSource;

  // beforeAll 钩子,在所有测试用例执行前运行一次
  beforeAll(() => {
    // 重写 console.error 以避免测试过程中的控制台错误输出
    console.error = () => { };
  });

  // beforeEach 钩子,在每个测试用例执行前运行
  beforeEach(() => {
    rowData = {
      "id": "806875307314253824",
      "userId": "393342074583257088",
      "title": "nihaoads233",
      "type": "3",
      "cateId": "2",
      "description": "hihahah",
      "conditionType": "1",
      "conditionValue": "Excellent",
      "price": 0,
      "phoneNumber": "+96611113111",
      "facilityCityId": "239798951631392770",
      "attachmentUrl": null,
      "attachmentName": null,
      "attachmentType": null,
      "startDate": 1722873600000,
      "expireDate": 1723478400000,
      "displayPeriod": null,
      "picList": [
        {
          "adsId": "806875307314253824",
          "oriPicUrl": "/testminio/20240806133946809056019501027328.png",
          "compressPicUrl": "/testminio/20240806133946809056023158460416.png",
          "sort": 1,
          "isCover": 1,
          "createTime": 1722922852000
        },
        {
          "adsId": "806875307314253824",
          "oriPicUrl": "/testminio/806875307410722816..png",
          "compressPicUrl": "/testminio/806875307326836736..png",
          "sort": 2,
          "isCover": 0,
          "createTime": 1722922852000
        },
        {
          "adsId": "806875307314253824",
          "oriPicUrl": "/testminio/806875307670769664..png",
          "compressPicUrl": "/testminio/806875307624632320..png",
          "sort": 3,
          "isCover": 0,
          "createTime": 1722922852000
        },
        {
          "adsId": "806875307314253824",
          "oriPicUrl": "/testminio/20240806134002809056086047854592.png",
          "compressPicUrl": "/testminio/20240806134002809056086878326785.png",
          "sort": 4,
          "isCover": 0,
          "createTime": 1722922852000
        }
      ],
      "adsFieldList": null,
      "customFieldMap": null,
      "createTime": null,
      "updateTime": 1722926770000,
      "postTime": null,
      "status": 3,
      "queryStatus": null,
      "cityName": "Abu Ali",
      "typeName": "Free",
      "categoryName": "Communication",
      "poster": "testsss1 testss2 testss3  testsss1 testss2",
      "sortType": null,
      "statusStr": "Rejected",
      "approveTime": 1722926763000,
      "iconPathCompressed": null,
      "compressPicUrl": null,
      "operationLog": {
        "adsId": "806875307314253824",
        "operator": "admin",
        "rejectReason": "撒潇洒",
        "operateTime": 1722926770000
      },
      "attachmentFileName": null,
      "attachmentSize": null,
      "staffName": null,
      "shareUrl": "/testminio/806875386880200704.html",
      "oriAdsId": null
    };

    dataSource = {
      "mode": "6",
      "required": false,
      "title": "Reject ad",
      "hint": "Reject",
      "placeholder": "Please enter your reason. It will be sent to user via notification.",
      "value": null
    };
  })

  // 测试用例:验证 SunModal 组件是否成功渲染
  it("Should render successfully", async () => {
    // 使用 render 函数渲染 SunModal 组件,并传入模拟的 props
    render(<SunModal
      open={true}
      dataSource={dataSource}
      rowData={rowData}
    />);

    // 使用 screen.getByRole 查找关闭按钮元素,并断言它存在于文档中
    const closeBtn = screen.getByRole('button', { name: /close/i });
    expect(closeBtn).toBeInTheDocument();

    // 使用 screen.getByText 查找包含特定文本的元素,并断言它存在于文档中
    const title = screen.getByText(/reject ad/i);
    expect(title).toBeInTheDocument();

    // 使用 getByTestId 查找具有特定 data-testid 属性的元素
    const subTitle = screen.getByTestId('title');
    expect(subTitle).toBeInTheDocument();

    // 检查子标题的文本内容是否包含 dataSource.hint 的值
    expect(subTitle.textContent).toContain(dataSource.hint);

    // 使用 getByRole 查找文本输入框,并断言它存在于文档中
    const text = screen.getByRole('textbox');
    expect(text).toBeInTheDocument();

    // 断言文本输入框的 placeholder 属性是否与 dataSource.placeholder 匹配
    expect(text.placeholder).toBe(dataSource.placeholder);

    // 使用 getByRole 查找确认按钮,并断言它存在于文档中
    const confirm = screen.getByRole('button', { name: /confirm/i });
    expect(confirm).toBeInTheDocument();

    // 断言确认按钮是否被禁用
    expect(confirm.disabled).toBe(true);

    // 记录测试的 URL,用于调试
    screen.logTestingPlaygroundURL();
  });

  // 测试用例:验证关闭按钮是否正常工作
  it("Close button works", async () => {
    // 定义 onClose 函数,并使用 jest.fn() 来跟踪其调用情况
    const onClose = jest.fn();

    // 渲染 SunModal 组件,并传入 onClose 函数作为 prop
    const { container } = render(<SunModal
      open={true}
      dataSource={dataSource}
      rowData={rowData}
      onClose={onClose}
    />);

    // 使用 getByRole 查找对话框元素,并断言它存在于文档中
    const dialog = screen.getByRole('dialog');
    expect(dialog).toBeInTheDocument();

    // 检查对话框的父元素的 display 样式是否为 'block'
    expect(getComputedStyle(dialog.parentElement).display).toBe('block');

    // 使用 getByRole 查找关闭按钮,并断言它存在于文档中
    const closeBtn = screen.getByRole('button', { name: /close/i });

    // 模拟用户点击关闭按钮
    user.click(closeBtn);

    // 断言 onClose 函数被调用,且调用时传递了 false
    expect(onClose).toHaveBeenCalled();
    expect(onClose).toHaveBeenCalledWith(false);

    // 检查对话框的父元素的 display 样式是否变为 'none'
    expect(getComputedStyle(dialog.parentElement).display).toBe('none');

    // 记录测试的 URL,用于调试
    screen.logTestingPlaygroundURL();
  });

  // 测试用例:验证组件是否能成功响应用户操作
  it("Should react successfully", async () => {
    // 定义模拟的拒绝理由字符串
    const rejectReason = 'No Special Reason!';

    // 定义 onClose, confirmCb, changeParentMode 函数,并使用 jest.fn() 跟踪调用
    const onClose = jest.fn();
    const confirmCb = jest.fn();
    const changeParentMode = jest.fn();

    // 渲染 SunModal 组件,并传入模拟的函数作为 props
    const { container } = render(<SunModal
      open={true}
      onClose={onClose}
      dataSource={dataSource}
      changeParentMode={changeParentMode}
      confirmCb={confirmCb}
      rowData={rowData}
    />);

    // 使用 getByRole 查找确认按钮,并断言它存在于文档中
    const confirm = screen.getByRole('button', { name: /confirm/i });

    // 断言确认按钮初始状态是禁用的
    expect(confirm.disabled).toBe(true);

    // 模拟用户点击确认按钮
    user.click(confirm);

    // 断言 onClose, confirmCb, changeParentMode 函数没有被调用
    expect(onClose).not.toHaveBeenCalled();
    expect(confirmCb).not.toHaveBeenCalled();
    expect(changeParentMode).not.toHaveBeenCalled();

    // 使用 getByRole 查找文本输入框
    const text = screen.getByRole('textbox');

    // 模拟用户在文本输入框中输入拒绝理由
    user.type(text, rejectReason);

    // 查找文本输入框的后缀元素,并断言它存在于文档中
    const suffix = (text.parentElement).querySelector('.ant-input-data-count');
    expect(suffix).toBeInTheDocument();

    // 检查后缀元素的文本内容是否正确显示字符计数
    expect(suffix.textContent).toBe('18 / 200');

    // 再次使用 getByRole 查找确认按钮,并断言它现在应该被启用
    const confirm2 = screen.getByRole('button', { name: /confirm/i });
    expect(confirm2.disabled).toBe(false);

    // 模拟用户再次点击确认按钮
    user.click(confirm2);

    // 断言 onClose 函数被调用,且调用时传递了 false
    expect(onClose).toHaveBeenCalled();
    expect(onClose).toHaveBeenCalledWith(false);

    // 断言 confirmCb 函数被调用,且调用时传递了正确的参数
    expect(confirmCb).toHaveBeenCalled();
    expect(confirmCb).toHaveBeenCalledWith(rowData.id, rejectReason);

    // 断言 changeParentMode 函数没有被调用
    expect(changeParentMode).not.toHaveBeenCalled();

    // 记录测试的 URL,用于调试
    screen.logTestingPlaygroundURL();
  });
});

2.4 测试技巧

本小节中,对上述测试文件中的技巧进行统计:

1. 导入最常用的两个测试库

js 复制代码
// 导入 @testing-library/react 的函数,用于组件渲染和查询 DOM 元素
import { render, screen, within } from "@testing-library/react";
// 导入 @testing-library/user-event 的 user 对象,用于模拟用户交互
import user from "@testing-library/user-event";

2. 使用下面的技巧防止在 terminal 中输出过多的信息

js 复制代码
  // beforeAll 钩子,在所有测试用例执行前运行一次
  beforeAll(() => {
    // 重写 console.error 以避免测试过程中的控制台错误输出
    console.error = () => { };
  });

3. 在 describe 的顶层域中定义本域的全局变量,在 beforeEach 中对这些变量进行赋值。

4. 对待测组件进行优化,这有点 TDD 的意思,在测试过程中我发现待测组件最后自带 props 的默认值,这将极大的方便测试。

5. 使用 getBy + toBeInTheDocument 的方式对 UI 进行测试:

js 复制代码
  const title = screen.getByText(/reject ad/i);
  expect(title).toBeInTheDocument();

6. 展示在页面上的值应该和输入值具有确定的对应关系/如何对 placeholder 进行断言

js 复制代码
  expect(text.placeholder).toBe(dataSource.placeholder);

7. 如何断言按钮处于 disabled 状态

js 复制代码
  expect(confirm.disabled).toBe(true);

8. 展示当前受测组件的状态

js 复制代码
  screen.logTestingPlaygroundURL();

9. 模拟一个函数,并利用此函数检测出调用时机和调用参数

js 复制代码
  const onClose = jest.fn();
  expect(onClose).toHaveBeenCalled();
  expect(onClose).toHaveBeenCalledWith(false);
  // expect(onClose).not.toHaveBeenCalled();
  // expect(confirmCb).not.toHaveBeenCalled();
  // expect(changeParentMode).not.toHaveBeenCalled();

10. 接受 render 函数的返回之,并使用其查找元素

js 复制代码
    const { container } = render(<SunModal
      open={true}
      dataSource={dataSource}
      rowData={rowData}
      onClose={onClose}
    />);

    // const confirm = container.querySelector('button');
js 复制代码
  // 使用 getByRole 查找对话框元素,并断言它存在于文档中
  const dialog = screen.getByRole('dialog');
  expect(dialog).toBeInTheDocument();

  // 检查对话框的父元素的 display 样式是否为 'block'
  expect(getComputedStyle(dialog.parentElement).display).toBe('block');

  // 检查对话框的父元素的 display 样式是否变为 'none'
  expect(getComputedStyle(dialog.parentElement).display).toBe('none');

12. 针对伪元素的断言

js 复制代码
  // 使用 getByRole 查找文本输入框
  const text = screen.getByRole('textbox');
  expect(getComputedStyle(text.parentElement, '::after').getPropertyValue('content')).toBe('18 / 200');

希望你能通过本文对 jest 测试有更加深入的了解,当然这只是开始,如果感兴趣的话欢迎关注,后续测试内容更加精彩!

相关推荐
哑巴语天雨12 小时前
React+Vite项目框架
前端·react.js·前端框架
初遇你时动了情12 小时前
react 项目打包二级目 使用BrowserRouter 解决页面刷新404 找不到路由
前端·javascript·react.js
码农老起12 小时前
掌握 React:组件化开发与性能优化的实战指南
react.js·前端框架
前端没钱13 小时前
从 Vue 迈向 React:平滑过渡与关键注意点全解析
前端·vue.js·react.js
高山我梦口香糖16 小时前
[react] <NavLink>自带激活属性
前端·javascript·react.js
撸码到无法自拔16 小时前
React:组件、状态与事件处理的完整指南
前端·javascript·react.js·前端框架·ecmascript
高山我梦口香糖16 小时前
[react]不能将类型“string | undefined”分配给类型“To”。 不能将类型“undefined”分配给类型“To”
前端·javascript·react.js
乐闻x18 小时前
VSCode 插件开发实战(四):使用 React 实现自定义页面
ide·vscode·react.js
irisMoon0619 小时前
react项目框架了解
前端·javascript·react.js
web150850966411 天前
【React&前端】大屏适配解决方案&从框架结构到实现(超详细)(附代码)
前端·react.js·前端框架