后端测试

写出值得信赖的代码

🎯 学习目标

学完本章,你将能够:

  • 使用 Vitest 编写单元测试和参数化测试
  • 使用 Supertest 对 API 接口进行集成测试
  • 理解数据库测试的四种策略,能隔离测试环境
  • 对 Prisma、外部服务和时间进行 Mock
  • 编写覆盖用户完整流程的 E2E 测试

你有没有想过

改了一行代码,上线后发现另外三个功能坏了——这种事怎么避免?

答案就是自动化测试。测试不是"锦上添花",而是项目能持续迭代的"安全网"。没有测试的代码,每次修改都像走钢丝。

本章将带你从零搭建 Node.js 后端的测试体系:单元测试、集成测试、E2E 测试,一个不落。

测试金字塔

底层多、顶层少——速度和覆盖的平衡

图:测试金字塔——单元测试、集成测试、E2E 测试的层次与比例

原则:单元测试量大跑得快,E2E 测试少量但覆盖关键流程。金字塔底部越扎实,你对代码越有信心。

Part 1: 单元测试

最小粒度的验证——一个函数一个函数地测

单元测试是测试金字塔的基石,它验证的是单个函数或方法的行为是否正确。粒度小意味着运行速度快、定位问题精准——一个测试红了,你就知道是哪个函数出了问题。

本部分将从框架选型开始,带你掌握 AAA 编写模式、参数化测试、中间件隔离测试、文件上传测试以及 WebSocket 事件测试等核心技能。

选择测试框架

Jest 老牌稳定,Vitest 后起之秀

特性JestVitest
执行速度中等快(原生 ESM)
TS 支持需 ts-jest原生支持
API 兼容Jest 原生Jest 兼容
覆盖率Istanbulc8 / V8

本教程选择 Vitest——与 Vite 生态无缝集成,API 与 Jest 兼容,迁移成本几乎为零。

安装与配置

一行命令装好,几行配置搞定

$ npm install -D vitest supertest @types/supertest
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,       // 使用全局 API
    environment: 'node', // Node.js 环境
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.ts'],
    },
    setupFiles: ['./tests/setup.ts'],
  },
});

全局 setup

测试前准备环境,测试后清理数据

// tests/setup.ts
import { beforeAll, afterAll, afterEach } from 'vitest';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

beforeAll(async () => {
  await prisma.$connect();     // 连接数据库
});

afterEach(async () => {
  // 按依赖顺序清理表数据
  const tables = ['Message', 'Post', 'User'];
  for (const table of tables) {
    await (prisma as any)[table.toLowerCase()].deleteMany();
  }
});

afterAll(async () => {
  await prisma.$disconnect();  // 关闭连接
});

export { prisma };

测试工具函数

被测代码:密码工具

// src/utils/password.util.ts
import bcrypt from 'bcryptjs';

const SALT_ROUNDS = 10;

export async function hashPassword(password: string): Promise<string> {
  if (!password || password.length < 6) {
    throw new Error('密码长度不能少于6位');
  }
  return bcrypt.hash(password, SALT_ROUNDS);
}

export async function verifyPassword(
  password: string, hash: string
): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

export function validateEmail(email: string): boolean {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(email);
}

编写单元测试

AAA 模式:准备、执行、断言

// tests/unit/password.util.test.ts
import { describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword, validateEmail } from '../../src/utils/password.util';

describe('hashPassword', () => {
  it('应该成功哈希有效密码', async () => {
    const hash = await hashPassword('MyPassword123');
    expect(hash).toBeDefined();
    expect(hash).not.toBe('MyPassword123');
    expect(hash.startsWith('$2a$')).toBe(true);
  });

  it('应该在密码太短时抛出错误', async () => {
    await expect(hashPassword('12345'))
      .rejects.toThrow('密码长度不能少于6位');
  });

  it('每次哈希结果应该不同', async () => {
    const hash1 = await hashPassword('samePassword');
    const hash2 = await hashPassword('samePassword');
    expect(hash1).not.toBe(hash2); // 盐值不同
  });
});

参数化测试

用 it.each 一批测多组数据

describe('validateEmail', () => {
  it.each([
    ['user@example.com', true],
    ['test.name@domain.co', true],
    ['invalid', false],
    ['@missing.com', false],
    ['missing@.com', false],
    ['', false],
  ])('validateEmail("%s") 应该返回 %s', (email, expected) => {
    expect(validateEmail(email)).toBe(expected);
  });
});

it.each 让你用一个测试用例覆盖多组输入输出,避免重复代码。每组数据独立运行,互不影响。

中间件测试

隔离测试 Express 中间件,Mock req/res/next

Express 中间件本质上是接收 (req, res, next) 三个参数的函数。测试中间件的关键在于构造假的请求和响应对象,然后观察中间件是否调用了正确的函数。

// tests/unit/middleware/requireAuth.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { requireAuth } from '../../../src/middleware/requireAuth';

function createMockRes() {
  return {
    status: vi.fn().mockReturnThis(),
    json: vi.fn().mockReturnThis(),
  } as any;
}

describe('requireAuth 中间件', () => {
  it('无 token 时应返回 401', () => {
    const req = { headers: {} } as any;
    const res = createMockRes();
    const next = vi.fn();

    requireAuth(req, res, next);

    expect(res.status).toHaveBeenCalledWith(401);
    expect(res.json).toHaveBeenCalledWith({ error: '未提供认证令牌' });
    expect(next).not.toHaveBeenCalled();
  });

  it('有效 token 时应调用 next 并注入 user', () => {
    const req = {
      headers: { authorization: 'Bearer valid-token' },
    } as any;
    const res = createMockRes();
    const next = vi.fn();

    requireAuth(req, res, next);

    expect(next).toHaveBeenCalled();
    expect(req.user).toBeDefined();
  });
});

技巧:把 createMockRes 提取为公共工具函数,在多个中间件测试中复用。也可以用 vi.fn() 监听 next 是否被调用、被调用时的参数等。

文件上传测试

用 Supertest 测试 multipart/form-data

Supertest 提供了 .attach() 和 .field() 方法来模拟文件上传。测试时重点关注文件类型校验、大小限制、上传成功后的响应。

// tests/integration/upload.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';
import path from 'path';
import { generateAccessToken } from '../../src/utils/jwt';

describe('POST /api/upload', () => {
  let token: string;

  beforeAll(async () => {
    // 获取认证 token...
    token = 'test-token';
  });

  it('应该成功上传图片', async () => {
    const res = await request(app)
      .post('/api/upload')
      .set('Authorization', `Bearer ${token}`)
      .attach('file', path.join(__dirname, '../fixtures/test-image.png'))
      .field('category', 'avatar');

    expect(res.status).toBe(201);
    expect(res.body).toHaveProperty('url');
    expect(res.body.category).toBe('avatar');
  });

  it('文件超过大小限制时应返回 413', async () => {
    const res = await request(app)
      .post('/api/upload')
      .set('Authorization', `Bearer ${token}`)
      .attach('file', path.join(__dirname, '../fixtures/large-file.pdf'));

    expect(res.status).toBe(413);
    expect(res.body.error).toContain('文件大小');
  });

  it('不支持的文件类型应返回 400', async () => {
    const res = await request(app)
      .post('/api/upload')
      .set('Authorization', `Bearer ${token}`)
      .attach('file', path.join(__dirname, '../fixtures/malware.exe'));

    expect(res.status).toBe(400);
    expect(res.body.error).toContain('不支持的文件类型');
  });
});
测试前准备:在 tests/fixtures/ 目录下放好各种测试文件(正常图片、超大文件、非法类型文件),确保测试可重复运行。

WebSocket / Socket.io 测试

测试实时通信的连接、事件和广播

测试 Socket.io 的关键是创建真实的服务器实例,然后用 socket.io-client 连接并监听/发送事件。注意测试结束后要关闭服务器和客户端连接。

// tests/unit/socket.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer } from 'http';
import { Server } from 'socket.io';
import { io as Client } from 'socket.io-client';
import { setupSocketHandlers } from '../../src/socket/handlers';

describe('Socket.io 事件测试', () => {
  let io: Server;
  let serverSocket: any;
  let clientSocket: any;
  let httpServer: any;

  beforeAll((done) => {
    httpServer = createServer();
    io = new Server(httpServer);
    setupSocketHandlers(io);
    httpServer.listen(() => {
      const port = (httpServer.address() as any).port;
      clientSocket = Client(`http://localhost:${port}`);
      io.on('connection', (socket) => { serverSocket = socket; });
      clientSocket.on('connect', done);
    });
  });

  afterAll(() => {
    io.close();
    clientSocket.close();
    httpServer.close();
  });

  it('应该接收广播消息', (done) => {
    clientSocket.on('message', (msg: string) => {
      expect(msg).toBe('Hello everyone!');
      done();
    });
    serverSocket.emit('message', 'Hello everyone!');
  });

  it('应该处理 join_room 事件', (done) => {
    clientSocket.emit('join_room', 'room-1');
    serverSocket.on('join_room', (room: string) => {
      expect(room).toBe('room-1');
      done();
    });
  });
});

测试实时通信时,用 done 回调(或返回 Promise)来等待异步事件完成。如果测试出现超时,检查是否有未关闭的连接导致进程挂起。

Part 2: 接口测试

用 Supertest 模拟 HTTP 请求

接口测试验证的是 HTTP 层的正确性——请求参数校验、响应格式、状态码、错误处理是否都符合预期。它是"不启动服务器就能测接口"的关键手段。

通过 Supertest,你可以在测试中模拟 GET/POST/PUT/DELETE 请求,覆盖正常路径和各种边界异常,确保 API 的行为在任何改动下都保持一致。

Supertest 入门

不启动服务器也能测接口

Supertest 可以模拟 HTTP 请求到 Express 应用,无需真正监听端口。它自动创建临时服务器并发送请求。

关键:确保 app.ts 导出的是 app 实例,而不是调用 app.listen() 后的 server。listen 放在单独的 server.ts 中。
// tests/integration/auth.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';

describe('POST /api/auth/register', () => {
  it('应该成功注册新用户', async () => {
    const res = await request(app)
      .post('/api/auth/register')
      .send({
        email: 'test@example.com',
        password: 'Password123!',
        name: 'Test User',
      });

    expect(res.status).toBe(201);
    expect(res.body).toHaveProperty('user');
    expect(res.body.user).not.toHaveProperty('password');
  });
});

接口测试进阶

覆盖异常场景和边界情况

describe('POST /api/auth/register', () => {
  it('应该在邮箱重复时返回 409', async () => {
    // 先注册一次
    await request(app).post('/api/auth/register').send({
      email: 'dup@example.com', password: 'Pass123!', name: 'First',
    });
    // 再用相同邮箱注册
    const res = await request(app).post('/api/auth/register').send({
      email: 'dup@example.com', password: 'Pass456!', name: 'Second',
    });
    expect(res.status).toBe(409);
  });

  // 参数化测试:多种无效输入
  it.each([
    [{ email: '', password: 'Pass123!', name: 'T' }, '邮箱不能为空'],
    [{ email: 'test@example.com', password: '123', name: 'T' }, '密码长度'],
    [{ email: 'bad-email', password: 'Pass123!', name: 'T' }, '邮箱格式'],
  ])('参数无效时返回 400', async (body, errorMsg) => {
    const res = await request(app)
      .post('/api/auth/register').send(body);
    expect(res.status).toBe(400);
    expect(res.body.error).toContain(errorMsg);
  });
});

Part 3: 数据库测试

数据隔离、可重复、不污染生产

数据库是后端的核心依赖,也是测试中最容易"翻车"的地方。如果测试之间共享数据,执行顺序一变,结果就可能不同。

本部分介绍四种数据库测试策略,教你如何用独立测试库、事务回滚、内存数据库和 Mock Prisma 来实现数据隔离,让每个测试都像在一张白纸上运行,结果可重复、可信赖。

四种策略对比

选对策略,测试又快又稳

策略优点缺点场景
独立测试库最接近生产需管理清理集成测试(推荐)
事务回滚自动清理快不能测事务快速单元测试
内存数据库零配置极快行为可能不同简单 CRUD
Mock Prisma无需真库不测真实 SQL纯逻辑测试

测试环境隔离

独立数据库 + 独立环境变量

# .env.test
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp_test"
JWT_ACCESS_SECRET="test-access-secret"
NODE_ENV="test"
// tests/setup-db.ts
import { PrismaClient } from '@prisma/client';

// 安全检查:绝不在生产环境跑测试
if (process.env.NODE_ENV !== 'test') {
  throw new Error('测试只能在 test 环境运行');
}

const prisma = new PrismaClient({
  datasources: {
    db: { url: process.env.DATABASE_URL },
  },
});

afterEach(async () => {
  const models = ['Message', 'Post', 'User'];
  for (const model of models) {
    await (prisma as any)[model.charAt(0).toLowerCase() + model.slice(1)]
      .deleteMany();
  }
});

export { prisma };
绝对不要在测试中连接生产数据库!始终使用独立的测试数据库。在 setup 文件中添加环境检查是最基本的保护。

Mock Prisma

不连数据库也能测服务层

// tests/unit/user.service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService } from '../../src/services/user.service';
import { PrismaClient } from '@prisma/client';

// Mock Prisma Client
vi.mock('@prisma/client', () => {
  const mockPrisma = {
    user: {
      create: vi.fn(),
      findUnique: vi.fn(),
    },
  };
  return { PrismaClient: vi.fn(() => mockPrisma) };
});

describe('UserService', () => {
  let service: UserService;
  let mockPrisma: any;

  beforeEach(() => {
    mockPrisma = new PrismaClient();
    service = new UserService(mockPrisma);
  });

  it('创建用户不返回密码', async () => {
    const mockUser = { id: 1, email: 't@e.com', name: 'Test' };
    mockPrisma.user.create.mockResolvedValue(mockUser);

    const result = await service.createUser('t@e.com', 'pass', 'Test');
    expect(result).not.toHaveProperty('password');
  });
});

优先使用真实数据库做集成测试。只在需要隔离外部依赖或测试边界条件时才用 Mock。过度 Mock 会让测试与实现细节耦合,一重构就全红。

Part 4: E2E 测试

从用户视角验证完整流程

E2E(端到端)测试模拟的是真实用户的操作流程:注册、登录、创建内容、修改、删除——整个链路一气呵成。它是最接近生产环境的测试方式。

虽然 E2E 测试运行较慢、维护成本较高,但它是你上线前的最后一道防线。只要 E2E 测试全绿,你就基本可以放心部署。本部分还包括外部服务 Mock、快照测试和测试调试等实用技巧。

集成测试:CRUD

覆盖增删改查 + 权限控制

// tests/integration/posts.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';
import { prisma } from '../setup-db';
import { generateAccessToken } from '../../src/utils/jwt';

describe('Posts API', () => {
  let authToken: string;
  let userId: number;

  beforeAll(async () => {
    const user = await prisma.user.create({
      data: { email: 'p@e.com', name: 'Poster',
              password: 'hash', role: 'USER' },
    });
    userId = user.id;
    authToken = generateAccessToken({
      userId: user.id, email: user.email, role: user.role,
    });
  });

  it('应该成功创建文章', async () => {
    const res = await request(app)
      .post('/api/posts')
      .set('Authorization', `Bearer ${authToken}`)
      .send({ title: '测试文章', content: '内容' });
    expect(res.status).toBe(201);
  });

  it('未认证不能创建文章', async () => {
    const res = await request(app)
      .post('/api/posts')
      .send({ title: '无认证', content: '内容' });
    expect(res.status).toBe(401);
  });
});

E2E:用户流程

注册→登录→创建→查看→更新→删除

// tests/e2e/user-flow.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';

describe('用户完整流程', () => {
  let accessToken: string;
  let postId: number;

  it('注册→登录→创建→查看→更新→删除', async () => {
    // 1. 注册
    const reg = await request(app).post('/api/auth/register')
      .send({ email: 'e2e@e.com', password: 'Pass123!', name: 'E2E' });
    expect(reg.status).toBe(201);

    // 2. 登录
    const login = await request(app).post('/api/auth/login')
      .send({ email: 'e2e@e.com', password: 'Pass123!' });
    expect(login.status).toBe(200);
    accessToken = login.body.accessToken;

    // 3. 创建文章
    const create = await request(app).post('/api/posts')
      .set('Authorization', `Bearer ${accessToken}`)
      .send({ title: '第一篇', content: 'Hello!' });
    expect(create.status).toBe(201);
    postId = create.body.post.id;

    // 4. 查看文章
    const list = await request(app).get('/api/posts');
    expect(list.status).toBe(200);
    expect(list.body.posts.length).toBeGreaterThan(0);

    // 5. 更新文章
    const update = await request(app)
      .patch(`/api/posts/${postId}`)
      .set('Authorization', `Bearer ${accessToken}`)
      .send({ title: '修改后' });
    expect(update.status).toBe(200);

    // 6. 删除文章
    const del = await request(app)
      .delete(`/api/posts/${postId}`)
      .set('Authorization', `Bearer ${accessToken}`);
    expect(del.status).toBe(200);
  });
});

外部服务 Mock

用 nock 拦截 HTTP 请求,隔离第三方依赖

当你的后端调用 AWS S3、邮件服务、OAuth 提供方等第三方 API 时,测试中绝对不能发真实请求。nock 可以拦截指定 URL 的 HTTP 请求并返回预设响应,让测试完全可控。

