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

Node.js 开发指南 / 第 17 章 · 测试

第 17 章 · 测试

17.1 测试金字塔

         /  E2E  \         ← 少量,慢,价值高
        / 集成测试 \        ← 中等数量
       /  单元测试  \       ← 大量,快,成本低
测试类型说明工具速度
单元测试测试独立函数/模块Jest, Mocha毫秒
集成测试测试模块间交互Supertest
E2E 测试测试完整流程Playwright秒-分钟

17.2 Jest

npm install --save-dev jest @types/jest
# 如使用 TypeScript
npm install --save-dev ts-jest @types/jest

配置

// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "node",
    "testMatch": ["**/__tests__/**/*.test.js", "**/*.test.js"],
    "coverageDirectory": "coverage",
    "collectCoverageFrom": ["src/**/*.js", "!src/**/*.test.js"]
  }
}

基本断言

// math.test.js
const { add, multiply, divide } = require('./math');

describe('math 模块', () => {
  describe('add', () => {
    test('两个正数相加', () => {
      expect(add(1, 2)).toBe(3);
    });

    test('负数相加', () => {
      expect(add(-1, -2)).toBe(-3);
    });
  });

  describe('multiply', () => {
    test('相乘', () => {
      expect(multiply(3, 4)).toBe(12);
    });
  });

  describe('divide', () => {
    test('正常除法', () => {
      expect(divide(10, 2)).toBe(5);
    });

    test('除以零抛出错误', () => {
      expect(() => divide(10, 0)).toThrow('除数不能为零');
    });
  });
});

常用断言

// 相等性
expect(value).toBe(42);               // 严格相等 ===
expect(value).toEqual({ a: 1 });      // 深度相等
expect(value).not.toBe(null);

// 真值
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
expect(value).toBeNaN();

// 数字
expect(value).toBeGreaterThan(0);
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThan(100);
expect(value).toBeCloseTo(3.14, 1);   // 浮点数比较

// 字符串
expect(str).toMatch(/hello/i);
expect(str).toContain('world');

// 数组和可迭代
expect(arr).toContain(3);
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining([1, 2]));

// 对象
expect(obj).toHaveProperty('name');
expect(obj).toHaveProperty('age', 30);
expect(obj).toMatchObject({ name: 'Alice' });

// 函数
expect(fn).toThrow();
expect(fn).toThrow('错误消息');
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith(arg1, arg2);
expect(mockFn).toHaveBeenCalledTimes(3);

Mock 和 Spy

// Mock 函数
const mockFn = jest.fn();
mockFn('hello');
expect(mockFn).toHaveBeenCalledWith('hello');

// 带返回值的 Mock
const mockGet = jest.fn().mockReturnValue('value');
const mockAsync = jest.fn().mockResolvedValue({ data: 'test' });

// Mock 模块
jest.mock('./database', () => ({
  findUser: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
  createUser: jest.fn().mockResolvedValue({ id: 2 }),
}));

// Spy
const spy = jest.spyOn(console, 'log');
console.log('test');
expect(spy).toHaveBeenCalledWith('test');
spy.mockRestore();

// Mock 实现
jest.mock('fs/promises', () => ({
  readFile: jest.fn(),
  writeFile: jest.fn(),
}));

const fs = require('fs/promises');
fs.readFile.mockResolvedValue('file content');

// 定时器 Mock
jest.useFakeTimers();
jest.advanceTimersByTime(1000);
jest.useRealTimers();

生命周期钩子

describe('测试套件', () => {
  beforeAll(async () => {
    // 整个套件开始前执行一次
    await setupDatabase();
  });

  afterAll(async () => {
    // 整个套件结束后执行一次
    await cleanupDatabase();
  });

  beforeEach(() => {
    // 每个测试前执行
    resetMocks();
  });

  afterEach(() => {
    // 每个测试后执行
    jest.clearAllMocks();
  });

  test('测试 1', () => { /* ... */ });
  test('测试 2', () => { /* ... */ });
});

异步测试

// 回调方式
test('异步回调', (done) => {
  fetchData((data) => {
    expect(data).toBe('result');
    done();
  });
});

// Promise 方式
test('异步 Promise', () => {
  return fetchData().then(data => {
    expect(data).toBe('result');
  });
});

// async/await(推荐)
test('异步 async/await', async () => {
  const data = await fetchData();
  expect(data).toBe('result');
});

// 测试异步错误
test('异步错误', async () => {
  await expect(fetchData('invalid')).rejects.toThrow('无效参数');
});

17.3 Supertest(集成测试)

npm install --save-dev supertest
const request = require('supertest');
const app = require('./app');

describe('用户 API', () => {
  test('GET /api/users — 获取用户列表', async () => {
    const res = await request(app)
      .get('/api/users')
      .expect(200);

    expect(res.body.data).toBeInstanceOf(Array);
    expect(res.body.pagination).toBeDefined();
  });

  test('POST /api/users — 创建用户', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'Test', email: '[email protected]' })
      .set('Content-Type', 'application/json')
      .expect(201);

    expect(res.body.data.name).toBe('Test');
    expect(res.body.data.id).toBeDefined();
  });

  test('POST /api/users — 验证失败', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: '' })
      .expect(400);

    expect(res.body.errors).toBeDefined();
  });

  test('认证接口', async () => {
    // 先登录获取 token
    const loginRes = await request(app)
      .post('/api/login')
      .send({ email: '[email protected]', password: 'password' })
      .expect(200);

    const token = loginRes.body.accessToken;

    // 使用 token 访问受保护接口
    const res = await request(app)
      .get('/api/profile')
      .set('Authorization', `Bearer ${token}`)
      .expect(200);

    expect(res.body.user.email).toBe('[email protected]');
  });
});

17.4 代码覆盖率

npm test -- --coverage

覆盖率报告:

指标说明目标
Statements语句覆盖率> 80%
Branches分支覆盖率> 75%
Functions函数覆盖率> 80%
Lines行覆盖率> 80%
// jest.config.js
module.exports = {
  coverageThreshold: {
    global: {
      branches: 75,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

17.5 内置测试运行器(Node.js 20+)

// Node.js 内置测试(无需安装 Jest)
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert');

describe('math', () => {
  it('should add numbers', () => {
    assert.strictEqual(add(1, 2), 3);
  });

  it('should throw on divide by zero', () => {
    assert.throws(() => divide(1, 0), /除数不能为零/);
  });
});
node --test test/**/*.test.js

注意事项

⚠️ 测试应该是独立的:每个测试不依赖其他测试的执行顺序或外部状态。

⚠️ 避免测试实现细节:测试行为(输入 → 输出),而非内部实现。

⚠️ Mock 最小化:只 Mock 外部依赖(数据库、API),不要 Mock 被测模块内部。

⚠️ CI 中必须运行测试:每次提交都应自动运行测试套件。

业务场景

  1. API 测试:使用 Supertest 验证所有 API 端点的行为
  2. 数据库测试:使用内存数据库(如 SQLite)进行隔离测试
  3. 回归测试:每次修复 Bug 后添加对应的测试用例
  4. TDD 开发:先写测试再写实现

扩展阅读


上一章第 16 章 · WebSocket 实时通信 下一章第 18 章 · 日志 — Winston、Pino、日志级别和结构化日志。