TYPESCRIPT / PRACTICE

API 测试用例建模

组合参数与返回类型、保留配对,并从方法表生成可筛选的测试条目。

本章目录CONTENTS ↓

已有 API 签名定义了方法名、参数与返回类型,测试数据需要自动跟随这些定义,并防止交叉配对。本章把 infer、映射与分配组合成测试条目类型。

需要先了解参数元组、infer 的位置和条件分配;具体语法可从文末相关基础返回查阅。本章聚焦数据模型与边界,不实现测试运行器。

同时提取输入和输出,构造测试用例

测试数据既要约束传入的参数,也要约束预期结果的形状。可以在同一次函数结构匹配中,分别提取这两部分。

ts
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 来自返回位置,真分支将二者组织成新的对象类型。这种类型能检查测试数据的形状,不能证明函数实际计算出的值正确;行为仍要通过运行测试验证。

无参数也是合法的函数形状

无参函数同样需要测试用例,它的输入列表用空元组表示。非函数输入则没有调用结构可供提取。

ts
type CountCase = CallCase<() => number>;
// { args: []; expected: number }
type NotCallable = CallCase<string>; // never

空元组 [] 表示零个参数,函数匹配仍然成功;string 不匹配函数结构,才走假分支。这里讨论的是自定义 CallCase;内置 Parameters 对非函数实参有泛型约束,不能把两者的行为混为一谈。

多种函数一起处理时,保留输入与输出的对应关系

测试集合可能同时覆盖字符串转数字、数字转字符串。需要保留每一种调用的合法配对,避免把一种输入与另一种输出混在一起。

ts
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 通常以对象组织方法。测试条目还需要记录方法名,并让名字与该方法的参数、返回类型保持对应。手写每个条目会重复维护整张方法表,可以用映射类型逐键生成。

ts
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 同时用于 nameT[K],因此逐键保持对应关系。映射先生成对象,末尾 [keyof T] 再取出各属性值,得到条目联合。这里只假定每个属性都是必填普通函数。

出现配置字段时,为什么要筛掉整个条目

如果 API 还包含 version: string,测试条目就应排除这个配置字段。上面的写法只让其 test 属性变成 never,仍会产生 { name: "version"; test: never };它没有让整个成员变成 never

条件应控制整个条目是否生成,而不仅控制 test 属性。将条件移到映射的值类型层即可:

ts
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]> 完全等价。分配定义的区别可回看分配与整体检查

官方阅读

这一页,先记到这里。