// tests/integration/avatar-upload.test.ts
import { describe, it, expect, afterEach } from 'vitest';
import nock from 'nock';
import { uploadToS3 } from '../../src/services/s3';

describe('uploadToS3', () => {
  afterEach(() => nock.cleanAll());

  it('成功上传时应返回文件 URL', async () => {
    nock('https://my-bucket.s3.amazonaws.com')
      .put(/\/uploads\//)
      .reply(200, { Location: 'https://my-bucket.s3.amazonaws.com/uploads/avatar.png' });

    const result = await uploadToS3(Buffer.from('fake-image'), 'avatar.png');
    expect(result.url).toContain('uploads/avatar.png');
  });

  it('S3 服务异常时应抛出错误', async () => {
    nock('https://my-bucket.s3.amazonaws.com')
      .put(/\/uploads\//)
      .reply(500);

    await expect(uploadToS3(Buffer.from('data'), 'file.png'))
      .rejects.toThrow();
  });
});

除了 nock,你还可以用 msw(Mock Service Worker)——它能在 Node 和浏览器环境中通用,API 更现代。对于定时任务相关代码,用 vi.useFakeTimers() 来冻结或快进时间。

Mock 时间与定时器

用 vi.useFakeTimers 控制时间流逝

涉及 JWT 过期检查、定时任务、延迟重试等场景时,不能真的等几小时。Vitest 提供了 vi.useFakeTimers() 让你自由控制时间。

// tests/unit/token-expiry.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { isTokenExpired } from '../../src/utils/jwt';

describe('isTokenExpired', () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());

  it('token 未过期应返回 false', () => {
    const future = Date.now() / 1000 + 3600; // 1小时后过期
    expect(isTokenExpired({ exp: future })).toBe(false);
  });

  it('token 已过期应返回 true', () => {
    const past = Date.now() / 1000 - 10; // 10秒前过期
    expect(isTokenExpired({ exp: past })).toBe(true);
  });

  it('快进时间后 token 应变为过期', () => {
    const twoHoursLater = Date.now() / 1000 + 7200;
    expect(isTokenExpired({ exp: twoHoursLater })).toBe(false);
    vi.advanceTimersByTime(3 * 60 * 60 * 1000); // 快进3小时
    expect(isTokenExpired({ exp: twoHoursLater })).toBe(true);
  });
});

快照测试

用 toMatchInlineSnapshot 锁定 API 响应结构

快照测试可以捕获 API 响应的完整结构,任何字段变更都会触发测试失败,非常适合守护 API 契约。推荐用 toMatchInlineSnapshot 把快照写在测试文件中,一目了然。

// tests/integration/api-contract.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';

describe('API 响应契约', () => {
  it('GET /api/users 响应结构不应意外变化', async () => {
    const res = await request(app).get('/api/users');
    expect(res.status).toBe(200);
    expect(res.body).toMatchInlineSnapshot(`
      {
        "page": 1,
        "total": 0,
        "users": [],
      }
    `);
  });
});
注意快照漂移:当 API 确实需要变更时,运行 vitest -u 更新快照。但每次更新都要人工确认变更是否合理,不要盲目 -u 全部通过。快照测试是"守卫"而非"文档"。

测试调试技巧

定位失败的测试,快速找到根因

# 只跑单个测试文件
$ npx vitest run tests/unit/password.util.test.ts

# 详细输出模式,显示每个测试的耗时
$ npx vitest run --reporter=verbose

# Vitest UI:可视化查看测试结果
$ npx vitest --ui
// .vscode/launch.json — VS Code 调试配置
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug Current Test",
      "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
      "args": ["run", "${relativeFile}"],
      "console": "integratedTerminal"
    }
  ]
}

调试流程:先跑单个文件确认问题范围,再用 --reporter=verbose 看详细信息,最后用 VS Code 断点调试深入排查。遇到"只有 CI 上失败"的问题,优先检查环境变量、数据库状态和时区差异。

测试脚本配置

package.json 中的常用命令

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:unit": "vitest run tests/unit",
    "test:integration": "vitest run tests/integration",
    "test:e2e": "vitest run tests/e2e",
    "test:coverage": "vitest run --coverage",
    "test:ci": "vitest run --coverage --reporter=json"
  }
}
# 日常开发:监听模式,改完代码自动跑
$ npm run test:watch

# 跑全部测试
$ npm test

# 只跑单元测试
$ npm run test:unit

# 查看覆盖率报告
$ npm run test:coverage

