一、课程目标 🎯
- 了解前端单元测试的重要性
- 前端单元测试工具选型
- 编写单元测试实践
二、课程主题 🎉
2.1 了解前端单元测试的重要性
- 什么是单元测试
- 单元测试的作用和优势
- 前端开发中的挑战与问题
2.2 前端单元测试工具
- 测试工具
- 测试规范与内容
2.3 使用React测试库实践
三、课程内容 📃
3.1 单元测试的概念及作用
什么是单元测试?
单元测试是测试中的一个重要环节,它针对软件中的最小可测试单元进行验证,通常是指对代码中的单个函数、方法或模块进行测试。
单元测试旨在确定特定部分代码的行为是否符合预期,通过针对单元代码的各种输入情况进行测试,来验证代码的正确性、稳定性和可靠性。
为什么要做单元测试?
- 确保代码质量和可靠性: 单元测试可以帮助开发人员发现和修复代码中的错误和缺陷。通过编写针对每个单独的函数或组件的测试用例,可以验证其行为是否符合预期,从而增强代码质量和可靠性。
- 提高代码可维护性: 单元测试可以作为文档和说明来帮助其他开发人员了解代码的预期行为。通过编写清晰、有目的性的测试用例,可以帮助开发团队更好地理解和维护代码。
- 快速反馈和迭代: 单元测试使得代码的迭代和快速反馈变得更加容易。通过自动运行测试用例,开发人员在修改代码后可以快速获得有关是否引入新错误或破坏现有功能的反馈。
- 节省时间和资源: 尽管编写和维护单元测试需要一些额外的工作量,但它可以节省大量的时间和资源。通过快速检测和修复代码中的错误,可以避免在后期发现问题并进行繁琐的调试和修复。此外,当代码库变得越来越大和复杂时,拥有一套稳健的单元测试可以节省大量的回归测试时间。
前端代码单元测试面临的挑战与问题
- 难以测试 DOM 操作: 前端开发中最常见的任务之一就是操作 DOM,但由于浏览器环境的限制,DOM 操作很难通过自动化测试来模拟。这意味着要测试 DOM 操作通常需要手动测试或使用可视化测试工具。
- 异步操作的测试: 在前端开发中,异步代码执行是比较常见的。但是,异步测试需要在数据返回之前等待一段时间。
- 测试用例覆盖率的管理: 在编写测试用例时,需要考虑完整和准确地覆盖所有代码路径。但是,测试用例的数量和管理可以是一个挑战,并且可能需要一些额外的工具来帮助管理测试用例的覆盖率。
- 特定 DOM 事件和浏览器环境的测试: 在某些情况下,需要通过特定的 DOM 事件和浏览器环境对代码进行测试。这可以通过模拟特定的事件和使用虚拟浏览器环境来完成。
3.2 前端单元测试工具以及测试规范
测试工具
以下是一些流行的前端单元测试工具:
- Jest: Jest 是一个 Facebook 公司开发的流行的 JavaScript 测试框架。它提供了自动化测试、模拟和覆盖率报告等功能。Jest 的主要特点是易于使用、速度快、自动运行测试用例和提供详细报告。
- Mocha: Mocha 是一个流行的 JavaScript 测试框架,可以用于编写前端和后端测试用例。它提供不同的测试运行器、测试框架、覆盖率报告等工具。Mocha 可以与其他库(如 Chai、Sinon 等)结合使用,以提供更好的测试功能。
- Enzyme: Enzyme 是一个 React 组件测试工具,它提供了一个简单的 API 来模拟 React 组件的行为。Enzyme 可以帮助开发人员测试组件的渲染和逻辑,以确保其正确性。它还提供了丰富的匹配器和渲染引擎,以进行功能和性能测试。
实际项目中测试框架和工具选择:
- 测试基础框架:Vitest ,它是基于vite驱动,如果项目中使用了vite,它是最好的选择
- DOM 环境:jsdom、happy-dom
React项目:
- @testing-library/react:作为 React DOM 和 UI 组件
- @testing-library/jest-dom:用于扩展Vitest的expect方法
Vue项目
- vue-router-mock:模拟 Vue 3应用程序中的路由交互
- @vitejs/plugin-vue 是一个 Vite 插件,它可以让 Vite 可以解析 .vue 文件,对于 JSX/TSX 支持,还需要@vitejs/plugin-vue-jsx
- @vue/test-utils:Vue 3的组件测试工具
测试规范
(1) 命名约定it or test?
- it是 BDD(行为驱动开发)风格中常用的命名约定。它强调描述被测试行为的自然语言描述,以便更好地阐述测试的用例。
- test是传统的命名约定,被广泛使用在各种单元测试框架中。它更加直接和简洁,通常以测试的目标作为开头,然后描述被测试的函数或特性。
无论使用it还是test作为测试函数的命名约定,最重要的是保持一致性和可读性。根据你的团队或项目的偏好,选择一个适合的命名约定并始终如一地使用它。
(2) 判断相等toBe or toEqual?
- toBe它使用===检查严格的平等,通常用于比较基础类型。
- toEqual用于检查两个对象具有相同的值。这个匹配器递归地检查所有字段的相等性,而不是检查对象身份 - 这也被称为"深度平等"。
使用toBe进行比较时要注意,它比较的是两个对象的引用,而不是对象的属性是否相同。
(3) 测试文件写在哪?
- 把测试文件统一写在 src/test/,这样保持项目和测试代码分离,保持工程目录整洁。
- 和组件写在同一级目录,即src/components/下,xx.jsx、xx.test.jsx, 这样对开发人员友好,组件与测试一起更方便维护。
(4) 测试用例注意事项
- 清晰的目的和描述:测试用例应该具有清晰的目的和描述,以便于理解和维护。用一个简洁但有意义的名称来描述该测试用例的功能或行为。
- 单一功能和场景:每个测试用例应该只关注一个功能或一个特定的场景。这有助于准确地定位和修复问题。
- 确保环境一致性:对于每个测试用例,提供必要的前提条件,确保测试环境的一致性。
- 测试目标简单、完整:尽量把业务代码的函数的功能单一化,简单化。如果一个函数的功能包含了十几个功能数十个功能,那应该对该函数进行拆分,从而更加有利于测试的进行。
四、使用Vitest测试React项目
4.1 安装相关工具
css
pnpm i -D vitest js-dom @testing-library/react
Vitest 1.0 需要 Vite >=v5.0.0 和 Node >=v18.00
4.2 配置vitest
Vitest 的主要优势之一是它与 Vite 的统一配置。如果存在,vitest 将读取你的根目录 vite.config.ts 以匹配插件并设置为你的 Vite 应用程序。
如果你已经在使用 Vite,请在 Vite配置中添加 test 属性。你还需要使用 三斜杠指令 在你的配置文件的顶部引用。
vite.config.ts
php
/// <reference types="vitest" />
import { defineConfig } from 'vite'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
},
})
- globals: 默认情况下,vitest 不显式提供全局 API。如果你更倾向于使用类似 jest 中的全局 API,可以将 --globals 选项传递给 CLI 或在配置中添加 globals: true。
- environment: Vitest中的默认测试环境是一个 Node.js 环境。如果你正在构建 Web 端应用程序,你可以使用 jsdom 或 happy-dom 这种类似浏览器(browser-like)的环境来替代 Node.js。
可以参阅 配置索引 中的配置选项列表
4.3 方法测试
测试独立的工具函数,例如测试斐波那契数列:
@param方法接受参数 num
@return返回值为斐波那契数列中第 n 个数字
fibonacci.ts
dart
export function fibonacci(num: number): number {
if (num <= 1) {
return num;
}
return fibonacci(num - 1) + fibonacci(num - 2);
}
fibonacci.test.ts
javascript
import { describe, it, expect } from 'vitest';
import { fibonacci } from '@/utils/fibonacci/fibonacci.ts';
describe('fibonacci', () => {
it('should return 0 when num is 0', () => {
expect(fibonacci(0)).toEqual(0);
});
it('should return 1 when num is 1', () => {
expect(fibonacci(1)).toEqual(1);
});
it('should return 1 when num is 2', () => {
expect(fibonacci(2)).toEqual(1);
});
it('should return 2 when num is 3', () => {
expect(fibonacci(3)).toEqual(2);
});
it('should return 3 when num is 4', () => {
expect(fibonacci(4)).toEqual(3);
});
});
当编写测试用例来测试独立的方法或函数时,应满足以下要求👇:
- 边界条件测试:测试应该覆盖方法或函数的所有边界条件。这包括输入的最小值、最大值、边界情况和异常情况。
- 异常处理测试:测试应该包括错误情况和异常处理,以确保方法或函数能够正确地处理这些情况,而不会导致系统崩溃或出现错误。
4.4 快照测试
快照测试是一种用于比较当前渲染结果与预期快照的自动化测试技术。
适用场景:测试一个纯渲染的组件,UI渲染一次后不再发生改变。这种场景下就不需要再耗费精力去单测,而是采用低成本的快照测试。
Result.tsx
typescript
import type { FC } from 'react';
type Student = {
id?: number;
name?: string;
};
interface PropsType {
stus: Student[];
}
const Results: FC<PropsType> = ({ stus }) => {
return (
<div className="search">
{!stus.length ? (
<h1>No Data</h1>
) : (
stus.map((stu: Student) => {
return (
<div key={stu.id}>
<div className="info">
<h1>{stu.name}</h1>
</div>
</div>
);
})
)}
</div>
);
};
export default Results;
Results.test.tsx
javascript
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import Results from './Results.tsx';
describe('Results', () => {
// 快照测试,会在当前目录下生成__snapshots__文件夹和快照文件
it('should renders correctly with no stus', () => {
const { asFragment } = render(<Results stus={[]} />);
// 渲染快照结果是否与存档快照一致
expect(asFragment()).toMatchSnapshot();
});
it('should renders correctly with some stus', () => {
const stus = [
{
id: 1,
name: 'Luna'
},
];
const { asFragment } = render(<Results stus={stus} />);
// 渲染快照结果是否与存档快照一致
expect(asFragment()).toMatchSnapshot();
});
});
在第一次执行完测试用例后,当前目录会生成以下文件:
后续的每次测试会将最新结果和这里保存的结果进行对比,如果一致,则代表测试通过,反之,则不然。
4.4 组件测试
日常开发中,我们接触最多的就是组件,越来越多的框架推荐页面组件化,组件也必然是单元测试的目标对象之一。
首先应该知道对于组件,应该测试哪些内容
- Component Data:组件静态数据
- Component Props:组件动态数据
- User Interaction:用户交互,例如单击
- LifeCycle Methods:生命周期逻辑
- Store:组件状态值
- Route Params:路由参数
- 输出的DOM
- 外部调用的函数Hook
- 对子组件的改变
组件DOM测试
组件根据不同的props输入,组件会呈现不同的DOM结构,我们可以通过@testing-library/react 库提供的render API,结合jsdom(在Node环境中提供对 web 标准的模拟实现)来完成DOM测试。
Pet.tsx
typescript
import type { FC } from 'react';
interface PropsType {
id?: number;
name?: string;
animal?: string;
breed?: string;
images?: string[];
location?: string;
}
const Pet: FC<PropsType> = (props) => {
const { name, animal, breed, images, location, id } = props;
let hero = 'http://pets-images.dev-apis.com/pets/0.jpg';
if (images?.length) {
hero = images[0];
}
return (
<div className="pet">
<div className="image-container">
<img data-testid="thumbnail" src={hero} alt={name} />
</div>
<div className="info">
<h1>{name}</h1>
<h2>{`${animal} --- ${breed} --- ${location}`}</h2>
</div>
</Link>
);
};
export default Pet;
Pet.test.tsx
javascript
import { render } from '@testing-library/react';
import { StaticRouter } from 'react-router-dom/server';
import { expect, test } from 'vitest';
import Pet from './Pet.tsx';
test('displays a default thumbnail', async () => {
// 渲染组件
const pet = render(<Pet />);
// 异步等待获取指定的DOM元素
const petThumbnail = (await pet.findByTestId(
'thumbnail'
)) as HTMLImageElement;
// 断言0.jpg包含在src的属性值中
expect(petThumbnail.src).toContain('0.jpg');
// 卸载组件
pet.unmount();
});
有很多API可以断言一个元素是否存在,例如toBeInTheDocument,详细用法请查阅官方文档。
scss
expect(testDom).toBeInTheDocument();
交互测试
上面测试了组件能够按预期渲染,单一个这样的用例是远远不够的,我们还需要模拟用户交互行为,来测试组件是否符合预期,例如常见的点击、拖拽等。
Carousel.tsx
ini
import type { FC, MouseEvent } from 'react';
import { useState } from 'react';
interface PropsType {
images?: string[];
}
const Carousel: FC<PropsType> = (props) => {
const { images = ['http://pets-images.dev-apis.com/pets/0.jpg'] } = props;
const [active, setActive] = useState<number>(0);
const handleIndexClick = (event: MouseEvent) => {
const { index = 0 } = (event.target as HTMLImageElement).dataset;
setActive(+index);
};
return (
<div className="carousel">
<img data-testid="hero" src={images[active]} alt="animal" />
<div className="carousel-smaller">
{images.map((photo, index) => (
<img
key={photo}
src={photo}
className={index === active ? 'active' : ''}
alt="animal thumbnail"
onClick={(e) => handleIndexClick(e)}
data-index={index}
data-testid={`thumbnail${index}`}
/>
))}
</div>
</div>
);
};
export default Carousel;
Carousel.test.tsx
ini
import { act, fireEvent, render } from '@testing-library/react';
import { expect, test } from 'vitest';
import Carousel from './Carousel.tsx';
test('lets users click on thumbnails to make them the hero', async () => {
const images = ['0.jpg', '1.jpg', '2.jpg', '3.jpg'];
const carousel = render(<Carousel images={images} />);
const hero = (await carousel.findByTestId('hero')) as HTMLImageElement;
expect(hero.src).toContain(images[0]);
for (let i = 0; i < images.length; i++) {
const image = images[i];
const thumb = await carousel.findByTestId(`thumbnail${i}`);
act(() => {
// 模拟 click 用户事件
fireEvent.click(thumb);
});
expect(hero.src).toContain(image);
expect(Array.from(thumb.classList)).toContain('active');
}
});
4.6 React Hook 测试
这里的Hook是指业务中自定义封装的Hook,我们知道Hook只能在函数组件中调用,那如何来做单元测试呢?
RTL中提供了renderHook ,专门用来调用 Hook 。
useSearch.ts
typescript
import { useState, useMemo } from 'react';
/**
* useSearch 数据过滤
* @param items 初始数组
* @return
* searchTerm 搜索词
* setSearchTerm 更新搜索词方法
* filteredItems 过滤后的数据
*/
export const useSearch = (items: any[]) => {
const [searchTerm, setSearchTerm] = useState('');
const filteredItems = useMemo(() => {
return items.filter((movie) =>
movie.title.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [items, searchTerm]);
return {
searchTerm,
setSearchTerm,
filteredItems,
};
};
useSearch.test.ts
javascript
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useSearch } from '@/hooks/__test__/useSearch.ts';
describe('useSearch hook', () => {
// 测试返回默认搜索项 和 原始项目列表
it('should return a default search term and original items', () => {
const items = [{ title: 'Star Wars' }];
// renderHook 模拟hook执行环境
const { result } = renderHook(() => useSearch(items));
// result.current 为useSearch hook的返回值
expect(result.current.searchTerm).toBe('');
expect(result.current.filteredItems).toEqual(items);
});
// 测试设置查询条件是否生效
it('should return a filtered list of items', () => {
const items = [{ title: 'Star Wars' }, { title: 'Starship Troopers' }];
const { result } = renderHook(() => useSearch(items));
// 反应,所有的渲染和触发的事件都包装在 act 中。它负责在调用之后刷新所有效果并重新渲染。
act(() => {
result.current.setSearchTerm('Wars');
});
expect(result.current.searchTerm).toBe('Wars');
expect(result.current.filteredItems.length).toBe(1);
expect(result.current.filteredItems).toEqual([{ title: 'Star Wars' }]);
});
});
4.6 异步、Mock测试
使用waitFor,异步等待接口返回
前端很多数据都是通过接口返回,页面需要等待接口返回后才能渲染,因此单元测试中的断言也需要等待,使用waitFor可以实现。
useMovies.ts
typescript
export const useMovies = ():{ movies: Movie[], isLoading: boolean, error: any } => {
const [movies, setMovies] = useState([]);
const fetchMovies = async () => {
try {
setIsLoading(true);
const response = await fetch("https://swapi.dev/api/films");
if (!response.ok) {
throw new Error("Failed to fetch movies");
}
const data = await response.json();
setMovies(data.results);
} catch (err) {
//do something
} finally {
//do something
}
};
useEffect(() => {
fetchMovies();
}, []);
return { movies }
}
useMovies.test.ts
javascript
import { renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useMovies } from '@/hooks/__test__/useMovies.ts';
describe('useMovies hook', () => {
//...
it('should setTimeout movies', async () => {
const { result } = renderHook(() => useMovies());
// 在达到超时值之前,waitFor 可能会多次运行回调。请注意,调用的数量受到超时和间隔选项的限制
await waitFor(() => {
expect(result.current.isLoading).toBe(true);
expect(result.current.movies).toEqual([{ title: 'Star Wars' }]);
expect(result.current.error).toBe(null);
});
});
});
waitFor的第二个参数是一个配置对象,可以配置超时时间,超过配置的上限后,测试为不通过
javascript
// 默认间隔为50毫秒,超时时间是1000ms。第二个参数可以配置间隔和超时时间
await waitFor(
() => {
// ...
},
{
timeout: 1000,
}
);
使用spyOn拦截请求方法,自定义mock
第一种方法是真实向后端发送了请求,在实际运用中可能不太合适。我们可以选择spyOn来拦截fetch,自己mock接口返回值,更加符合测试场景。
typescript
import { renderHook, waitFor } from '@testing-library/react';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { useMovies } from '@/hooks/__test__/useMovies.ts';
describe('useMovies hook', () => {
// 通过vi.spyOn方法,我们可以在不触发真正的 API 调用的情况下运行测试,从而减少由于外部因素导致测试失败的机会。
// 监视该global.fetch方法并模拟其实现以返回虚假响应
const fetchSpy = vi.spyOn(global, 'fetch');
// 注册一个回调函数,在开始运行当前上下文中的所有测试之前调用一次
beforeAll(() => {
const mockResolveValue = {
ok: true,
data: [{ title: 'Star Wars' }],
};
// 接受一个值,该值将在调用 mock 函数时返回
fetchSpy.mockReturnValue(mockResolveValue as any);
});
// 测试 fetch异步获取数据
it('should fetch movies', async () => {
const { result } = renderHook(() => useMovies());
await waitFor(() => {
expect(result.current.isLoading).toBe(true);
expect(result.current.movies).toEqual([{ title: 'Star Wars' }]);
expect(result.current.error).toBe(null);
});
});
// 注册一个回调函数,以便在当前上下文中所有测试运行完毕后调用一次。
afterAll(() => {
// 将内部实现还原为原始函数
// 还可以在 beforeEach 或 afterEach 中使用 mockClear()in方法来确保我们的测试完全隔离
fetchSpy.mockRestore();
});
});
通过调用vi.spyOn(global, 'fetch'),拿到代理方法,再调用mockReturnValue设置mock值,以此来模拟接口返回,这样更方便去断言。
由于我们模拟了fetch方法的返回值,因此需要在测试完成后使用mockRestore恢复其原始实现,还可以使用该mockClear()方法清除所有mock的信息
测试生命周期
- beforeAll:在当前文件的正式开始测试前执行一次,适合做一些每次 test 前都要做的初始化操作,比如数据库的清空以及初始化
- beforeEach:在当前文件的每个 test 执行前都调用一次。
- afterAll:在当前文件所有测试结束后执行一次,适合做一些收尾工作,比如将mock清除。
- afterEach:在当前文件的每个 test 执行完后都调用一次。
五、结尾:
这篇文章从单元测试的重要性、测试工具、测试实践三个方面,带大家入门前端单元测试。
相信大家认真学完后都能有收获!
参考: