关于 javascript-testing-expert
面向 JavaScript 函数和组件测试的技能,供编写测试的开发者参考。它强调以示例测试记录用法、发现边界问题并防止回归,而非黑盒端到端测试。
主要功能
- 01函数与组件测试指导
- 02AAA 测试结构与命名规范
- 03属性测试与边界检测
- 04DOM 交互及视觉测试建议
适用场景
- 01为新函数补充可读的单元测试
- 02使用 fast-check 探索边界情况
- 03验证组件的 DOM 与用户交互
- 04避免测试依赖网络或不稳定数据
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dubzzz/ fast-check --skill javascript-testing-expert安装后在 Claude Code 中输入/javascript-testing-expert确认已加载。
详细介绍
**⚠️ 范围:**测试函数和组件,而非黑盒端到端测试。
**🏅 主要目标:**将测试用作以下方式……
- 发现难以检测的 Bug
- 记录代码的使用方法
- 避免回归
- 挑战代码
**🔧 推荐的工具:**将 vitest、fast-check、@fast-check/vitest、@testing-library/*、@vitest/browser-playwright、msw 安装为开发依赖。
✅ 应当尝试安装缺失且相关的工具,但只推荐安装 @fast-check/vitest 和浏览器测试工具。
✅ 应当强烈建议用户安装缺失且相关的工具。
✅ 应当适应缺失的工具。
文件和代码布局
✅ 应当添加新测试时模仿项目现有的测试结构
✅ 应当每个代码文件对应一个测试文件
👍 优先使用 .spec.ts 扩展名(例如,fileName.ts → fileName.spec.ts);如果项目没有现有的测试结构,则将测试文件与源文件放在一起
✅ 应当使用 it 时,将其放在 describe 中
👍 优先使用 it 而非 test
✅ 应当将 describe 命名为被测函数的名称
✅ 应当为每个被测函数使用单独的 describe
✅ 应当让 it 的名称以 "should" 开头,并确保名称清晰、尽可能简洁,且可以隐含地在前面加上 "it" 后读作一个句子
✅ 应当从简单且具有说明性的测试开始
✅ 应当继续编写高级测试,寻找边界情况
❌ 不要明确区分简单测试和高级测试,只需按正确顺序排列
✅ 应当将此文件专用的辅助函数放在所有 describe 之后,并紧接在标记辅助函数部分开头的注释 // Helpers 之下
核心准则
✅ 应当遵循 AAA 模式,并在测试中清晰体现
it('should...', () => {
// Arrange
code;
// Act
code;
// Assert
code;
});
✅ 应当让测试聚焦,尽量断言一个明确的方面
✅ 应当保持测试简单
👎 避免在测试或其辅助函数中编写复杂逻辑
❌ 不要测试内部细节
👍 优先使用存根而非模拟;前者提供替代实现,后者有助于断言是否进行了调用
为什么?断言调用次数通常并非函数使用者关心的关键事项,而纯粹是内部细节
❌ 不要依赖网络调用,应使用 msw 对其进行存根
✅ 应当如果某个 it 使用了模拟或间谍,或修改了全局变量,则在 beforeEach 中重置全局变量和模拟
或者,使用 vitest 时,可以检查配置中是否启用了 mockReset、unstubEnvs 和 unstubGlobals 标志;如果启用,则默认会重置全局变量
👍 优先在类似文档的测试中使用真实数据
例如:如果需要创建用户实例,请使用真实姓名
❌ 不要过度使用快照测试;只有在“快照中预期看到的内容”明确时才对内容进行快照
为什么?快照测试往往会捕获过多细节,导致难以更新,因为后续读者会不清楚实际测试的内容是什么
👍 优先在形状和结构很重要时使用快照(组件层级、属性、输出结构的非回归)
👍 优先在最终渲染效果很重要时使用截图(视觉样式、布局)
✅ 应当当被测代码需要伪造过多参数和/或模拟或存根(超过 10 个)时,提醒开发者
为什么?代码难以测试通常是一种代码异味,表明 API 需要更改。这类代码更难扩展、更难理解,而且往往承担了过多职责。建议遵循单一职责原则(SRP)
✅ 应当尝试通过将重复逻辑提取到辅助函数中,让测试更短、更易读
✅ 应当使用清晰明确的函数名,将共享逻辑归组;这些辅助函数也应遵循单一职责原则
例如:避免使用包含许多可选参数或执行多项操作的函数
❌ 不要在所有测试的执行部分复用一个庞大的 prepare 函数;应让名称更清晰,并最终将其拆分为多个函数
✅ 要确保删除原本应该让测试通过的因素后,测试就会失败
例如:当测试写着“当 Y 时应该执行 X”,要确保没有 Y 时测试会失败,而不是仍然通过。
👎 避免编写实体的未使用字段包含硬编码值的测试
测试内容示例
const user: User = {
name: 'Paul', // unused
birthday: '2010-02-03',
};
const age = computeAge(user);
//...
👍 优先使用 @fast-check/vitest(如果已安装)
import { describe } from 'vitest';
import { it, fc } from '@fast-check/vitest';
describe('computeAge', () => {
it('should compute a positive age', ({ g }) => {
// Arrange
const user: User = {
name: g(fc.string), // unused
birthday: '2010-02-03',
};
// Act
const age = computeAge(user);
// Assert
expect(age).toBeGreaterThan(0);
});
});
👍 优先使用 fast-check(如果已安装,但未安装 @fast-check/vitest)
👎 避免编写依赖不稳定值的测试
例如:上面的示例中,computeAge 依赖当前日期
备注:地区设置和许多其他依赖平台的值也有同样的问题。
👍 优先使用 vi.setSystemTime 固定今天的日期
👍 优先使用 @fast-check/vitest 控制今天的日期
原因:与单独使用 vi.setSystemTime 不同,你可以在每次运行时使用一个新的日期来检查代码;如果某天测试失败,系统会报告导致问题的确切日期。
// Arrange
vi.setSystemTime(g(fc.date, { min: new Date('2010-02-04'), noInvalidDate: true }));
const user: User = {
name: g(fc.string), // unused
birthday: '2010-02-03',
};
👎 避免编写依赖随机值或实体的测试
👍 优先使用 @fast-check/vitest(如果已安装)或 fast-check(否则)控制随机生成的值
✅ 要对任何涉及“总是”或“从不”的测试使用基于属性的测试
例如:测试名称为“当 Y 时应该总是执行 X”或“当 Y 时绝不执行 X”
备注:将这些测试视为高级测试,将它们放在文档测试之后,而不是与文档测试放在一起。
👍 优先使用基于属性的测试来发现边界情况,而不是逐个编写所有情况
❌ 不要尝试使用基于属性的测试覆盖算法的所有情况
原因:基于属性的测试与基于示例的测试互为补充。基于属性的测试擅长发现边界情况并验证通用属性,而基于示例的测试则能清晰地说明代码,并覆盖特定的重要场景。两种方法结合使用,才能全面覆盖测试。
// for all a, b, c strings
// b is a substring of a + b + c
it.prop([fc.string(), fc.string(), fc.string()])('should detect the substring', (a, b, c) => {
// Arrange
const text = a + b + c;
const pattern = b;
// Act
const result = isSubstring(text, pattern);
// Assert
expect(result).toBe(true);
});
✅ 要将组件中的复杂逻辑提取到专门且可测试的函数中
❌ 不要测试复杂度为零的简单组件逻辑
👍 优先使用 testing-library 时测试 DOM 结构和用户交互
👍 优先使用浏览器测试时测试视觉呈现和用户交互
👍 优先在使用 testing-library 和浏览器测试时,尽可能使用 getByRole、getByLabelText、getByText,通过无障碍属性和用户可见文本查询,而不是使用 getByTestId
✅ 要在条件允许时使用浏览器截图测试,确保 Design System 组件,以及更广泛来说的视觉组件,不会出现非视觉方面的回归
✅ 要在无法运行截图测试时,使用捕获 DOM 结构的快照测试作为替代
属性指南
本节内容均适用于基于属性的测试!
**⚠️ 版本兼容性:**从 fast-check v5 开始,同步和异步断言都使用 fc.property。在 fast-check v4 中,同步断言使用 fc.property,异步断言使用 fc.asyncProperty。
✅ 要直接使用 fast-check 时,始终等待 fc.assert(...) 完成,无论使用 v4 还是 v5+,即使断言是同步的。将外层测试回调设为 async,以便测试运行器报告属性测试失败。
⚠️ 重要:使用来自 @fast-check/vitest 的 g 时,应将任意值生成器函数(例如 fc.string、fc.date)及其参数作为独立参数传给 g,而不是传入调用该函数的结果。
正确:g(fc.string)、g(fc.date, { min: new Date('2010-01-01') })
错误:g(fc.string())、g(fc.date({ min: new Date('2010-01-01') }))
❌ 不要直接生成输入
这样做的风险是,你可能会在测试中重写被测代码。
✅ 应该构造值来生成一些输入,并且知道预期结果
❌ 不要期望返回值的具体细节;很多情况下,你没有足够的信息来断言完整的值
✅ 应该期望返回值的某些方面和特征
❌ 绝不要在任意生成器上指定 maxLength,除非这是算法的要求
👍 建议指定 size: '-1',如果你觉得算法处理大型输入会花很长时间(默认情况下,fast-check 生成的项目数最多为 10,因此只有在明确需要时才使用 size)
例如:不要使用 fc.string({maxLength: 5}) 或 fc.array(arb, {maxLength: 8}),除非字符串长度有明确要求
❌ 绝不要在任意生成器上指定非其要求的约束,尽可能使用默认值
例如:如果算法应接受任意整数,就只生成整数,不要指定最小值和最大值
👎 避免过度使用 .filter 和 fc.pre
为什么?它们会丢弃生成的值,从而降低值的生成速度
👍 建议使用任意生成器提供的选项,直接生成有效值
例如:使用 fc.string({ minLength: 2 }),而不是 fc.string().filter(s => s.length >= 2)
例如:使用 fc.integer({ min: 1 }),而不是 fc.integer().filter(n => n >= 1);或者使用 fc.nat(),而不是 fc.integer().filter(n => n >= 0)
👍 建议当 map 技巧可以避免筛选时,使用 map 代替 filter
例如:使用 fc.nat().map(n => n * 2) 生成偶数
例如:使用 fc.tuple(fc.string(), fc.string()).map(([start, end]) => start + 'A' + end) 生成始终包含字符 'A' 的字符串
👍 建议在谓词中进行整数计算时,若存在溢出风险(例如,对生成的值执行 pow、乘法等操作),优先使用 bigint 类型而非 number 类型
一些经典属性:
- 与输入无关的特征。例如:对于任意浮点数 d,Math.floor(d) 都是整数。对于任意整数 n,Math.abs(n) ≥ 0
- 根据输入推导出的特征。例如:对于任意整数 a 和 b,a 和 b 的平均值介于 a 和 b 之间。对于任意 n,n 的素因数分解中所有数的乘积都等于 n。对于任意数据数组,sorted(data) 与 data 包含相同的元素。对于任意整数 n1、n2,若 n1 != n2,则 romanString(n1) != romanString(n2)。对于任意浮点数 d,Math.floor(d) 是整数,且满足 d-1 ≤ Math.floor(d) ≤ d
- 受限输入集合的有用特征。例如:对于任意不含重复项的数据数组,从 data 中移除重复项后的结果就是 data 本身。对于任意字符串 a、b 和 c,a、b、c 的拼接结果始终包含 b。对于任意素数 p,其素因数分解结果就是它本身
- 函数组合的特征。例如:压缩后再解压文件,结果应与原文件相同。lcm(a,b) 乘以 gcd(a,b) 必须等于 a 乘以 b
- 与较简单实现的比较。例如:对于二分查找,c 包含在已排序数组 data 中,等价于对于线性查找,c 包含在 data 中
竞态条件指南
✅ 应该编写检查竞态条件并尝试不同解析顺序的测试——由 fast-check 自动处理——当算法接受异步函数作为输入时
✅ 应该利用 fast-check 及其 fc.scheduler() 任意生成器,测试依赖异步函数的异步代码
将:
it('should resolve in call order', async () => {
// Arrange
const seenAnswers = [];
const call = vi.fn().mockImplementation((v) => Promise.resolve(v));
// Act
const queued = queue(call);
await Promise.all([queued(1).then((v) => seenAnswers.push(v)), queued(2).then((v) => seenAnswers.push(v))]);
// Assert
expect(seenAnswers).toEqual([1, 2]);
});
改为(fast-check v5+;在 v4 中使用 fc.asyncProperty 代替 fc.property):
it('should resolve in call order', async () => {
await fc.assert(
fc.property(fc.scheduler(), async (s) => {
// Arrange
const seenAnswers = [];
const call = vi.fn().mockImplementation((v) => Promise.resolve(v));
// Act
const queued = queue(s.scheduleFunction(call));
await s.waitFor(
Promise.all([queued(1).then((v) => seenAnswers.push(v)), queued(2).then((v) => seenAnswers.push(v))]),
);
// Assert
expect(seenAnswers).toEqual([1, 2]);
}),
);
});
给 faker 用户的建议
如果使用 faker 生成虚构数据,我们建议通过以下代码片段,将虚构数据的生成接入 fast-check:
// Source: https://fast-check.dev/blog/2024/07/18/integrating-faker-with-fast-check/
import { Faker, Randomizer, base } from '@faker-js/faker';
import * as fc from 'fast-check';
class FakerBuilder<TValue> extends fc.Arbitrary<TValue> {
constructor(private readonly generator: (faker: Faker) => TValue) {
super();
}
generate(mrng: fc.Random, biasFactor: number | undefined): fc.Value<TValue> {
const randomizer: Randomizer = {
next: (): number => (mrng.nextInt(0, 0x3ffffff) * 2 ** 27 + mrng.nextInt(0, 0x7ffffff)) / 2 ** 53,
seed: () => {}, // no-op, no support for updates of the seed, could even throw
};
const customFaker = new Faker({ locale: base, randomizer });
return new fc.Value(this.generator(customFaker), undefined);
}
canShrinkWithoutContext(value: unknown): value is TValue {
return false;
}
shrink(value: TValue, context: unknown): fc.Stream<fc.Value<TValue>> {
return fc.Stream.nil();
}
}
function fakerToArb<TValue>(generator: (faker: Faker) => TValue): fc.Arbitrary<TValue> {
return new FakerBuilder(generator);
}
使用示例
await fc.assert(
fc.property(
fakerToArb((faker) => faker.person.firstName),
fakerToArb((faker) => faker.person.lastName),
(firstName, lastName) => {
// code
},
),
);
fast-check 与 @fast-check/vitest 等效
示例 1。
// with @fast-check/vitest
import { it, fc } from '@fast-check/vitest';
it('...', ({ g }) => {
//...
});
// with fast-check
import { it } from 'vitest';
import * as fc from 'fast-check';
it('...', async () => {
await fc.assert(
fc.property(fc.gen(), (g) => {
//...
}),
);
});
示例 2。
// with @fast-check/vitest
import { it, fc } from '@fast-check/vitest';
it.prop([...arbitraries])('...', (...values) => {
//...
});
// with fast-check
import { it } from 'vitest';
import * as fc from 'fast-check';
it('...', async () => {
await fc.assert(
fc.property(...arbitraries, (...values) => {
//...
}),
);
});
示例 3。对于异步谓词,在 fast-check v5+ 中使用 fc.property(如下所示),在 v4 中使用 fc.asyncProperty。始终等待 fc.assert,与上述同步示例一样。
// with @fast-check/vitest
import { it, fc } from '@fast-check/vitest';
it.prop([...arbitraries])('...', async (...values) => {
//...
});
// with fast-check
import { it } from 'vitest';
import * as fc from 'fast-check';
it('...', async () => {
await fc.assert(
fc.property(...arbitraries, async (...values) => {
//...
}),
);
});