测试类型化:vitest mock 类型、tsd 类型测试
第23章:测试类型化:vitest mock 类型与 tsd 类型级测试
理解测试类型化是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用vitest mock 类型与 tsd 类型级测试?关键的设计决策和陷阱是什么?
读完本章你将理解:
- TypeScript 测试的两个维度
- 运行时测试:vitest 中的 Mock 类型化
- 类型级测试:用 tsd 验证类型
Level 1 · 你需要知道的(1-3年经验)
TypeScript 测试的两个维度
TypeScript 项目需要两类测试,大多数团队只做了其中一类。
第一类:运行时测试——验证代码在运行时的行为是否正确。这是传统测试(单元测试、集成测试)。vitest 是目前最适合 TypeScript 项目的运行时测试框架。
第二类:类型级测试——验证类型定义本身是否正确。工具函数类型(DeepPartial<T>、NonNullable<T> 等)、函数重载类型、泛型约束是否按预期工作,这些在运行时测试里看不出来,因为类型在运行时被擦除了。
如果你写了一个类型工具函数,只运行测试是不够的:
// 自定义工具类型
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
// 运行时测试无法验证类型是否正确——类型在 JS 里根本不存在
// 必须用类型级测试工具
运行时测试:vitest 中的 Mock 类型化
安装
npm install -D vitest @vitest/coverage-v8
vi.fn() 基础类型
import { vi, describe, it, expect } from 'vitest';
// 不指定类型:fn 是 Mock<[], void>,没有类型信息
const nakedMock = vi.fn();
nakedMock('any', 'arguments', 42); // 什么参数都接受
// 指定类型:vi.fn<参数类型数组, 返回类型>
const typedMock = vi.fn<[name: string, age: number], string>();
typedMock('Alice', 30); // 正确
// typedMock(123, 'Alice'); // 编译错误:参数类型不匹配
// typedMock('Alice'); // 编译错误:少了 age 参数
// 更常见的写法:直接传入实现函数,类型自动推导
const computeDiscount = vi.fn((price: number, rate: number): number => price * (1 - rate));
// 类型推导:Mock<[price: number, rate: number], number>
computeDiscount(100, 0.1); // 正确,返回 number
vi.mocked():为已有函数创建类型化 mock
// 假设有一个实际的 API 模块
// api/users.ts
export async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
export async function createUser(data: CreateUserData): Promise<User> {
const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) });
return res.json();
}
// users.test.ts
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { fetchUser, createUser } from '../api/users';
import { UserService } from '../services/user-service';
// 模拟整个模块
vi.mock('../api/users');
describe('UserService', () => {
// vi.mocked() 把函数类型从 (id: string) => Promise<User>
// 变为 Mock<[id: string], Promise<User>>,解锁所有 mock 方法
const mockedFetchUser = vi.mocked(fetchUser);
const mockedCreateUser = vi.mocked(createUser);
beforeEach(() => {
vi.clearAllMocks();
});
it('should return user when found', async () => {
const mockUser: User = {
id: '1',
name: 'Alice',
email: '[email protected]',
createdAt: new Date('2024-01-01'),
};
// mockResolvedValue 的参数类型与函数返回类型一致:User
mockedFetchUser.mockResolvedValue(mockUser);
// mockedFetchUser.mockResolvedValue({ id: 1 }) // 编译错误:id 应是 string
const service = new UserService();
const result = await service.getUser('1');
expect(result.name).toBe('Alice');
// 验证调用参数类型
expect(mockedFetchUser).toHaveBeenCalledWith('1');
});
it('should handle not found error', async () => {
mockedFetchUser.mockRejectedValue(new Error('User not found'));
const service = new UserService();
await expect(service.getUser('999')).rejects.toThrow('User not found');
});
});
Mock 返回值类型化
interface User { id: string; name: string; email: string }
const mockFn = vi.fn<[id: string], Promise<User>>();
// mockReturnValue:同步返回值
const syncMock = vi.fn<[], number>();
syncMock.mockReturnValue(42); // 参数类型:number
// syncMock.mockReturnValue('hello'); // 编译错误
// mockResolvedValue:Promise resolve 值
mockFn.mockResolvedValue({ id: '1', name: 'Alice', email: '[email protected]' });
// 参数类型:User(不是 Promise<User>,vitest 自动处理)
// mockResolvedValueOnce:只 resolve 一次
mockFn.mockResolvedValueOnce({ id: '1', name: 'Alice', email: '[email protected]' });
mockFn.mockResolvedValueOnce({ id: '2', name: 'Bob', email: '[email protected]' });
// 第一次调用返回 Alice,第二次返回 Bob
// mockRejectedValue:Promise reject
mockFn.mockRejectedValue(new Error('fetch failed'));
// mockImplementation:完整实现
mockFn.mockImplementation(async (id: string): Promise<User> => {
if (id === '404') throw new Error('Not found');
return { id, name: 'Test User', email: '[email protected]' };
});
// mockImplementationOnce:只执行一次的实现
mockFn.mockImplementationOnce(async (id) => ({ id, name: 'First call', email: '[email protected]' }));
vi.spyOn():监视已有对象方法
import { vi, it, expect } from 'vitest';
class EmailService {
async sendWelcomeEmail(email: string): Promise<void> {
// 实际发送逻辑
await smtp.send({ to: email, subject: 'Welcome' });
}
async sendResetEmail(email: string, token: string): Promise<void> {
await smtp.send({ to: email, subject: 'Reset password', body: token });
}
}
it('should send welcome email after registration', async () => {
const emailService = new EmailService();
// spyOn 的类型:从 EmailService 推导方法签名
// spy 类型:SpyInstance<[email: string], Promise<void>>
const spy = vi.spyOn(emailService, 'sendWelcomeEmail').mockResolvedValue(undefined);
await registerUser({ email: '[email protected]' }, emailService);
// toHaveBeenCalledWith 的参数会被类型检查
expect(spy).toHaveBeenCalledWith('[email protected]');
expect(spy).toHaveBeenCalledTimes(1);
});
Mock 整个模块(带类型工厂函数)
// 不带工厂函数的 vi.mock:自动 mock,所有导出变为 vi.fn()
vi.mock('../services/email');
// 带工厂函数的 vi.mock:手动控制 mock 实现,保留类型
vi.mock('../services/email', () => {
// 返回值类型必须与模块实际导出结构匹配
return {
EmailService: vi.fn().mockImplementation(() => ({
sendWelcomeEmail: vi.fn().mockResolvedValue(undefined),
sendResetEmail: vi.fn().mockResolvedValue(undefined),
})),
sendBulkEmail: vi.fn().mockResolvedValue({ sent: 0, failed: 0 }),
};
});
测试异步 hook 和复杂场景
import { renderHook, act } from '@testing-library/react';
import { vi, it, expect } from 'vitest';
// 测试自定义 hook
it('useFetch should return data on success', async () => {
const mockUser: User = { id: '1', name: 'Alice', email: '[email protected]' };
// 模拟全局 fetch
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(mockUser), { status: 200 })
);
const { result } = renderHook(() => useFetch<User>('/api/users/1'));
// 等待异步操作完成
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(result.current.data).toEqual(mockUser);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
fetchSpy.mockRestore();
});
类型级测试:用 tsd 验证类型
运行时测试验证行为,类型级测试验证类型契约。
安装 tsd
npm install -D tsd
在 package.json 里配置:
{
"scripts": {
"test:types": "tsd"
},
"tsd": {
"directory": "src/types/__tests__"
}
}
tsd 基础用法
// src/types/__tests__/utility-types.test-d.ts
// 文件名以 .test-d.ts 结尾,tsd 会识别这些文件
import { expectType, expectNotType, expectError, expectAssignable } from 'tsd';
import type { DeepPartial, DeepReadonly, NonNullableDeep } from '../utility-types';
// ============ 测试 DeepPartial ============
interface Address {
street: string;
city: string;
zip: number;
}
interface User {
id: string;
name: string;
age: number;
address: Address;
}
// 测试:DeepPartial 的所有字段(包括嵌套)都是可选的
declare const partialUser: DeepPartial<User>;
expectType<string | undefined>(partialUser.id);
expectType<string | undefined>(partialUser.name);
expectType<string | undefined>(partialUser.address?.street);
expectType<number | undefined>(partialUser.address?.zip);
// 测试:DeepPartial<User> 可以赋给 Partial<User>(兼容性)
expectAssignable<Partial<User>>(partialUser);
// ============ 测试 DeepReadonly ============
declare const readonlyUser: DeepReadonly<User>;
// 测试:顶层字段是 readonly
expectType<Readonly<string>>(readonlyUser.id);
// 测试:嵌套字段也是 readonly
expectType<Readonly<string>>(readonlyUser.address.street);
用 @ts-expect-error 测试应该失败的情况
// 测试:向 readonly 字段赋值应该报错
// @ts-expect-error — 下一行应该编译失败
readonlyUser.id = 'new-id';
// @ts-expect-error — 嵌套字段也不可修改
readonlyUser.address.street = 'new street';
// 注意:如果 @ts-expect-error 后面的代码实际上没有报错,
// tsd 会把 @ts-expect-error 本身视为错误
// 这样可以确保你的类型约束真正起作用
测试自定义类型工具:完整示例
// src/types/utility-types.ts
// 需要测试的工具类型
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
export type Flatten<T> = T extends Array<infer U> ? U : T;
export type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
Pick<T, Exclude<keyof T, Keys>> &
{ [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>> }[Keys];
export type ValueOf<T> = T[keyof T];
// src/types/__tests__/utility-types.test-d.ts
import { expectType, expectNotType, expectError } from 'tsd';
import type { DeepPartial, Flatten, RequireAtLeastOne, ValueOf } from '../utility-types';
// ============ 测试 Flatten ============
expectType<string>(null as unknown as Flatten<string[]>);
expectType<number>(null as unknown as Flatten<number[]>);
expectType<string>(null as unknown as Flatten<string>); // 非数组原样返回
// ============ 测试 RequireAtLeastOne ============
interface FilterOptions {
name?: string;
email?: string;
id?: string;
}
type AtLeastOneFilter = RequireAtLeastOne<FilterOptions>;
// 至少提供一个字段的对象应该可以赋给 AtLeastOneFilter
const withName: AtLeastOneFilter = { name: 'Alice' };
expectAssignable<AtLeastOneFilter>(withName);
// 空对象不应该可以赋给 AtLeastOneFilter
// @ts-expect-error
const empty: AtLeastOneFilter = {};
// ============ 测试 ValueOf ============
interface StatusMap {
active: 'ACTIVE';
inactive: 'INACTIVE';
pending: 'PENDING';
}
expectType<'ACTIVE' | 'INACTIVE' | 'PENDING'>(null as unknown as ValueOf<StatusMap>);
用 @ts-expect-error 进行精确错误测试
// 这是专门测试"应该失败的代码"的模式
function strictAdd(a: number, b: number): number {
return a + b;
}
// 正确用法应该编译通过
const result = strictAdd(1, 2);
expectType<number>(result);
// 错误用法应该编译失败
// @ts-expect-error — 传字符串给 number 参数
strictAdd('1', 2);
// @ts-expect-error — 参数数量不对
strictAdd(1);
// 如果这里没有 @ts-expect-error,下面这行会通过(但你希望它失败)
// 若加了 @ts-expect-error 但下面没有错误,tsd 也会报错
// 这就实现了"测试应该失败但没有失败"的检测
测试函数重载类型
// 被测的重载函数
function parse(input: string): number;
function parse(input: number): string;
function parse(input: string | number): number | string {
if (typeof input === 'string') return parseInt(input, 10);
return input.toString();
}
// 类型测试
import { expectType } from 'tsd';
// 传 string 应该返回 number
expectType<number>(parse('42'));
// 传 number 应该返回 string
expectType<string>(parse(42));
// @ts-expect-error — 传 boolean 不应该工作
parse(true);
Level 2 · 它是怎么运行的(3-5年经验)
用 @ts-expect-error 替代 tsd(轻量方案)
如果不想引入 tsd,可以用 TypeScript 内置的 @ts-expect-error 做简单的类型测试:
// type-tests.ts — 可以直接放在项目里,tsc 会编译并检查
function identity<T>(value: T): T { return value; }
// 测试:正确的用法通过编译
const s: string = identity('hello'); // OK
const n: number = identity(42); // OK
// 测试:错误的用法被编译器拦截
// @ts-expect-error — 不能把 string 赋给 number 变量
const wrong: number = identity('hello');
// 如果 @ts-expect-error 后面的代码实际上没报错,TypeScript 本身会报错:
// "Unused '@ts-expect-error' directive."
// 这样你就知道原来期望失败的测试现在通过了(可能是类型定义改了)
# 在 CI 里运行类型检查
tsc --noEmit
# 如果有 @ts-expect-error 后面的代码没有报错,tsc 会报错
CI 集成:类型错误作为测试失败
// package.json
{
"scripts": {
"test": "vitest run",
"test:types": "tsd && tsc --noEmit",
"test:all": "npm run test && npm run test:types",
"ci": "npm run test:all"
}
}
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run runtime tests
run: npm test
- name: Run type tests
run: npm run test:types
- name: Type check
run: npx tsc --noEmit
这样配置后,任何类型错误都会导致 CI 失败:
- vitest 捕获运行时行为错误
- tsd 捕获类型定义错误
tsc --noEmit捕获整个项目的类型错误(包括@ts-expect-error滥用)
完整示例:测试 DeepPartial<T> 工具类型
工具类型实现
// src/types/deep-partial.ts
export type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
// 验证一个值符合 DeepPartial 类型
export function mergeWithDefaults<T extends object>(
defaults: T,
overrides: DeepPartial<T>
): T {
const result = { ...defaults };
for (const key in overrides) {
const k = key as keyof T;
const override = overrides[k];
if (override !== undefined) {
if (typeof override === 'object' && !Array.isArray(override)) {
(result[k] as object) = mergeWithDefaults(
result[k] as object,
override as DeepPartial<object>
);
} else {
result[k] = override as T[keyof T];
}
}
}
return result;
}
运行时测试(vitest)
// src/types/__tests__/deep-partial.test.ts
import { describe, it, expect } from 'vitest';
import { mergeWithDefaults } from '../deep-partial';
interface Config {
server: {
host: string;
port: number;
tls: boolean;
};
database: {
url: string;
poolSize: number;
};
debug: boolean;
}
const defaultConfig: Config = {
server: { host: 'localhost', port: 3000, tls: false },
database: { url: 'postgresql://localhost/dev', poolSize: 10 },
debug: false,
};
describe('mergeWithDefaults', () => {
it('should merge top-level properties', () => {
const result = mergeWithDefaults(defaultConfig, { debug: true });
expect(result.debug).toBe(true);
expect(result.server.port).toBe(3000); // unchanged
});
it('should merge nested properties', () => {
const result = mergeWithDefaults(defaultConfig, {
server: { port: 8080 }, // only override port
});
expect(result.server.port).toBe(8080);
expect(result.server.host).toBe('localhost'); // unchanged
expect(result.server.tls).toBe(false); // unchanged
});
it('should return full config with no overrides', () => {
const result = mergeWithDefaults(defaultConfig, {});
expect(result).toEqual(defaultConfig);
});
});
类型测试(tsd)
// src/types/__tests__/deep-partial.test-d.ts
import { expectType, expectAssignable, expectNotAssignable } from 'tsd';
import type { DeepPartial } from '../deep-partial';
import { mergeWithDefaults } from '../deep-partial';
interface Config {
server: { host: string; port: number; tls: boolean };
database: { url: string; poolSize: number };
debug: boolean;
}
// ---- 测试 DeepPartial 类型本身 ----
// 顶层字段可选
declare const partial: DeepPartial<Config>;
expectType<boolean | undefined>(partial.debug);
// 嵌套字段可选
expectType<string | undefined>(partial.server?.host);
expectType<number | undefined>(partial.server?.port);
// DeepPartial<Config> 可以赋给自身
expectAssignable<DeepPartial<Config>>({});
expectAssignable<DeepPartial<Config>>({ debug: true });
expectAssignable<DeepPartial<Config>>({ server: { port: 8080 } });
// 完整的 Config 可以赋给 DeepPartial<Config>
const fullConfig: Config = {
server: { host: 'localhost', port: 3000, tls: false },
database: { url: 'db://localhost', poolSize: 10 },
debug: false,
};
expectAssignable<DeepPartial<Config>>(fullConfig);
// ---- 测试 mergeWithDefaults 返回类型 ----
const result = mergeWithDefaults(fullConfig, { debug: true });
// 返回类型应该是完整的 Config,不是 DeepPartial<Config>
expectType<Config>(result);
expectType<string>(result.server.host); // 不是 string | undefined
// ---- 测试错误情况 ----
// @ts-expect-error — DeepPartial 的字段类型必须匹配
const wrongPartial: DeepPartial<Config> = { debug: 'not-a-boolean' };
// @ts-expect-error — 不存在的字段不可以
const extraField: DeepPartial<Config> = { unknownField: true };
Level 3 · 规范怎么定义的(资深)
vitest 的 vi.fn<[Params], ReturnType>() 利用泛型参数约束 mock 函数的参数和返回值类型。vi.mocked() 将已有函数类型转换为 Mock 类型,使 mockResolvedValue 等方法的参数类型与原函数返回类型一致。tsd 的类型级测试通过在编译时检查 expectType、expectAssignable 等断言来验证类型契约——这些断言在运行时不存在。@ts-expect-error 的"多余指令检测"使其可以作为轻量级类型测试工具:当被注释的代码不再有错误时,TypeScript 编译器会报告这个不一致。
Level 4 · 边界与陷阱(所有人)
反模式
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| mock 函数不指定类型 | mockResolvedValue 接受任何值,错误不会被拦截 |
vi.fn<[Params], ReturnType>() 或用 vi.mocked() |
| 测试实现细节(内部状态)而不是契约(输入输出) | 重构后测试失败,但行为正确 | 测试公共 API 的输入输出 |
| 写了类型工具函数但不写类型测试 | 重构后类型行为变了,没有测试能发现 | 用 tsd 或 @ts-expect-error 测试类型契约 |
@ts-expect-error 加在正确的代码上 |
代码改对后,注释变成错误,而不是提示 | 只用在真正应该失败的代码上 |
CI 只跑 vitest,不跑 tsc --noEmit |
类型错误只在本地开发时发现 | CI 里同时运行 tsc --noEmit 和 tsd |
总结
| 测试维度 | 工具 | 验证什么 |
|---|---|---|
| 运行时行为 | vitest | 函数输出、副作用、异步逻辑 |
| Mock 类型安全 | vi.fn<Params, Return>() / vi.mocked() |
mock 参数和返回值类型与真实函数一致 |
| 类型正确性 | tsd / @ts-expect-error |
工具类型、泛型约束、函数重载类型 |
| 全量类型检查 | tsc --noEmit |
整个项目无类型错误 |
| CI 集成 | 全部组合 | 任何类型或行为错误都阻断合并 |
运行时测试 + 类型级测试 + CI 集成,三者结合才能保证 TypeScript 项目在持续迭代中类型契约不被侵蚀。