【自动化】使用Jest给组件库做单元测试

使用Jest给组件库做单元测试

相关文章

使用Jest给组件库做单元测试

系统环境

  • react:v18+

一、Jest是什么

Jest 是一个用于 JavaScript 应用程序的开源测试框架,由 Facebook 开发和维护。它专注于提供简单易用的测试工具,使得编写、运行和维护测试变得更加容易。

二、单元测试是什么

单元测试是软件开发中的一种测试方法,用于验证代码的最小单元(通常是函数、方法或类)是否按照预期进行工作。单元测试的目标是对单元进行隔离测试,从而确保单元在不受其他部分影响的情况下能够正确地执行其功能。

三、测试框架选型

Enzyme

Enzyme 是 Airbnb 开发的一个流行的 React 测试实用工具,它提供了丰富的 API 用于测试 React 组件。

Enzyme 官方支持react16的版本,17的版本可以安装社区提供的包@wojtekmaj/enzyme-adapter-react-17支持,目标不支持18的版本。

React Testing Library

React Testing Library 是一个专门用于测试 React 组件的库,它专注于测试用户行为而不是内部实现细节。

通过 create-react-app 脚手架创建的项目默认包含 Testing Library,而 React Testing Library 内部使用 Jest 作为其测试运行器。

所以,最终根据项目react18+的版本选择React Testing Library,使用Jest作为测试工具。

四、新增单元测试

UI组件

这段代码定义了一个名为 Product 的 React 组件,用于展示商品信息的卡片。该组件接受一系列属性(props)作为输入,包括布局方式、标题、封面图、利益点、价格、商品标签等。其中,handleBuy 是一个函数,用于处理购买按钮的点击事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// product.tsx

import { MouseEventHandler, useCallback, useEffect, useState } from "react";
import "./product.scss";

export interface ProductProps {
/**
* 布局
*/
layout?: "horizontal" | "vertical";
/**
* 标题
*/
title: string;
/**
* 封面图
*/
cover?: string;
/**
* 利益点
*/
interest?: string;
/**
* 价格
*/
price: number;
/**
* 商品标签
*/
categorys?: Array<string>;
/**
* 价格标签
*/
tags?: Array<string>;
/**
* 购买
*/
handleBuy: MouseEventHandler<HTMLDivElement>;
}

/**
* 商品卡片
*/
export const Product: React.FC<ProductProps> = ({
layout = "horizontal",
...props
}) => {
const [active, setActive] = useState(false);

const handleClick = useCallback(() => {
setActive(true);
}, []);

return (
<div
className={`com-product layout_${layout} ${active ? "active" : ""}`}
onClick={handleClick}
>
<div className="com-product-cover">
<img
src={
props.cover
? props.cover
: "https://pic.imgdb.cn/item/65f9367d9f345e8d036c28cf.png"
}
/>
</div>
<div className="com-product-content">
<div className="com-product-title">
<span className="com-product-category">
{Array.isArray(props.categorys) &&
props.categorys.map((category) => {
return (
<span key={category} className="com-product-category-item">
{category}
</span>
);
})}
</span>
<span className="com-product-text">{props.title}</span>
</div>
<div className="com-product-interest">{props.interest}</div>
<div className="com-product-tag">
{Array.isArray(props.tags) &&
props.tags.map((tag) => {
return (
<span key={tag} className="com-product-tag-item">
{tag}
</span>
);
})}
</div>
<div className="com-product-buy">
<div className="com-product-buy-price">
{props.price > 0 ? "¥" + props.price : "免费"}
</div>
<div onClick={props.handleBuy} className="com-product-buy-button">
购买
</div>
</div>
</div>
</div>
);
};

单元测试

