Node

Node.js 测试实战:Jest、Vitest 与 Playwright 构建分层测试体系

✎ -- 字 🕐 -- 分钟
字号

Node.js 测试实战:Jest、Vitest 与 Playwright 构建分层测试体系

Node.js 测试实战封面

测试不是开发的负担,而是持续交付的保险绳。很多团队把测试留到最后一天,结果线上频出回归 Bug;也有些团队一股脑写了几百个测试,但维护成本极高,重构时反而拖慢节奏。问题不在于测试本身,而在于没有分层。本文从单元测试到 E2E,用 Jest、Vitest 和 Playwright 搭建一套可落地的 Node.js 分层测试体系,帮你找到速度与覆盖率的平衡点。

一、为什么测试必须分层?

测试分层本质上是在模拟真实世界的故障成本。单元测试验证函数逻辑,毫秒级反馈;集成测试验证模块协作,秒级反馈;E2E 测试验证用户路径,分钟级反馈。如果所有测试都走浏览器,CI 跑半小时,开发者会本能地跳过;如果只有单元测试,接口契约变了也发现不了。

经典的测试金字塔告诉我们:

  • 底层(70%):单元测试,成本低、速度快、定位准;
  • 中层(20%):集成/接口测试,验证数据库、HTTP、消息队列等外部依赖;
  • 顶层(10%):E2E 测试,模拟真实用户操作,成本最高但最贴近业务。

Node.js 分层测试金字塔

记住一个原则: bug 越容易被用户发现,测试就应该越靠近顶层;bug 越隐蔽,就越应该被单元测试拦截。

二、Jest:单元测试的瑞士军刀

2.1 快速上手

Jest 是 Node.js 生态里事实标准的测试框架,内置断言、Mock、覆盖率、快照,几乎开箱即用。

# 安装
npm install --save-dev jest

# package.json
{
  "scripts": {
    "test": "jest",
    "test:cov": "jest --coverage"
  }
}

写一个最简单的单元测试:

// src/cart.js
function calcDiscount(price, level) {
  if (price < 0) throw new Error('price must be positive');
  if (level === 'vip') return price * 0.8;
  if (level === 'svip') return price * 0.7;
  return price;
}

module.exports = { calcDiscount };

// __tests__/cart.test.js
const { calcDiscount } = require('../src/cart');

describe('calcDiscount', () => {
  test('普通会员无折扣', () => {
    expect(calcDiscount(100, 'normal')).toBe(100);
  });

  test('VIP 八折', () => {
    expect(calcDiscount(100, 'vip')).toBe(80);
  });

  test('SVIP 七折', () => {
    expect(calcDiscount(100, 'svip')).toBe(70);
  });

  test('负数价格抛异常', () => {
    expect(() => calcDiscount(-1, 'vip')).toThrow('price must be positive');
  });
});

2.2 Mock 外部依赖

单元测试的关键是隔离。对于 HTTP、数据库、第三方 SDK,统一用 Mock 替代。

// src/pay.js
const axios = require('axios');

async function createOrder(amount) {
  const res = await axios.post('https://api.pay.com/order', { amount });
  return res.data.orderId;
}

module.exports = { createOrder };

// __tests__/pay.test.js
jest.mock('axios');
const axios = require('axios');
const { createOrder } = require('../src/pay');

test('创建订单返回 orderId', async () => {
  axios.post.mockResolvedValue({ data: { orderId: 'ORD-20260811' } });
  const id = await createOrder(99);
  expect(id).toBe('ORD-20260811');
  expect(axios.post).toHaveBeenCalledWith(
    'https://api.pay.com/order',
    { amount: 99 }
  );
});

2.3 快照测试的正确用法

快照适合输出结构稳定的场景,比如 CLI 输出、复杂对象、React 组件渲染结果。但不要拿快照测业务逻辑,否则每次改字段都要更新快照,反而增加噪音。

test('生成的配置对象结构稳定', () => {
  const config = buildConfig({ env: 'prod', port: 3000 });
  expect(config).toMatchSnapshot();
});

三、Vitest:比 Jest 更快的现代化选择

3.1 为什么要换 Vitest?

Vitest 由 Vite 团队打造,原生支持 ESM,冷启动和热更新速度明显优于 Jest。如果你的项目已经在用 Vite,或者想拥抱 ESM,Vitest 几乎是最佳选择。

维度JestVitest
配置方式jest.config.jsvite.config.js 或 vitest.config.js
ESM 支持需额外配置原生支持
启动速度较慢(需转译)快(基于 Vite)
Mock 语法jest.mockvi.mock
IDE 支持成熟极佳(VS Code 插件)
TypeScript需 ts-jest原生支持

3.2 迁移成本几乎为零

Vitest 的 API 设计几乎照搬 Jest,把 jest 换成 vi 就能跑。

// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    coverage: {
      reporter: ['text', 'html'],
      exclude: ['node_modules/', '__tests__/']
    }
  }
});
// __tests__/cart.vitest.js
import { describe, it, expect, vi } from 'vitest';
import { calcDiscount } from '../src/cart.js';

describe('calcDiscount', () => {
  it('VIP 八折', () => {
    expect(calcDiscount(100, 'vip')).toBe(80);
  });
});

3.3 实战技巧:in-source 测试

Vitest 支持把测试写在源码旁边,方便阅读和维护:

// src/utils.js
export function sum(a, b) {
  return a + b;
}

// 源码内测试,仅开发模式生效
if (import.meta.vitest) {
  const { it, expect } = import.meta.vitest;
  it('sum', () => {
    expect(sum(2, 3)).toBe(5);
  });
}

四、集成测试:supertest 验证接口契约

4.1 为什么单元测试不够?

单元测试能告诉你函数对不对,但没法告诉你路由有没有挂、中间件顺序有没有错、数据库连接池有没有泄漏。集成测试就是把这些串起来跑一遍。

4.2 Express + supertest 最小实战

// src/app.js
const express = require('express');
const app = express();
app.use(express.json());

const users = [];

app.post('/users', (req, res) => {
  const { name } = req.body;
  if (!name) return res.status(400).json({ error: 'name required' });
  const user = { id: users.length + 1, name };
  users.push(user);
  res.status(201).json(user);
});

app.get('/users/:id', (req, res) => {
  const user = users.find(u => u.id === parseInt(req.params.id));
  if (!user) return res.status(404).json({ error: 'not found' });
  res.json(user);
});

module.exports = app;
// __tests__/app.test.js
const request = require('supertest');
const app = require('../src/app');

describe('User API', () => {
  test('POST /users 创建用户', async () => {
    const res = await request(app)
      .post('/users')
      .send({ name: 'Alice' })
      .expect(201);
    expect(res.body.name).toBe('Alice');
  });

  test('POST /users 缺少 name 返回 400', async () => {
    await request(app).post('/users').send({}).expect(400);
  });

  test('GET /users/:id 查询用户', async () => {
    const create = await request(app).post('/users').send({ name: 'Bob' });
    const res = await request(app).get(`/users/${create.body.id}`).expect(200);
    expect(res.body.name).toBe('Bob');
  });
});

4.3 数据库怎么测?

不要直接连生产库。推荐三种方案:

  1. SQLite 内存库:Prisma/Sequelize 可快速切换,适合 CRUD 测试;
  2. Testcontainers:启动真实 MySQL/Postgres 容器,适合复杂 SQL 和事务;
  3. 每层事务回滚:测试前后 rollback,保持数据干净。
// 用 Testcontainers 启动 Postgres
const { PostgreSqlContainer } = require('@testcontainers/postgresql');

let container;
beforeAll(async () => {
  container = await new PostgreSqlContainer().start();
  process.env.DATABASE_URL = container.getConnectionUri();
});

afterAll(async () => {
  await container.stop();
});

五、Playwright:E2E 测试的终极方案

5.1 为什么选 Playwright?

相比 Selenium,Playwright 的自动等待、Trace Viewer、多浏览器并行、Codegen 录制都更现代化。它的断言会智能重试,极大减少 flaky test。

# 安装
npm init playwright@latest

# 自动生成配置文件和示例测试
npx playwright test

5.2 一个完整的登录流程测试

// e2e/login.spec.js
const { test, expect } = require('@playwright/test');

test('用户登录并跳转仪表盘', async ({ page }) => {
  await page.goto('https://app.example.com/login');

  await page.fill('[data-testid="email"]', 'user@example.com');
  await page.fill('[data-testid="password"]', 'secret123');
  await page.click('[data-testid="submit"]');

  await expect(page).toHaveURL(/\/dashboard/);
  await expect(page.locator('h1')).toContainText('仪表盘');
});

5.3 用 codegen 自动生成测试

手动写选择器很痛苦。Playwright 提供了录制工具:

npx playwright codegen https://app.example.com

录制完成后会得到一段可运行的代码,再手动加上 data-testid 等稳定选择器,就是一份可靠的 E2E 用例。

5.4 并行与 trace

// playwright.config.js
module.exports = {
  workers: 4,
  retries: 1,
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure'
  }
};

开启 trace 后,失败用例会生成可交互的 trace.zip,用 npx playwright show-trace trace.zip 可以看到每一步的 DOM、网络请求和控制台输出,排查效率比截图高一个数量级。

