name: 测试 description: 测试文件约定的设置函数、工厂模式、测试组织、类型测试和命名。在编写或修改 *.test.ts 文件、创建测试设置函数或评审测试结构时使用。
测试文件约定
文件级文档注释
每个 .test.ts 文件必须以一个 JSDoc 块开头,解释正在测试的内容和验证的关键行为。这作为模块合同的文档。
结构
/**
* [模块名称] 测试
*
* [1-3 句解释此文件测试的内容及其重要性。]
*
* 关键行为:
* - [行为 1]
* - [行为 2]
* - [行为 3]
*
* 另见:
* - `相关文件.test.ts` 用于 [相关方面]
*/
好例子
/**
* 单元级 LWW CRDT 同步测试
*
* 验证单元级 LWW 冲突解决,其中每个字段
* 有自己的时间戳。与行级 LWW 不同,对
* 不同字段的并发编辑独立合并。
*
* 关键行为:
* - 对相同字段的并发编辑:最新时间戳获胜
* - 对不同字段的并发编辑:两者保留(合并)
* - 删除移除行的所有单元
*/
坏例子(太简略)
// 测试 create-tables
节标题
对于长测试文件(100+ 行),使用注释标题分隔逻辑节:
// ============================================================================
// 消息同步测试
// ============================================================================
多方面测试文件拆分
当模块有不同行为方面时,拆分为聚焦的测试文件,而非单一大型文件:
| 模式 | 用例 |
|---|---|
{模块}.test.ts |
核心 CRUD 行为、快乐路径、边缘情况 |
{模块}.types.test.ts |
类型推断验证、负面类型测试 |
{模块}.{场景}.test.ts |
特定场景(CRDT 同步、离线、集成) |
何时拆分
- 文件超过约 500 行
- 测试涵盖真正不同的关注点(CRUD 与同步与类型)
- 不同关注点有不同的设置要求
何时不拆分
- 拆分会创建少于 3 个测试的文件
- 所有测试共享相同设置和关注点
测试命名
测试描述必须是行为断言,而非模糊描述。名称应在测试失败时告诉你什么坏了。
规则
- 陈述发生了什么,而非“应该工作”或“正确处理”
- 包括条件当测试边缘情况时 n3. 无填充词:“应该”、“正确”、“适当”无添加
好名字
test('upsert 存储行且 get 检索它', () => { ... });
test('filter 仅返回已发布的帖子', () => { ... });
test('对不同字段的并发编辑:两者保留', () => { ... });
test('删除与更新竞争:更新获胜(最右条目)', () => { ... });
test('观察者每事务触发一次,非每操作', () => { ... });
test('get() 对未定义表抛出有帮助消息', () => { ... });
坏名字
test('应该正确工作', () => { ... }); // 什么工作?什么正确?
test('应该处理批量操作', () => { ... }); // 如何处理?
test('基本测试', () => { ... }); // 什么都没说
test('应该正确创建和检索行', () => { ... }); // 模糊“正确”
模式:{动作} {结果} [条件]
“upsert 存储行且 get 检索它”
^^^^^^ ^^^^^^^^^^ ^^^ ^^^^^^^^^^^^^
动作 结果 动作 结果
“观察者每事务触发一次,非每操作”
^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
主题 结果 条件
“get() 返回未找到对于不存在的行”
^^^^^ ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^
动作 结果 条件
负面类型测试
对于库代码,测试不正确类型被拒绝。使用 @ts-expect-error 验证编译器捕获类型错误。
何时使用
.types.test.ts文件测试类型推断- 任何测试验证公共 API 的类型约束
- 特别重要对于泛型 API,其中不正确输入应在编译时失败
模式
test('在编译时拒绝无效行数据', () => {
const doc = createTables(new Y.Doc(), [
table({
id: 'posts',
name: '',
fields: [id(), text({ id: 'title' })] as const,
}),
]);
// @ts-expect-error — 缺少必需字段 'title'
doc.get('posts').upsert({ id: Id('1') });
// @ts-expect-error — 错误类型对于 'title'(数字替代字符串)
doc.get('posts').upsert({ id: Id('1'), title: 42 });
// @ts-expect-error — 未知表名
doc.get('nonexistent');
});
规则
- 总是包括注释解释期望什么错误:
// @ts-expect-error — [原因] - 一个
@ts-expect-error每断言 — 不要堆叠它们 - 在它们自己的
describe('类型错误', () => { ... })块中分组负面类型测试 - 这些测试验证编译器捕获错误 — 它们不需要运行时断言
在 bun:test 中(无 expectTypeOf)
由于我们使用 bun:test(非 Vitest),我们没有 expectTypeOf。使用这些替代:
- 正面类型测试:让 TypeScript 检查类型 — 如果编译,类型工作。添加注释如
// 类型:{ id: string; title: string }用于文档。 - 负面类型测试:
@ts-expect-error验证拒绝 - CI 强制执行:
bun typecheck(运行tsc --noEmit)捕获类型回归
测试中无 as any
测试必须不使用 as any 绕过类型检查。测试应证明类型工作,而非规避它们。
替代
| 替代 | 使用 |
|---|---|
(obj as any).privateMethod() |
通过公共 API 测试 |
tables.get('bad' as any) |
仅当测试无效输入的运行时错误处理时保持 as any — 添加注释解释为什么 |
createMock() as any |
创建正确类型的模拟或使用最小类型 |
(content as any).store.ensure(id) |
暴露仅测试访问器或通过公共 API 测试 |
可接受的 as any(带注释)
// 测试无效表名的运行时错误 — 故意绕过 TypeScript
expect(() => tables.get('nonexistent' as any)).toThrow(
/表 'nonexistent' 未找到/,
);
绝不可接受
// 坏 — 隐藏真实类型问题
const result = someFunction(data as any);
expect(result).toBe('expected');
setup() 模式
每个需要共享基础设施的测试文件必须有一个 setup() 函数。这替换 beforeEach 用于代码重用,遵循 Kent C. Dodds 原则:“我们有函数做那件事。”
规则
setup()总是返回解构对象,即使对于单值- 测试总是解构返回:
const { thing } = setup() setup()是普通函数,非钩子 — 每个测试独立调用它- 在 describe 范围无可变
let变量 — setup 每测试返回新鲜状态
为什么总是对象(即使对于单值)
- 可扩展性:稍后添加第二值不需要更改任何现有调用点
- 自文档:
const { files } = setup()通过名称告诉你你得到什么 - 一致性:每个测试文件遵循相同模式 — 无猜测
单值
// 好 — 总是对象,即使对于单事物
function setup() {
const ws = createWorkspace({ id: 'test', tables: { files: filesTable } });
return { files: ws.tables.files };
}
test('创建文件', () => {
const { files } = setup();
files.set({ id: '1', name: 'test.txt', _v: 1 });
expect(files.has('1')).toBe(true);
});
// 坏 — 直接返回值
function setup() {
const ws = createWorkspace({ id: 'test', tables: { files: filesTable } });
return ws.tables.files; // 无解构 = 破坏约定
}
多值
function setup() {
const ydoc = new Y.Doc();
const yarray = ydoc.getArray<YKeyValueLwwEntry<unknown>>('test-table');
const ykv = new YKeyValueLww(yarray);
return { ydoc, yarray, ykv };
}
test('存储行', () => {
const { ykv } = setup(); // 仅取所需
// ...
});
test('原子事务', () => {
const { ydoc, ykv } = setup(); // 需要时取多个
ydoc.transact(() => {
ykv.set('1', { name: 'Alice' });
});
});
可组合设置函数
当测试需要超出基础的额外设置时,创建可组合设置变体,构建在 setup() 上:
function setup() {
const tableDef = defineTable(fileSchema);
const ydoc = new Y.Doc({ guid: 'test-workspace' });
const tables = createTables(ydoc, { files: tableDef });
return { ydoc, tables };
}
function setupWithBinding(
overrides?: Partial<Parameters<typeof createDocumentBinding>[0]>,
) {
const { ydoc, tables } = setup();
const binding = createDocumentBinding({
guidKey: 'id',
tableHelper: tables.files,
ydoc,
...overrides,
});
return { ydoc, tables, binding };
}
何时不需要 setup()
- 纯函数测试无共享基础设施(如
parseFrontmatter('# Hello')) - 测试其中每个案例有完全不同的输入,无重叠
- 仅类型测试文件(
*.test-d.ts)
避免 beforeEach 用于设置
仅对必须运行的清理使用 beforeEach/afterEach,即使测试失败(服务器关闭、间谍恢复)。绝不对数据设置使用它们。
// 坏 — 可变状态、隐藏设置
let files: TableHelper;
beforeEach(() => {
const ws = createWorkspace({ id: 'test', tables: { files: filesTable } });
files = ws.tables.files;
});
// 好 — 设置函数、每测试不可变
function setup() {
const ws = createWorkspace({ id: 'test', tables: { files: filesTable } });
return { files: ws.tables.files };
}
模块级共享模式
跨多个测试使用的模式和表定义应在模块级定义,在 setup() 外部:
const fileSchema = type({
id: 'string',
name: 'string',
updatedAt: 'number',
_v: '1',
});
const filesTable = defineTable(fileSchema);
function setup() {
const ws = createWorkspace({ id: 'test', tables: { files: filesTable } });
return { files: ws.tables.files };
}
这些是无状态定义 — 安全共享。有状态对象(Y.Doc、工作空间实例)放入 setup()。
不返回死重
设置返回中的每个属性应被至少一个测试使用。如果无测试使用 ydoc,不返回它:
// 坏 — ydoc 无任何测试解构
function setup() {
const ydoc = new Y.Doc();
return { ydoc, tl: createTimeline(ydoc) };
}
// 好 — 仅返回测试实际使用的
function setup() {
return { tl: createTimeline(new Y.Doc()) };
}
例外:如果值需要用于清理或可能被同一文件中未来测试需要,保留它是可以的。
测试结构
扁平优于嵌套
优选扁平 test() 调用。仅使用 describe() 分组相同单元的真正不同行为类别:
// 好 — describe 分组行为,测试内扁平
describe('文件树', () => {
describe('创建', () => {
test('在根创建文件', () => { ... });
test('拒绝无效名称', () => { ... });
});
describe('移动', () => {
test('重命名文件', () => { ... });
test('移动到不同父级', () => { ... });
});
});
// 坏 — 不必要嵌套
describe('文件树', () => {
describe('创建', () => {
describe('当名称有效时', () => {
describe('且父级存在时', () => {
test('创建文件', () => { ... });
});
});
});
});
辅助函数优于嵌套
当测试需要不同设置场景时,使用命名设置变体(非嵌套 describe + beforeEach):
// 好 — 可组合设置函数
function setupWithFiles() {
const { files } = setup();
files.set(makeRow('f1', 'test.txt'));
files.set(makeRow('f2', 'other.txt'));
return { files };
}
test('列出所有文件', () => {
const { files } = setupWithFiles();
expect(files.count()).toBe(2);
});
参考文献
- Kent C. Dodds, “测试时避免嵌套” — 设置函数优于 beforeEach,扁平测试
- Kent C. Dodds, “AHA 测试” — 避免测试中草率抽象
- Kent C. Dodds, 测试 JavaScript — 测试对象工厂模式
- Matt Pocock, “如何测试你的类型” — vitest
expectTypeOf用于类型测试 - Matt Pocock,
shoehorn— 用于测试工效的部分模拟