强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

TypeScript 开发指南 / 21 - 测试

测试

Jest + TypeScript 配置

安装

npm install -D jest @jest/globals ts-jest @types/jest

配置

// jest.config.ts
import type { Config } from "jest";

const config: Config = {
  preset: "ts-jest",
  testEnvironment: "node",
  roots: ["<rootDir>/src"],
  testMatch: ["**/__tests__/**/*.ts", "**/*.test.ts", "**/*.spec.ts"],
  moduleNameMapper: {
    "^@/(.*)$": "<rootDir>/src/$1"
  },
  collectCoverageFrom: [
    "src/**/*.ts",
    "!src/**/*.d.ts",
    "!src/**/index.ts"
  ]
};

export default config;

package.json

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

基本测试

// math.ts
export function add(a: number, b: number): number {
  return a + b;
}

export function multiply(a: number, b: number): number {
  return a * b;
}

export function divide(a: number, b: number): number {
  if (b === 0) {
    throw new Error("Division by zero");
  }
  return a / b;
}
// math.test.ts
import { describe, it, expect } from "@jest/globals";
import { add, multiply, divide } from "./math";

describe("math", () => {
  describe("add", () => {
    it("should add two positive numbers", () => {
      expect(add(1, 2)).toBe(3);
    });

    it("should handle negative numbers", () => {
      expect(add(-1, -2)).toBe(-3);
      expect(add(-1, 2)).toBe(1);
    });
  });

  describe("multiply", () => {
    it("should multiply two numbers", () => {
      expect(multiply(2, 3)).toBe(6);
    });
  });

  describe("divide", () => {
    it("should divide two numbers", () => {
      expect(divide(6, 2)).toBe(3);
    });

    it("should throw on division by zero", () => {
      expect(() => divide(1, 0)).toThrow("Division by zero");
    });
  });
});

测试异步代码

// async.test.ts
import { describe, it, expect } from "@jest/globals";

// Promise
describe("async", () => {
  it("should resolve", async () => {
    const result = await Promise.resolve("hello");
    expect(result).toBe("hello");
  });

  it("should reject", async () => {
    await expect(Promise.reject(new Error("fail"))).rejects.toThrow("fail");
  });
});

// API 调用
describe("fetchUser", () => {
  it("should fetch user", async () => {
    const user = await fetchUser(1);
    expect(user).toEqual({
      id: 1,
      name: expect.any(String)
    });
  });
});

Mock 类型

函数 Mock

import { jest, describe, it, expect } from "@jest/globals";

// 创建 mock 函数
const mockFn = jest.fn();

// 类型化的 mock
const mockAdd = jest.fn<(a: number, b: number) => number>();

// mock 实现
const mockMultiply = jest.fn<(a: number, b: number) => number>()
  .mockImplementation((a, b) => a * b);

// mock 返回值
const mockGetUser = jest.fn<() => Promise<User>>()
  .mockResolvedValue({ id: 1, name: "Alice", email: "a@b.com" });

describe("mock functions", () => {
  it("should call mock", () => {
    mockFn("arg1", "arg2");
    expect(mockFn).toHaveBeenCalledWith("arg1", "arg2");
    expect(mockFn).toHaveBeenCalledTimes(1);
  });

  it("should mock implementation", () => {
    expect(mockMultiply(2, 3)).toBe(6);
  });
});

模块 Mock

// services/user.ts
export async function getUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

// __mocks__/services/user.ts
import { jest } from "@jest/globals";

export const getUser = jest.fn<(id: number) => Promise<User>>()
  .mockResolvedValue({ id: 1, name: "Alice", email: "a@b.com" });

// 测试文件中
jest.mock("./services/user");
import { getUser } from "./services/user";

describe("with mocked user service", () => {
  it("should use mocked getUser", async () => {
    const user = await getUser(1);
    expect(user.name).toBe("Alice");
  });
});

Mock 的类型安全

// 定义 Mock 类型
type MockFn<T extends (...args: any[]) => any> = jest.MockedFunction<T>;

// 使用
interface UserService {
  getUser(id: number): Promise<User>;
  updateUser(id: number, data: Partial<User>): Promise<User>;
}

const mockUserService: jest.Mocked<UserService> = {
  getUser: jest.fn(),
  updateUser: jest.fn()
};

// 或者使用 jest.createMockFromModule
const mockModule = jest.createMockFromModule<typeof import("./services")>("./services");

测试工具类型

提取函数返回类型

// 测试辅助类型
type AsyncReturnType<T extends (...args: any) => Promise<any>> =
  T extends (...args: any) => Promise<infer R> ? R : never;

// 使用
async function fetchUser(id: number): Promise<User> {
  return { id, name: "Alice", email: "a@b.com" };
}

type FetchUserReturn = AsyncReturnType<typeof fetchUser>; // User

// 在测试中使用
it("should return user", async () => {
  const user = await fetchUser(1);
  // user 的类型是 User,IDE 会提供完整的自动补全
  expect(user.name).toBe("Alice");
});