六、CI/CD 里的测试流水线

测试写在本地只是第一步,真正发挥价值是在 CI 里强制执行。下面是一份 GitHub Actions 配置:

# .github/workflows/test.yml
name: Test
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
      - run: npm run lint
      - run: npm run test:unit -- --coverage
      - run: npm run test:integration
      - run: npm run test:e2e

为了让 E2E 更稳定,建议:

  • 用 Playwright 的 webServer 选项自动启动本地服务;
  • E2E 放在单独 job,失败时不阻塞单元/集成测试的反馈;
  • 对覆盖率设置门禁,例如单元测试行覆盖率不低于 80%。

七、常见陷阱与避坑指南

陷阱表现解法
测试依赖真实网络CI 随机失败统一 Mock HTTP / 用 Testcontainers
数据库未隔离测试互相污染每个测试独立事务 / 内存数据库
选择器过于脆弱UI 一改 E2E 全挂优先使用 data-testid
测试名描述不清失败后看不出业务含义用 "should...when..." 命名
过度 Mock测试通过但线上挂关键路径保留集成测试
忽略测试性能CI 越跑越慢分层 + 并行 + 只跑改动相关测试

八、Node.js 异步测试的四种模式

Node.js 到处都是回调、Promise 和 async/await,测试异步代码是新手最容易踩坑的地方。下面四种模式覆盖了绝大多数场景。

8.1 Promise + async/await

最推荐的方式,可读性最好:

test('异步获取用户', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('Alice');
});

8.2 resolves / rejects 断言

test('Promise 成功', () => {
  return expect(fetchUser(1)).resolves.toMatchObject({ id: 1 });
});

test('Promise 失败', () => {
  return expect(fetchUser(999)).rejects.toThrow('not found');
});

8.3 done 回调

处理事件驱动或流式 API 时仍然有用:

test('事件触发', (done) => {
  const emitter = new EventEmitter();
  emitter.on('done', (data) => {
    expect(data).toBe('ok');
    done();
  });
  emitter.emit('done', 'ok');
});

8.4 定时器控制

测试含 setTimeout/setInterval 的代码时,用 Jest 的 fake timers:

test('定时任务', () => {
  jest.useFakeTimers();
  const fn = jest.fn();
  setTimeout(fn, 1000);

  jest.advanceTimersByTime(1000);
  expect(fn).toHaveBeenCalled();

  jest.useRealTimers();
});

九、测试数据管理:从硬编码到 Factory

测试代码里最恶心的就是一堆硬编码对象。业务字段一改,几十处测试要跟着改。用 factory 函数生成数据是更优雅的做法。

// tests/factories/user.js
function createUser(overrides = {}) {
  return {
    id: 1,
    name: 'Alice',
    email: 'alice@example.com',
    role: 'user',
    createdAt: new Date().toISOString(),
    ...overrides
  };
}

module.exports = { createUser };
// 使用 factory
const { createUser } = require('./factories/user');

test('管理员可删除普通用户', () => {
  const admin = createUser({ id: 1, role: 'admin' });
  const target = createUser({ id: 2, role: 'user' });
  expect(canDelete(admin, target)).toBe(true);
});

test('普通用户不能删除管理员', () => {
  const user = createUser({ id: 3, role: 'user' });
  const admin = createUser({ id: 4, role: 'admin' });
  expect(canDelete(user, admin)).toBe(false);
});

对于复杂关系数据,可以引入 @faker-js/faker

import { faker } from '@faker-js/faker';

function createRandomUser() {
  return {
    id: faker.number.int(),
    name: faker.person.fullName(),
    email: faker.internet.email(),
    avatar: faker.image.avatar()
  };
}

十、TDD 实战:红绿重构循环

测试驱动开发不是神话,它是一种让设计更清晰的节奏。以"密码强度校验"为例:

第一步:写失败的测试(红)

test('密码必须至少 8 位', () => {
  expect(isStrongPassword('123')).toBe(false);
});

第二步:写最小实现(绿)

function isStrongPassword(pwd) {
  return pwd.length >= 8;
}

第三步:补充更多测试并重构

test('强密码需包含大小写、数字和特殊字符', () => {
  expect(isStrongPassword('Hello1!')).toBe(false);   // 只有 7 位
  expect(isStrongPassword('Hello1!a')).toBe(true);   // 满足全部条件
  expect(isStrongPassword('hello1!a')).toBe(false);  // 缺少大写
  expect(isStrongPassword('HELLO1!A')).toBe(false);  // 缺少小写
});