CI 自动化测试

GitHub Actions 配置

# .github/workflows/test.yml
name: Test
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: myapp_test
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx prisma migrate deploy
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/myapp_test
      - run: npm run test:ci
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/myapp_test
          NODE_ENV: test

常见坑与排错

测试中的 bug 通常和"状态泄漏"有关。以下是最常踩的坑。

问题一:测试有时通过有时不通过

原因:测试之间共享了状态,执行顺序不同结果不同。

修复:在 afterEach 中清理数据,确保每个测试独立运行。

问题二:Supertest 报 "EADDRINUSE"

原因:导出了调用 app.listen() 后的 server,而不是 app 本身。

修复:app.ts 导出 export const app = express(),listen 放在 server.ts。

问题三:Mock 不生效,仍然连了真数据库

原因:vi.mock 的路径与实际 import 路径不匹配。

修复:确保 mock 路径和被测文件中的 import 路径完全一致。

问题四:覆盖率明明跑了但数字是 0%

原因:include 配置的路径没有匹配到源文件。

修复:检查 coverage.include 中的 glob 模式是否正确。

动手练一练

练习一

任务:为以下函数编写单元测试。

// src/utils/validator.ts
export function validateUsername(name: string): string {
  if (!name) throw new Error('用户名不能为空');
  if (name.length < 2) throw new Error('用户名至少2个字符');
  if (name.length > 20) throw new Error('用户名不能超过20个字符');
  return name.trim();
}
// 你的测试:

参考答案

// tests/unit/validator.test.ts
import { describe, it, expect } from 'vitest';
import { validateUsername } from '../../src/utils/validator';

describe('validateUsername', () => {
  it('正常用户名应该返回 trim 后的值', () => {
    expect(validateUsername('  张三  ')).toBe('张三');
  });

  it('空字符串应该抛错', () => {
    expect(() => validateUsername('')).toThrow('用户名不能为空');
  });

  it('少于2个字符应该抛错', () => {
    expect(() => validateUsername('A')).toThrow('至少2个字符');
  });

  it.each([
    ['', '用户名不能为空'],
    ['A', '至少2个字符'],
    ['A'.repeat(21), '不能超过20个字符'],
  ])('"%s" 应该抛出含 "%s" 的错误', (input, msg) => {
    expect(() => validateUsername(input)).toThrow(msg);
  });
});

练习二

任务:用 Supertest 为一个 GET 接口编写集成测试。

// 接口:GET /api/users?page=1&limit=10
// 返回:{ users: User[], total: number, page: number }
// 要求:
// 1. 测试正常返回 200 + 数据结构
// 2. 测试分页参数
// 3. 测试未认证返回 401
// 你的测试:

参考答案

import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';

describe('GET /api/users', () => {
  let token: string;

  beforeAll(async () => {
    // 注册并登录获取 token
    await request(app).post('/api/auth/register')
      .send({ email: 'u@e.com', password: 'Pass123!', name: 'Test' });
    const res = await request(app).post('/api/auth/login')
      .send({ email: 'u@e.com', password: 'Pass123!' });
    token = res.body.accessToken;
  });

  it('返回 200 和正确数据结构', async () => {
    const res = await request(app)
      .get('/api/users')
      .set('Authorization', `Bearer ${token}`);
    expect(res.status).toBe(200);
    expect(res.body).toHaveProperty('users');
    expect(res.body).toHaveProperty('total');
  });

  it('支持分页参数', async () => {
    const res = await request(app)
      .get('/api/users?page=1&limit=5')
      .set('Authorization', `Bearer ${token}`);
    expect(res.status).toBe(200);
    expect(res.body.users.length).toBeLessThanOrEqual(5);
  });

  it('未认证返回 401', async () => {
    const res = await request(app).get('/api/users');
    expect(res.status).toBe(401);
  });
});

📝 要点总结

本章核心知识点

测试金字塔

单元测试 → 集成测试 → E2E 测试,越往上越真实但越慢越脆弱

Vitest

快速、TypeScript 原生支持,参数化测试覆盖多种输入场景

Mock 策略

Prisma Mock、外部服务 Mock、时间 Mock 让测试可控且可重复

数据库测试

测试数据库隔离 + 每次测试后清理 = 可靠的测试环境

下一章

你已经掌握了后端测试的完整体系。下一章我们学习 GraphQL——一种更灵活的 API 设计方式,让前端按需获取数据,告别过度获取和不足获取。

1 / X