API 测试用例建模
组合参数与返回类型、保留配对,并从方法表生成可筛选的测试条目。
本章目录CONTENTS ↓
已有 API 签名定义了方法名、参数与返回类型,测试数据需要自动跟随这些定义,并防止交叉配对。本章把 infer、映射与分配组合成测试条目类型。
需要先了解参数元组、infer 的位置和条件分配;具体语法可从文末相关基础返回查阅。本章聚焦数据模型与边界,不实现测试运行器。
同时提取输入和输出,构造测试用例
测试数据既要约束传入的参数,也要约束预期结果的形状。可以在同一次函数结构匹配中,分别提取这两部分。
type CallCase<T> =
T extends (...args: infer P) => infer R
? { args: P; expected: R }
: never;
type Create = (name: string, count: number) => { id: string };
type CreateCase = CallCase<Create>;
// {
// args: [name: string, count: number];
// expected: { id: string };
// }P 来自参数列表,R 来自返回位置,真分支将二者组织成新的对象类型。这种类型能检查测试数据的形状,不能证明函数实际计算出的值正确;行为仍要通过运行测试验证。
无参数也是合法的函数形状
无参函数同样需要测试用例,它的输入列表用空元组表示。非函数输入则没有调用结构可供提取。
type CountCase = CallCase<() => number>;
// { args: []; expected: number }
type NotCallable = CallCase<string>; // never空元组 [] 表示零个参数,函数匹配仍然成功;string 不匹配函数结构,才走假分支。这里讨论的是自定义 CallCase;内置 Parameters 对非函数实参有泛型约束,不能把两者的行为混为一谈。
多种函数一起处理时,保留输入与输出的对应关系
测试集合可能同时覆盖字符串转数字、数字转字符串。需要保留每一种调用的合法配对,避免把一种输入与另一种输出混在一起。
type Convert =
| ((value: string) => number)
| ((value: number) => string);
type Cases = CallCase<Convert>;
// { args: [value: string]; expected: number }
// | { args: [value: number]; expected: string }
type LooseCase = {
args: Cases["args"];
expected: Cases["expected"];
};
// { args: [string] | [number]; expected: number | string }
const crossed: LooseCase = { args: ["42"], expected: "42" };
// 同样的对象不能赋给 Cases:没有成员允许这组配对。CallCase 对每个函数成员分别构造完整对象,再组成联合,因此保留了对应关系。LooseCase 分别取出两组属性联合后重新组装,允许交叉组合;它的 args 仍是单元素元组联合,并没有变成任意长度数组。
这与状态建模中的规则一致:有关联的信息要放在同一个联合成员里。本节只构造数据类型,不讨论直接调用函数联合的规则。
从 API 方法表生成带名称的测试条目
实际 API 通常以对象组织方法。测试条目还需要记录方法名,并让名字与该方法的参数、返回类型保持对应。手写每个条目会重复维护整张方法表,可以用映射类型逐键生成。
type API = {
parse: (text: string) => number;
format: (value: number) => string;
};
type ApiCases<T> = {
[K in keyof T]: {
name: K;
test: T[K] extends (...args: infer P) => infer R
? { args: P; expected: R }
: never;
}
}[keyof T];
type ApiTest = ApiCases<API>;
// { name: "parse"; test: { args: [string]; expected: number } }
// | { name: "format"; test: { args: [number]; expected: string } }当前 K 同时用于 name 和 T[K],因此逐键保持对应关系。映射先生成对象,末尾 [keyof T] 再取出各属性值,得到条目联合。这里只假定每个属性都是必填普通函数。
出现配置字段时,为什么要筛掉整个条目
如果 API 还包含 version: string,测试条目就应排除这个配置字段。上面的写法只让其 test 属性变成 never,仍会产生 { name: "version"; test: never };它没有让整个成员变成 never。
条件应控制整个条目是否生成,而不仅控制 test 属性。将条件移到映射的值类型层即可:
type MixedAPI = {
parse: (text: string) => number;
format: (value: number) => string;
version: string;
};
type FunctionApiCases<T> = {
[K in keyof T]: T[K] extends (...args: infer P) => infer R
? { name: K; test: { args: P; expected: R } }
: never
}[keyof T];
type BeforeNames = ApiCases<MixedAPI>["name"];
// "parse" | "format" | "version"
type AfterNames = FunctionApiCases<MixedAPI>["name"];
// "parse" | "format"中间映射对象仍包含 version: never,它的键没有在映射阶段删除。尾部索引得到“parse 条目 | format 条目 | never”,联合中的 never 才被消去。
这些映射例子使用必填属性,函数属性为单个普通签名。直接检查 T[K] 不触发裸参数分配;若属性本身改成函数联合,不能直接假定它与调用独立的 CallCase<T[K]> 完全等价。分配定义的区别可回看分配与整体检查。
官方阅读
- 条件类型中的 infer:结构提取与真分支使用。
- infer 的声明位置:条件匹配与普通泛型约束的区别。
- Parameters 与 ReturnType:已有工具的用途。
- 元组类型的剩余参数:参数列表与元组的对应。
- 映射类型 与 索引访问:逐键构造与取值。
这一页,先记到这里。