TDD 的真正价值不在于"先写测试",而在于用小步快跑的方式暴露设计缺陷。如果你发现某个功能很难测试,往往说明模块耦合过高,需要重构。

十一、覆盖率不是终点

很多团队把"行覆盖率 80%"当成 KPI,结果出现大量无意义断言。覆盖率只能告诉你哪些代码被执行过,不能告诉你测试是否有效。

更合理的指标组合:

指标意义健康阈值
行覆盖率代码被执行的比例> 70%
分支覆盖率条件分支是否都覆盖> 60%
变异测试得分测试能否发现人为注入的 Bug> 50%
测试失败率CI 中 flaky test 的比例< 1%
测试执行时间反馈速度< 5 分钟

推荐使用 stryker-js 做变异测试,它会自动修改你的源码并检查测试是否失败,能真实反映测试的"杀伤力"。

十二、最佳实践 Checklist

  1. 每个公共函数至少覆盖正常路径、边界条件和异常路径;
  2. Mock 只替代外部依赖,不要 Mock 被测函数自己的逻辑;
  3. 测试数据用 factory 函数生成,不要硬编码大量对象;
  4. 集成测试优先验证状态码、契约字段和副作用(数据库、消息);
  5. E2E 只覆盖核心用户旅程,不要事无巨细;
  6. 失败测试先本地复现,再提交修复,不要直接跳过;
  7. 定期清理无用测试,防止测试代码腐化;
  8. 把测试速度作为工程指标,CI 超过 10 分钟必须优化;
  9. 避免测试私有方法,测试的是行为而不是实现细节;
  10. 断言要有业务含义,不要只检查 "not null"。

十三、Flaky Test 的识别与治理

Flaky test(不稳定测试)是测试体系最大的敌人。它时而过时而过,开发者会逐渐对测试失去信心,最后把失败当成"正常噪音"。

13.1 常见诱因

  • 时间依赖:用真实 sleep 等待动画或请求完成;
  • 顺序依赖:测试之间共享状态,A 测试失败导致 B 测试也失败;
  • 外部依赖:数据库、Redis、第三方 API 响应不稳定;
  • 随机数据:用随机值生成测试数据,偶尔触发边界条件。

13.2 Playwright 的自动等待机制

Playwright 内置了智能等待,不要手动 sleep:

// 推荐:Playwright 会自动轮询直到条件满足
await page.click('[data-testid="submit"]');
await expect(page.locator('.toast')).toContainText('保存成功');

// 不推荐:硬编码等待
await page.waitForTimeout(2000);

13.3 重跑策略

完全消除 flaky 很难,可以先通过重跑隔离偶发问题:

// jest.config.js
module.exports = {
  retry: 2,  // 失败时重试 2 次
  testRunner: 'jest-circus/runner'
};

但重跑只是止痛药,长期还是要回到根因治理。建议每周统计 flaky test 榜单,TOP 3 必须限期修复。

十四、测试与重构的正向循环

很多人害怕重构,是因为没有测试兜底。反过来,如果没有重构,测试代码也会越来越臃肿。测试与重构应该形成正向循环:

  1. 先补测试:在改动旧代码前,先写测试把当前行为"固化";
  2. 小步重构:每次只改一个函数或一个类,跑测试确认没破坏行为;
  3. 删除死代码:覆盖率报告会暴露没人调用的代码,放心删除;
  4. 提升抽象:重复出现的测试准备代码,抽取成 helper 或 fixture。

一个实用的重构信号是:当你发现写一个新测试要复制粘贴大量代码时,说明被测代码或测试辅助工具需要抽象了。

十五、从 0 到 1 的落地路线图

如果你的项目现在几乎没有测试,不要试图一次性补完。按下面四步走,阻力最小:

阶段目标周期产出
第一阶段为核心工具函数补单元测试1 周覆盖率 30%
第二阶段为关键 API 加集成测试2 周登录/支付/订单主流程覆盖
第三阶段接入 CI 强制门禁1 周PR 合并前必须全绿
第四阶段补齐 E2E 和监控告警持续核心用户旅程自动化

记住:有测试总比没有好,跑起来的测试才有价值。不要陷入"等技术债还清再写测试"的拖延陷阱。

十六、结语

一套好的测试体系不是越多越好,而是每层都各司其职。Jest 和 Vitest 守住单元测试的底线,supertest 验证接口契约,Playwright 把住用户入口。把它们串进 CI,配合清晰的命名和稳定的依赖隔离,测试才能真正从"开发负担"变成"重构底气"。如果你还在为线上回归 Bug 头疼,不妨从今天开始,给你的 Node.js 项目补上一张分层测试网。