测试基础概念
测试的目的
- 发现bug:通过测试可以尽早发现代码中的错误或潜在问题,确保最终产品的质量
- 保证稳定性:确保软件各个功能在不同的条件下都能正确运行,避免由于功能更改或添加新特性而导致的问题
- 提高用户体验:通过测试保证功能按预期工作,确保最终用户体验不会受到影响
- 节省成本:及时发现并修复缺陷,避免在生产环境中发现问题,造成更高的修复成本
单元测试、集成测试和端到端测试的区别
- 单元测试(Unit Testing):
- 目标:测试最小的功能单元(例如,函数、方法、类等)。
- 作用:确保单个组件或函数在不同输入条件下能够按预期工作。
- 示例:测试一个计算器函数是否正确执行加法。
- 集成测试(Integration Testing):
- 目标:测试多个模块或功能单元是否能正确协同工作。
- 作用:确保不同的组件组合在一起时,能够按照预期相互作用。
- 示例:测试一个用户登录功能,确保数据库和前端页面能够协同工作。
- 端到端测试(End-to-End Testing):
- 目标:测试整个系统从开始到结束是否按预期工作。
- 作用:模拟用户的操作来确保整个应用在生产环境中的功能是正常的。
- 示例:模拟一个用户从首页到结账流程的整个操作。
编写第一个测试
scss
function add(a: number,b: number){
//这里做一个假设不传b返回 b is null
if(!b) return { msg:"b is null" };
return a + b;
}
// add.test.ts
test("测试下add方法"){
expect(add(3,4)).toBe(7);
expect(add(1)).toEqual({ msg:"b is null" });
}
这里其实已经可以大致知道vitest的一些基础语法了,简单介绍一下
- test / it : 用来定义测试用例,二者是等价的,也就是说这里的test也可以用it代替。
- expect: 用来进行断言的。将代码结果与期望结果对比又一些常用的断言方法可以在后续简单介绍
常用的断言方法:
- .toBe() - 精确相等(===)用于比较原始值或对象引用(使用 === 比较)
php
// 对象比较测试,使用 .toEqual() 断言
test('对象比较使用 toEqual', () => {
// 创建一个用户对象
const user = createUser('Alice', 30);
// .toEqual() 用于深度比较对象的内容(而不是引用)
expect(user).toEqual({
name: 'Alice',
age: 30,
createdAt: user.createdAt // 我们使用实际的日期,因为它是动态生成的
});
// 注意:如果使用 .toBe(),这个测试会失败,因为它们是不同的对象引用
// expect(user).toBe({ name: 'Alice', age: 30, createdAt: user.createdAt }); // 这会失败
});
-
.toEqual() - 深度相等(对象比较)比较的是对象的内容,不是引用
-
.toBeTruthy() / .toBeFalsy() - 真/假值检查
scss
// .toBeTruthy() 检查值是否为真值
expect('hello').toBeTruthy();
expect({}).toBeTruthy();
// .toBeFalsy() 检查值是否为假值
expect(isValidAge(-5)).toBeFalsy();
expect(0).toBeFalsy();
- .toContain() - 包含检查
scss
// 数组包含检查,使用 .toContain() 断言
test('数组包含检查使用 toContain', () => {
const numbers = [1, -2, 3, -4, 5];
const positiveNumbers = filterPositiveNumbers(numbers);
// .toContain() 检查数组是否包含特定元素
expect(positiveNumbers).toContain(1);
expect(positiveNumbers).toContain(3);
expect(positiveNumbers).toContain(5);
// 也可以检查数组不包含某些元素
expect(positiveNumbers).not.toContain(-2);
expect(positiveNumbers).not.toContain(-4);
// 对于字符串,.toContain() 检查子字符串
expect('hello world').toContain('hello');
});
- .toThrow() - 异常检查
scss
// 异常检查,使用 .toThrow() 断言
test('异常检查使用 toThrow', () => {
// .toThrow() 检查函数是否抛出异常
// 注意:必须将函数包装在另一个函数中,否则异常会直接抛出而不是被捕获
expect(() => divide(10, 0)).toThrow();
// 可以检查特定的错误消息
expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
// 正常情况下不应抛出异常
expect(() => divide(10, 2)).not.toThrow();
});
测试组织
使用 describe 块组织测试
describe 块用于将相关的测试组织在一起。可以嵌套 describe 块来创建层次结构。
javascript
describe('ShoppingCart', () => {
describe('添加商品', () => {
test('应该能够添加商品到空购物车', () => {
// 测试实现
});
});
});
测试钩子
- beforeAll 和 afterAll:这些钩子在测试套件开始前和结束后执行一次。
scss
describe('测试套件', () => {
beforeAll(() => {
// 在所有测试开始前执行
// 适用于:
// - 建立数据库连接
// - 创建共享资源
// - 设置全局配置
});
afterAll(() => {
// 在所有测试结束后执行
// 适用于:
// - 关闭数据库连接
// - 清理共享资源
// - 重置全局配置
});
});
- beforeEach 和 afterEach:这些钩子在每个测试用例前后执行。
scss
describe('ShoppingCart', () => {
let cart: ShoppingCart;
beforeEach(() => {
// 在每个测试前执行
// 适用于:
// - 创建新的测试实例
// - 重置测试状态
// - 准备测试数据
cart = new ShoppingCart();
});
afterEach(() => {
// 在每个测试后执行
// 适用于:
// - 清理测试数据
// - 重置修改过的状态
// - 清空临时存储
cart.clear();
});
});
大致的生命周期顺序是这样的