部分 Mock 类型

// DeepPartial 用于创建部分 mock
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

// 创建测试数据工厂
function createMockUser(overrides?: DeepPartial<User>): User {
  return {
    id: 1,
    name: "Alice",
    email: "alice@example.com",
    ...overrides
  };
}

// 使用
const user = createMockUser({ name: "Bob" });
const adminUser = createMockUser({ role: "admin" });

高级测试模式

测试类

// UserService.ts
export class UserService {
  constructor(private db: Database) {}

  async getUser(id: number): Promise<User | null> {
    return this.db.users.findById(id);
  }

  async createUser(data: CreateUserDto): Promise<User> {
    return this.db.users.create(data);
  }
}

// UserService.test.ts
import { jest, describe, it, expect, beforeEach } from "@jest/globals";

describe("UserService", () => {
  let service: UserService;
  let mockDb: jest.Mocked<Database>;

  beforeEach(() => {
    mockDb = {
      users: {
        findById: jest.fn(),
        create: jest.fn()
      }
    } as any;

    service = new UserService(mockDb);
  });

  it("should get user", async () => {
    const mockUser = { id: 1, name: "Alice" };
    mockDb.users.findById.mockResolvedValue(mockUser);

    const user = await service.getUser(1);
    expect(user).toEqual(mockUser);
    expect(mockDb.users.findById).toHaveBeenCalledWith(1);
  });

  it("should return null for non-existent user", async () => {
    mockDb.users.findById.mockResolvedValue(null);

    const user = await service.getUser(999);
    expect(user).toBeNull();
  });
});

测试 React 组件

npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
// Button.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { jest, describe, it, expect } from "@jest/globals";
import { Button } from "./Button";

describe("Button", () => {
  it("should render with text", () => {
    render(<Button variant="primary">Click me</Button>);
    expect(screen.getByText("Click me")).toBeInTheDocument();
  });

  it("should call onClick", () => {
    const handleClick = jest.fn();
    render(
      <Button variant="primary" onClick={handleClick}>
        Click me
      </Button>
    );

    fireEvent.click(screen.getByText("Click me"));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it("should be disabled", () => {
    render(<Button variant="primary" disabled>Click me</Button>);
    expect(screen.getByText("Click me")).toBeDisabled();
  });
});

快照测试

// 组件快照
it("should match snapshot", () => {
  const { container } = render(
    <UserCard user={{ id: 1, name: "Alice", email: "a@b.com" }} />
  );
  expect(container).toMatchSnapshot();
});

// 内联快照
it("should render error message", () => {
  render(<ErrorMessage code={404} />);
  expect(screen.getByText("页面未找到")).toMatchInlineSnapshot(`
    <span class="error-message">
      页面未找到
    </span>
  `);
});

测试覆盖率

// jest.config.ts
{
  "collectCoverageFrom": [
    "src/**/*.ts",
    "src/**/*.tsx",
    "!src/**/*.d.ts",
    "!src/**/index.ts",
    "!src/**/*.stories.ts"
  ],
  "coverageThreshold": {
    "global": {
      "branches": 80,
      "functions": 80,
      "lines": 80,
      "statements": 80
    }
  }
}

业务场景:测试 API 端点

// __tests__/api/users.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "@jest/globals";
import request from "supertest";
import { app } from "../../src/app";
import { prisma } from "../../src/db";

describe("Users API", () => {
  beforeAll(async () => {
    await prisma.$connect();
  });

  afterAll(async () => {
    await prisma.$disconnect();
  });

  beforeEach(async () => {
    await prisma.user.deleteMany();
  });

  describe("GET /api/users", () => {
    it("should return empty array when no users", async () => {
      const response = await request(app)
        .get("/api/users")
        .expect(200);

      expect(response.body).toEqual([]);
    });

    it("should return all users", async () => {
      await prisma.user.create({
        data: { name: "Alice", email: "alice@example.com" }
      });

      const response = await request(app)
        .get("/api/users")
        .expect(200);

      expect(response.body).toHaveLength(1);
      expect(response.body[0].name).toBe("Alice");
    });
  });

  describe("POST /api/users", () => {
    it("should create a user", async () => {
      const response = await request(app)
        .post("/api/users")
        .send({ name: "Alice", email: "alice@example.com" })
        .expect(201);

      expect(response.body).toMatchObject({
        id: expect.any(Number),
        name: "Alice",
        email: "alice@example.com"
      });
    });

    it("should return 400 for invalid data", async () => {
      await request(app)
        .post("/api/users")
        .send({ name: "" })
        .expect(400);
    });
  });
});

注意事项

  1. 类型安全的 Mock——使用 jest.Mocked<T> 确保 Mock 与原函数类型一致
  2. 异步测试——使用 async/await 测试异步代码
  3. 清理测试状态——使用 beforeEachafterEach 清理测试数据
  4. 测试覆盖率——设置合理的覆盖率阈值
  5. 快照测试——适合测试 UI 组件的输出,但不要过度使用

扩展阅读