TypeScript测试文件规范Skill testing

这个技能提供了编写TypeScript测试文件的详细指南,涵盖文件级文档注释、文件结构拆分、测试命名规范、负面类型测试、setup函数模式等,旨在提高测试代码的可读性、维护性和类型安全性。关键词:测试、TypeScript、文件结构、命名规范、setup函数、类型测试、代码质量、bun:test、CRDT、LWW。

测试 0 次安装 0 次浏览 更新于 3/20/2026

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 个测试的文件
  • 所有测试共享相同设置和关注点

测试命名

测试描述必须是行为断言,而非模糊描述。名称应在测试失败时告诉你什么坏了。

规则

  1. 陈述发生了什么,而非“应该工作”或“正确处理”
  2. 包括条件当测试边缘情况时 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');
});

规则

  1. 总是包括注释解释期望什么错误:// @ts-expect-error — [原因]
  2. 一个 @ts-expect-error 每断言 — 不要堆叠它们
  3. 在它们自己的 describe('类型错误', () => { ... }) 块中分组负面类型测试
  4. 这些测试验证编译器捕获错误 — 它们不需要运行时断言

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 原则:“我们有函数做那件事。”

规则

  1. setup() 总是返回解构对象,即使对于单值
  2. 测试总是解构返回:const { thing } = setup()
  3. setup() 是普通函数,非钩子 — 每个测试独立调用它
  4. 在 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);
});

参考文献