搭建 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 测试有更加深入的了解,当然这只是开始,如果感兴趣的话欢迎关注,后续测试内容更加精彩!

相关推荐
qq. 28040339842 分钟前
react --> redux
前端·react.js·前端框架
qq. 280403398422 分钟前
react 副作用探究
前端·react.js
梦65040 分钟前
React 封装 UEditor 富文本编辑器
前端·react.js·前端框架
qq. 280403398442 分钟前
react 编写规范
前端·react.js·前端框架
qq. 280403398443 分钟前
react 基本语法
前端·react.js·前端框架
studyForMokey1 小时前
【跨端技术】React Native学习记录一
javascript·学习·react native·react.js
我是刘成13 小时前
基于React Native 0.83.1 新架构下的拆包方案
react native·react.js·架构·拆包
梦65014 小时前
Vue 组件 vs React 组件深度对比
javascript·vue.js·react.js
全栈前端老曹16 小时前
【ReactNative】页面跳转与参数传递 - navigate、push 方法详解
前端·javascript·react native·react.js·页面跳转·移动端开发·页面导航
_Kayo_17 小时前
React上绑定全局方法
前端·javascript·react.js