Testing

测试概览

了解 Nebutra 的测试策略、命令、覆盖率要求和 CI 集成。

Nebutra 采用三层测试金字塔:用于验证逻辑正确性的快速单元测试、用于 API 合约验证的集成测试,以及用于关键用户流程的 Playwright 端到端测试。

测试金字塔


       /E2E\         5%  — Playwright — 关键流程(注册、创建项目、升级)
      /------\
     /  集成  \     15%  — Vitest — API 端点、DB 操作、队列处理器
    /----------\
   /  单元测试  \   80%  — Vitest — 函数、工具函数、React 组件
  /--------------\

保持金字塔形状是有意为之的。单元测试快速且成本低——多写。端到端测试速度慢且脆弱——少写,专注于最关键的用户旅程。

测试命令

命令运行内容
pnpm test所有单元测试 + 集成测试(Vitest)
pnpm test:watchVitest 监听模式,用于开发
pnpm test:coverage带 V8 覆盖率报告的 Vitest
pnpm e2ePlaywright 端到端测试套件(需要运行中的应用)
pnpm e2e:uiPlaywright UI 模式,用于可视化调试
pnpm test:arch架构测试(导入边界验证)

覆盖率要求

指标最低要求
行覆盖率80%
函数覆盖率80%
分支覆盖率70%
语句覆盖率80%

覆盖率按包计算。低于阈值的包将导致 CI 检查失败。

在本地查看覆盖率报告:

pnpm test:coverage
# 在浏览器中打开 coverage/index.html

编写单元测试(Vitest)

测试文件与源文件放在同一目录,使用 .test.ts.spec.ts 后缀:

packages/commerce/metering/src/
  metering.ts
  metering.test.ts      ← 同目录单元测试

示例:测试纯逻辑

import { describe, it, expect } from "vitest";
import { calculateOverageFee } from "@nebutra/metering";

describe("calculateOverageFee", () => {
  it("使用量在配额内时返回零", () => {
    expect(
      calculateOverageFee({ used: 5000, limit: 10000, pricePerUnit: 0.001 })
    ).toBe(0);
  });

  it("计算超出配额的费用", () => {
    expect(
      calculateOverageFee({ used: 12000, limit: 10000, pricePerUnit: 0.001 })
    ).toBe(2);
  });

  it("处理恰好达到配额的情况(无超额)", () => {
    expect(
      calculateOverageFee({ used: 10000, limit: 10000, pricePerUnit: 0.001 })
    ).toBe(0);
  });
});

示例:测试 React 组件

import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { CreateProjectButton } from "./create-project-button";

describe("CreateProjectButton", () => {
  it("以项目名称调用 onSubmit", async () => {
    const onSubmit = vi.fn();
    render(<CreateProjectButton onSubmit={onSubmit} />);

    await userEvent.click(screen.getByRole("button", { name: /创建项目/i }));
    await userEvent.type(screen.getByRole("textbox", { name: /项目名称/i }), "我的项目");
    await userEvent.click(screen.getByRole("button", { name: /提交/i }));

    expect(onSubmit).toHaveBeenCalledWith({ name: "我的项目" });
  });
});

编写端到端测试(Playwright)

端到端测试文件位于每个应用根目录下的 e2e/ 目录中:

apps/web/
  e2e/
    auth.spec.ts          ← 注册、登录、登出
    projects.spec.ts      ← 创建、重命名、删除项目
    billing.spec.ts       ← 升级、降级、取消

示例:端到端用户流程

import { test, expect } from "@playwright/test";

test("用户可以创建项目", async ({ page }) => {
  await page.goto("/dashboard");
  await page.click('[data-testid="create-project-button"]');
  await page.fill('[name="projectName"]', "我的测试项目");
  await page.click('[type="submit"]');
  await expect(
    page.locator('[data-testid="project-card"]')
  ).toContainText("我的测试项目");
});

data-testid 规范

所有端到端测试目标元素必须使用 data-testid 属性:

// ✅ 可测试
<button type="button" data-testid="create-project-button">
  创建项目
</button>

// ❌ 脆弱 — 与可能变更的文案绑定
await page.click('text=创建项目');

Mock 策略

依赖项Mock 方式
Stripevi.mock("stripe") 配合固定响应
Clerk / Authvi.mock("@clerk/nextjs") 配合 Mock 会话
PostHogvi.mock("@nebutra/analytics") — 无操作桩
邮件vi.mock("@nebutra/email") — 捕获已发送的邮件
数据库(单元测试)通过 Prisma 测试客户端使用内存 SQLite
数据库(集成测试)真实测试数据库——绝不 Mock 数据库层
外部 HTTP API集成测试使用 msw(Mock Service Worker)

集成测试中绝不要 Mock 数据库。使用通过固定数据填充的专用测试数据库。这确保你的 SQL 查询、Schema 约束和索引都能在真实数据上进行测试。

架构测试

架构测试验证包导入边界是否被遵守,以及 CSS Token 规范是否被遵循:

pnpm test:arch

这些测试使用 vitest + fast-check 来断言以下规则:

  • apps/* 不得相互导入
  • packages/ui 不得从 apps/* 导入
  • 组件文件中不得有硬编码的十六进制值(必须使用 CSS 变量)

架构测试在 5 秒内完成,可在跨边界导入根深蒂固之前将其捕获。

CI 集成

每次拉取请求(PR)时,GitHub Actions 都会运行所有测试类型:

# .github/workflows/ci.yml(摘录)
jobs:
  test:
    steps:
      - run: pnpm test:coverage    # 单元 + 集成测试,含覆盖率
      - run: pnpm test:arch        # 架构边界检查
      - run: pnpm e2e              # 针对预览部署的 Playwright 端到端测试

覆盖率结果会作为评论发布在每个 PR 上。如果出现以下情况,PR 将无法合并:

  • 单元/集成覆盖率低于阈值
  • 任何测试失败
  • 检测到架构边界违规

测试驱动开发

本项目对新功能采用测试驱动开发(TDD)方式:

在编写实现代码之前先编写测试。运行测试并确认它失败。

只编写足以使测试通过的代码,不要过度设计。

在不破坏测试的前提下清理实现代码。

运行 pnpm test:coverage,确认新代码的覆盖率达到要求的阈值。


How is this guide?

目录