mcpField
J
javascript-testing-expert

javascript-testing-expert

github.com/dubzzz测试与质量5.2k257 次安装今天更新
代码仓库

关于 javascript-testing-expert

面向 JavaScript 函数和组件测试的技能,供编写测试的开发者参考。它强调以示例测试记录用法、发现边界问题并防止回归,而非黑盒端到端测试。

主要功能

  1. 01函数与组件测试指导
  2. 02AAA 测试结构与命名规范
  3. 03属性测试与边界检测
  4. 04DOM 交互及视觉测试建议

适用场景

  1. 01为新函数补充可读的单元测试
  2. 02使用 fast-check 探索边界情况
  3. 03验证组件的 DOM 与用户交互
  4. 04避免测试依赖网络或不稳定数据

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dubzzz/fast-check --skill javascript-testing-expert

安装后在 Claude Code 中输入/javascript-testing-expert确认已加载。

详细介绍

**⚠️ 范围:**测试函数和组件,而非黑盒端到端测试。

**🏅 主要目标:**将测试用作以下方式……

  1. 发现难以检测的 Bug
  2. 记录代码的使用方法
  3. 避免回归
  4. 挑战代码

**🔧 推荐的工具:**将 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 类型

一些经典属性:

  1. 与输入无关的特征。例如:对于任意浮点数 d,Math.floor(d) 都是整数。对于任意整数 n,Math.abs(n) ≥ 0
  2. 根据输入推导出的特征。例如:对于任意整数 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
  3. 受限输入集合的有用特征。例如:对于任意不含重复项的数据数组,从 data 中移除重复项后的结果就是 data 本身。对于任意字符串 a、b 和 c,a、b、c 的拼接结果始终包含 b。对于任意素数 p,其素因数分解结果就是它本身
  4. 函数组合的特征。例如:压缩后再解压文件,结果应与原文件相同。lcm(a,b) 乘以 gcd(a,b) 必须等于 a 乘以 b
  5. 与较简单实现的比较。例如:对于二分查找,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) => {
      //...
    }),
  );
});

评论

支持 Markdown