钩子函数可以是异步的,Vitest会等待它们完成后再继续:
scss
beforeAll(async () => {
await setupDatabase();
});
afterAll(async () => {
await cleanupDatabase();
});
beforeAll / beforeEach支持返回清理函数,类似于 useEffect 的返回,清理函数的执行时机就是 afterAll / afterEach的执行时机。
测试覆盖率
暂时略过
异步测试
异步代码测试是现代JavaScript/TypeScript应用程序测试中的重要部分。本指南将介绍如何使用Vitest有效地测试异步代码。
JavaScript中的异步操作主要有三种形式:
- Promise
- async/await(基于Promise的语法糖)
- 回调函数
Vitest提供了多种方法来测试这些异步操作。
1.测试返回Promise的函数
方法1:返回Promise
最简单的方法是从测试函数中返回Promise:
javascript
test('应该返回存在的用户', () => {
// 返回Promise,让Vitest等待它解析
return api.getUserById('1').then(user => {
expect(user.name).toBe('Alice');
});
});
方法2:使用resolves/rejects匹配器
Vitest提供了.resolves和.rejects匹配器,可以更优雅地测试Promise:
kotlin
test('应该返回存在的用户', () => {
// 使用resolves匹配器
return expect(api.getUserById('1')).resolves.toEqual({
id: '1',
name: 'Alice',
email: 'alice@example.com'
});
});
test('不存在的用户ID应该抛出错误', () => {
// 使用rejects匹配器
return expect(api.getUserById('999')).rejects.toThrow('User with id 999 not found');
});
2.测试async/await函数
使用async/await可以让异步测试代码更加清晰:
dart
test('应该返回匹配邮箱的用户', async () => {
// 使用await等待Promise解析
const user = await api.searchUserByEmail('bob@example.com');
expect(user.name).toBe('Bob');
});
测试异步错误
使用async/await测试异步错误有两种方法:
方法1:使用try/catch
dart
test('不存在的邮箱应该抛出错误', async () => {
try {
await api.searchUserByEmail('nonexistent@example.com');
// 如果没有抛出错误,则测试失败
expect.fail('应该抛出错误但没有');
} catch (error) {
expect(error.message).toContain('not found');
}
});
方法2:使用rejects匹配器(推荐)
dart
test('不存在的邮箱应该抛出错误', async () => {
await expect(api.searchUserByEmail('nonexistent@example.com'))
.rejects.toThrow('User with email nonexistent@example.com not found');
});
3. 测试回调函数
现代方法:将回调包装为Promise(推荐)
最佳实践是将回调函数包装为Promise,然后使用async/await测试:
typescript
test('应该更新存在的用户', async () => {
// 将回调API包装为Promise
const updateUserPromise = (id: string, updates: any) => {
return new Promise((resolve, reject) => {
api.updateUser(id, updates, (error, user) => {
if (error) reject(error);
else resolve(user);
});
});
};
const user = await updateUserPromise('2', { name: 'Robert' });
expect(user.name).toBe('Robert');
});
传统方法:使用返回值(不推荐)
在Vitest中,你可以从测试函数返回一个Promise
javascript
test('应该更新存在的用户', () => {
return new Promise<void>((resolve) => {
api.updateUser('3', { name: 'Charles' }, (error, user) => {
expect(error).toBeNull();
expect(user?.name).toBe('Charles');
resolve();
});
});
});
4.测试并行异步操作
测试并行异步操作(如Promise.all)与测试单个Promise类似:
scss
test('应该并行获取多个用户', async () => {
// 这个接口是一个并行异步接口
const users = await api.getUsers(['1', '2']);
expect(users).toHaveLength(2);
expect(users[0].id).toBe('1');
expect(users[1].id).toBe('2');
});
5. 超时控制
设置测试超时
对于可能需要较长时间的测试,可以设置超时时间:
scss
test('应该在超时内完成操作', async () => {
const result = await api.slowOperation();
expect(result).toBe('Operation completed');
}, 3000); // 设置超时为3秒(默认为5秒)
测试超时行为
使用Vitest的计时器模拟功能测试超时行为:
scss
test('模拟超时失败', async () => {
// 使用假计时器
vi.useFakeTimers();
// 启动异步操作但不等待它
const promise = api.slowOperation();
// 快进时间
vi.advanceTimersByTime(2000);
// 恢复真实计时器
vi.useRealTimers();
// 等待操作完成
const result = await promise;
expect(result).toBe('Operation completed');
});
模拟(Mocking)
1. 为什么需要模拟
在 Vitest 中,模拟(mocking)是在测试中替代实际依赖模块或函数的行为,从而控制输出,以便更容易地验证目标代码的行为。通过模拟,测试变得更加独立和可控,减少了对外部环境的依赖。
- 隔离测试
- 避免测试依赖外部服务(如数据库、API)
- 确保测试的可重复性和可靠性
- 加快测试执行速度
- 控制依赖行为
- 模拟特定的响应或错误情况
- 验证边界条件和错误处理
- 测试难以触发的场景
- 简化测试
- 避免复杂的设置和清理
- 专注于被测试的代码逻辑
- 减少测试的复杂性
2. 模拟的类型
Vitest提供了多种模拟技术:
- 函数模拟 (vi.fn())
用于创建可以跟踪调用和设置返回值的模拟函数:
ini
const mockFn = vi.fn();
mockFn.mockReturnValue('hello');
// 或者对于异步函数
mockFn.mockResolvedValue('hello');
- 模块模拟 (vi.mock())
用于模拟整个模块的导出:
less
vi.mock('./database', () => ({
default: {
query: vi.fn(),
connect: vi.fn()
}
}));
- 对象方法监视 (vi.spyOn())
用于监视和模拟对象的方法:
typescript
const spy = vi.spyOn(object, 'method');
spy.mockImplementation(() => 'mocked');
import { vi, test, expect } from 'vitest';
test('监视console.log', () => {
// 创建一个监视器
const consoleSpy = vi.spyOn(console, 'log');
// 调用被监视的方法
console.log('测试消息');
// 验证调用
expect(consoleSpy).toHaveBeenCalledWith('测试消息');
// 恢复原始实现
consoleSpy.mockRestore();
});
- 计时器模拟 (vi.useFakeTimers())
用于控制时间相关的操作:
scss
import { vi, test, expect } from 'vitest';
// 要测试的函数
function delayedCallback(callback: () => void, delay: number) {
return setTimeout(callback, delay);
}
test('delayedCallback 应该在指定延迟后调用回调', () => {
// 启用假定时器
vi.useFakeTimers();
const callback = vi.fn();
delayedCallback(callback, 1000);
// 在调用前,回调不应该被调用
expect(callback).not.toHaveBeenCalled();
// 快进时间
vi.advanceTimersByTime(1000);
// 现在回调应该被调用了
expect(callback).toHaveBeenCalledTimes(1);
// 恢复真实定时器
vi.useRealTimers();
});
Vitest模拟API
vi.fn() - 创建模拟函数
scss
// 基本用法
const mock = vi.fn();
// 设置返回值
mock.mockReturnValue(42);
mock.mockResolvedValue('async result');
mock.mockRejectedValue(new Error('async error'));
// 设置实现
mock.mockImplementation((arg) => arg * 2);
mock.mockImplementationOnce((arg) => arg * 3);
// 验证调用
expect(mock).toHaveBeenCalled();
expect(mock).toHaveBeenCalledWith(1);
expect(mock).toHaveBeenCalledTimes(2);
vi.spyOn() - 监视对象方法
typescript
// 创建监视
const spy = vi.spyOn(object, 'method');
// 修改实现
spy.mockImplementation(() => 'mocked');
// 恢复原始实现
spy.mockRestore();
vi.mock() - 模拟模块
javascript
// api.ts
export async function fetchData() {
const response = await fetch('https://example.com/data');
const data = await response.json();
return data;
}
export async function postData(data) {
const response = await fetch('https://example.com/data', {
method: 'POST',
body: JSON.stringify(data),
});
const responseData = await response.json();
return responseData;
}
// 模拟整个模块
vi.mock('./api', () => ({
fetchData: vi.fn(),
postData: vi.fn()
}));
// 部分模拟
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
specificFunction: vi.fn()
};
});
快照测试
快照测试(Snapshot Testing)是一种测试技术,主要用于验证界面、输出或复杂结构在不同版本之间是否保持一致
快照测试的主要目的是捕捉组件、函数输出或应用界面在特定时间点的状态,并且在后续的开发过程中,确保这些输出没有发生意外的变化。
- 组件快照测试
用于测试UI组件的渲染输出。在React、Vue等框架中特别有用。
javascript
import { render } from '@testing-library/react';
import { expect, test } from 'vitest';
import Button from './Button';
test('Button renders correctly', () => {
const { container } = render(<Button label="Click me" />);
expect(container).toMatchSnapshot();
});
- 数据结构快照测试
用于测试函数返回的数据结构、配置对象等。
javascript
import { expect, test } from 'vitest';
import { generateConfig } from './config-generator';
test('generateConfig produces the expected configuration', () => {
const config = generateConfig({ env: 'development' });
expect(config).toMatchSnapshot();
});
- API响应快照测试
用于测试API响应的格式和内容。
javascript
import { expect, test } from 'vitest';
import { fetchUserData } from './api-client';
import { vi } from 'vitest';
// 模拟API响应
vi.mock('./api-client');
test('fetchUserData returns the expected data structure', async () => {
const userData = await fetchUserData(123);
expect(userData).toMatchSnapshot();
});
存储快照的两种方式
1. 内联快照
可以使用 toMatchInlineSnapshot()
将内联快照存储在测试文件中。
javascript
import { expect, it } from 'vitest'
it('toUpperCase', () => {
const result = toUpperCase('foobar')
expect(result).toMatchInlineSnapshot()
})
Vitest 不会创建快照文件,而是直接修改测试文件,将快照作为字符串更新到文件中:
javascript
import { expect, it } from 'vitest'
it('toUpperCase', () => {
const result = toUpperCase('foobar')
expect(result).toMatchInlineSnapshot('"FOOBAR"')
})
好处:允许直接查看期望输出,而无需跨不同的文件跳转
坏处:项目太大会导致测试文件变大,可读性变差
2. 文件快照
调用 toMatchSnapshot()
时,将所有快照存储在格式化的快照文件中。这意味着需要转义快照字符串中的一些字符(即双引号 "
和反引号 ```)。同时,可能会丢失快照内容的语法突出显示(如果它们是某种语言)。
为了改善这种情况,引入 toMatchFileSnapshot()
以在文件中显式快照。这允许你为快照文件分配任何文件扩展名,并使它们更具可读性。
javascript
import { expect, it } from 'vitest'
it('render basic', async () => {
const result = renderHTML(h('div', { class: 'foo' }))
await expect(result).toMatchFileSnapshot('./test/basic.output.html')
})
它将与 ./test/basic.output.html
的内容进行比较。并且可以用 --update
标志写回
类型测试
类型测试的主要作用是确保代码在运行时与期望的类型一致,防止类型错误的发生。这可以帮助开发者在编译时就捕捉到潜在的类型问题,提供更强的类型安全性和代码的可维护性
- 基本类型测试
我们可以使用 expectTypeOf 来验证一个值是否是特定的类型。
- 函数参数类型测试
可以验证一个函数参数的类型是否符合预期:
- 对接口类型进行测试
你还可以对接口或者类型别名进行测试:
- 测试联合类型
当处理联合类型时,类型测试也很有用:
组件测试
虽然快照测试可以测试一些组件的内容是否变化,但是对于更多的场景,比如一些交互,还是需要通过安装一些工具才能够实现
Testing Library:一套测试实用工具,鼓励更好的测试实践
- @testing-library/react:用于测试 React 组件
- @testing-library/user-event:用于模拟用户交互
- @testing-library/jest-dom:提供额外的 DOM 断言
组件测试可以分为:
- 渲染测试:验证组件是否正确渲染
- 交互测试:验证组件是否正确响应用户交互
- 状态测试:验证组件状态是否正确更新
- 集成测试:验证组件与其他组件或服务的交互
常用查询方法
- getBy... - 返回匹配的元素,如果没有匹配或有多个匹配则抛出错误
- queryBy... - 返回匹配的元素,如果没有匹配则返回null
- findBy... - 返回一个Promise,解析为匹配的元素,用于异步操作
查询优先级(从高到低):
- getByRole - 通过ARIA角色查询
- getByLabelText - 通过关联的label文本查询
- getByPlaceholderText - 通过placeholder属性查询
- getByText - 通过文本内容查询
- getByDisplayValue - 通过表单元素的当前值查询
- getByAltText - 通过alt属性查询
- getByTitle - 通过title属性查询
- getByTestId - 通过data-testid属性查询(最后手段)
组件测试的最佳实践
- 使用数据测试属性:使用 data-testid 属性来选择元素,而不是依赖于类名或标签
css
<button data-testid="submit-button">提交</button>
- 按角色和文本查询元素:优先使用 getByRole 和 getByText 等查询,这更接近用户如何识别元素
php
screen.getByRole('button', { name: '提交' });
screen.getByText('欢迎使用');
- 使用 userEvent 而不是 fireEvent:userEvent 更接近真实用户交互
scss
// 推荐
await userEvent.click(button);
// 不推荐
fireEvent.click(button);
- 测试可访问性:确保组件具有适当的 ARIA 属性和键盘导航
kotlin
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(input).toHaveAttribute('aria-describedby', 'email-error');
- 使用 act 包装状态更新:确保在断言之前所有状态更新都已完成
scss
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
- 模拟依赖:使用 vi.mock() 或 vi.fn() 模拟外部依赖
ini
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
- 测试边缘情况:测试加载状态、错误状态、空数据等边缘情况
scss
test('在加载状态下禁用提交按钮', () => {
render(<LoginForm isLoading={true} />);
expect(screen.getByRole('button')).toBeDisabled();
});
- 使用自定义渲染函数:为需要特定上下文的组件创建自定义渲染函数
javascript
const renderWithTheme = (ui, { initialTheme = 'light' } = {}) => {
return render(
<ThemeProvider initialTheme={initialTheme}>{ui}</ThemeProvider>
);
};
- 等待异步操作:使用 waitFor 或 findBy* 查询等待异步操作完成
scss
await waitFor(() => {
expect(screen.getByText('登录成功')).toBeInTheDocument();
});
- 保持测试简单:每个测试只测试一个行为或断言,避免复杂的测试逻辑
scss
// 好的做法
test('点击增加按钮增加计数', async () => {
render(<Counter />);
await userEvent.click(screen.getByTestId('increment-button'));
expect(screen.getByTestId('count-input')).toHaveValue('1');
});