第 23 章

测试类型化:vitest mock 类型、tsd 类型测试

第23章:测试类型化: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 失败:

完整示例:测试 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 的类型级测试通过在编译时检查 expectTypeexpectAssignable 等断言来验证类型契约——这些断言在运行时不存在。@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 项目在持续迭代中类型契约不被侵蚀。

本章评分
4.8  / 5  (6 评分)

💬 留言讨论