测试点

  • 组件渲染:

    • 测试组件是否能够正常渲染,不会抛出错误。
    • 测试组件的布局是否正确,包括垂直和水平布局。
    • 测试组件是否包含所需的 DOM 元素,如封面图、标题、价格等。
  • 属性传递:

    • 测试组件是否能够正确接收和处理传入的属性。
    • 测试组件是否能够根据属性展示正确的商品信息,如标题、封面图、价格等。
  • 交互行为:

    • 测试点击购买按钮时是否触发了 handleBuy 函数。
    • 测试点击商品卡片是否能够改变其状态(例如,激活状态)。
  • 条件渲染:

    • 测试组件在不同属性情况下的渲染效果,如有无封面图、价格为免费等。
  • 边界条件:

    • 测试组件在传入无效或不完整的属性时的表现,如缺少标题或价格。
    • 测试组件是否能够处理异常情况,如价格为负数或非数值类型。

单元测试文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202

// product.test.tsx

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Product, ProductProps } from "./product";

const productProps: ProductProps = {
title: "测试商品",
cover: "测试封面链接",
interest: "测试利益点",
price: 100,
categorys: ["新品", "促销"],
tags: ["标签1", "标签2"],
handleBuy: function (): void {},
};

describe("Product 组件", () => {
test("渲染商品卡片的内容是否正确", () => {
render(<Product {...productProps} />);

// 检查标题是否正确渲染
const titleElement = screen.getByText(productProps.title);
expect(titleElement).toBeInTheDocument();

// 检查类目是否正确渲染
productProps.categorys?.forEach((category) => {
const categorysElement = screen.getByText(category);
expect(categorysElement).toBeInTheDocument();
});

// 检查利益点是否正确渲染
if (productProps.interest) {
const interestElement = screen.getByText(productProps.interest);
expect(interestElement).toBeInTheDocument();
}

// 检查价格是否正确渲染
const priceElement = screen.getByText(`¥${productProps.price}`);
expect(priceElement).toBeInTheDocument();

// 检查标签是否正确渲染
productProps.tags?.forEach((tag) => {
const tagElement = screen.getByText(tag);
expect(tagElement).toBeInTheDocument();
});

// 检查 "购买" 按钮是否正确渲染
const buyButtonElement = screen.getByText("购买");
expect(buyButtonElement).toBeInTheDocument();
});

test("当标题为空时,不渲染标题", () => {
const propsWithoutInterest: ProductProps = {
...productProps,
title: "",
};

render(<Product {...propsWithoutInterest} />);
const interestElement = screen.queryByTestId("interest");
expect(interestElement).toBeNull();
});

test("当类目数组为空时,不渲染任何类目", () => {
const propsWithoutTags: ProductProps = {
...productProps,
categorys: [],
};

render(<Product {...propsWithoutTags} />);
const tagElements = screen.queryAllByTestId("tag");
expect(tagElements).toHaveLength(0);
});

test("当利益点为空时,不渲染利益点", () => {
const propsWithoutInterest: ProductProps = {
...productProps,
interest: undefined,
};

render(<Product {...propsWithoutInterest} />);
const interestElement = screen.queryByTestId("interest");
expect(interestElement).toBeNull();
});

test("当价格为零时,渲染为免费", () => {
const freeProductProps: ProductProps = {
...productProps,
price: 0,
};

render(<Product {...freeProductProps} />);
const priceElement = screen.getByText("免费");
expect(priceElement).toBeInTheDocument();
});

test("当价格为负数时,渲染为免费", () => {
const negativePriceProps: ProductProps = {
...productProps,
price: -100,
};

render(<Product {...negativePriceProps} />);
const priceElement = screen.getByText("免费");
expect(priceElement).toBeInTheDocument();
});

test("当标签数组为空时,不渲染任何标签", () => {
const propsWithoutTags: ProductProps = {
...productProps,
tags: [],
};

render(<Product {...propsWithoutTags} />);
const tagElements = screen.queryAllByTestId("tag");
expect(tagElements).toHaveLength(0);
});

test("渲染商品卡片的布局是否正确", () => {
const verticalProps: ProductProps = {
...productProps,
layout: "vertical",
};

const { container } = render(<Product {...verticalProps} />);
const productElement = container.querySelector(".com-product");
expect(productElement).toHaveClass("layout_vertical");
});

test("商品标签应该是唯一的", () => {
if (productProps.tags !== undefined) {
const uniqueTags = [...new Set(productProps.tags)];
expect(uniqueTags.length).toBe(productProps.tags.length);
}
});

test("当封面链接为空时,渲染默认封面图", () => {
const propsWithoutCover: ProductProps = {
...productProps,
cover: "",
};

const { container } = render(<Product {...propsWithoutCover} />);
const coverElement = container
.querySelector(".com-product-cover")
?.querySelector("img");
expect(coverElement?.src).toEqual(
"https://pic.imgdb.cn/item/65f9367d9f345e8d036c28cf.png"
);
});

test("点击购买按钮触发购买操作", () => {
// 创建一个 mock 函数来监视回调函数的调用
const mockHandleBuy = jest.fn();

const propsWithoutCover: ProductProps = {
...productProps,
handleBuy: mockHandleBuy,
};

const { container } = render(<Product {...propsWithoutCover} />);
const buyButtonElement = container.querySelector(".com-product-buy-button");
if (buyButtonElement) {
fireEvent.click(buyButtonElement);
}
// 验证回调函数被调用
expect(mockHandleBuy).toHaveBeenCalled();
});

test("点击购买按钮触发异步购买操作", async () => {
// 创建一个 mock 函数来监视回调函数的调用
const mockHandleBuy = jest.fn(async () => {
await setTimeout(() => {
Promise.resolve();
}, 2000);
});

const propsWithoutCover: ProductProps = {
...productProps,
handleBuy: mockHandleBuy,
};

const { container } = render(<Product {...propsWithoutCover} />);
const buyButtonElement = container.querySelector(".com-product-buy-button");
if (buyButtonElement) {
fireEvent.click(buyButtonElement);
}
// 验证回调函数被调用
await waitFor(() => {
expect(mockHandleBuy).toHaveBeenCalled();
});
});

test("点击组件后,组件新增了一个特定的class", () => {
const { container } = render(<Product {...productProps} />);
const productElement = container.querySelector(".com-product");
if (productElement) {
fireEvent.click(productElement);
expect(productElement).toHaveClass("active");
}
});
});

执行测试

1
# yarn test

输出

五、Jest API介绍

全局函数(Global Functions)

  • describe(name, fn):定义一个测试套件(test suite),用于组织和描述一组相关的测试用例。
  • test(name, fn):定义一个测试用例(test case),包含需要被测试的代码逻辑以及期望的结果。
  • beforeAll(fn):在所有测试用例运行前执行一次的函数。
  • afterAll(fn):在所有测试用例运行后执行一次的函数。
  • beforeEach(fn):在每个测试用例运行前执行的函数。
  • afterEach(fn):在每个测试用例运行后执行的函数。

断言(Assertions)

  • expect(value):创建一个期望对象,用于对值进行断言。
    • .toBe(value):判断值是否严格相等。
    • .toEqual(value):判断值是否深度相等。
    • .not.toBe(value):判断值是否不等。
    • .toMatch(regexpOrString):用于对字符串进行正则匹配。
    • .toContain(item):用于数组或可迭代对象,判断是否包含某个项。
    • .toThrow(error?):用于捕获函数抛出的错误。
    • .toHaveLength(length):判断对象的 length 属性是否等于给定值。
    • .toBeGreaterThan(number):判断数字是否大于给定值。
    • .toBeTruthy():判断值是否为真。
    • .toBeFalsy():判断值是否为假。

Mocking

  • jest.fn(implementation):创建一个模拟函数(mock function)。
  • jest.mock(moduleName, factory):用于模拟模块。
  • jest.spyOn(object, methodName):用于模拟对象的方法,并追踪其调用情况。

异步处理

  • test(‘name’, () => { return promise }):测试异步代码,确保 promise 在 resolve 后测试用例才结束。
  • async/await:可用于测试异步函数或异步代码块,与常规的异步测试结合使用。

配置(Configuration):

  • jest.config.js:Jest 的配置文件,用于配置 Jest 的各种行为和选项。
  • jest.mock():用于模拟模块。
  • jest.setup.js:在运行测试前执行的全局设置脚本。

【自动化】使用Jest给组件库做单元测试
https://www.cccccl.com/20230325/工程化/自动化/使用Jest给组件库做单元测试/
作者
Jeffrey
发布于
2023年3月25日
许可协议