第 16 章:SAAS 测试策略与质量保障
在第 14 章中,我们建立了一个 CI/CD 流水线,它充当了我们 SAAS 应用的“守门员”。在第 15 章中,我们使用 Biome (Linter) 确保了代码的“静态质量”。然而,一个只检查语法的守门员是远远不够的。我们的 CI 流程中还有一个 pnpm test 命令——这才是质量保障的核心。
第 16 章:SAAS 测试策略与质量保障
在第 14 章中,我们建立了一个 CI/CD 流水线,它充当了我们 SAAS 应用的“守门员”。在第 15 章中,我们使用 Biome (Linter) 确保了代码的“静态质量”。然而,一个只检查语法的守门员是远远不够的。我们的 CI 流程中还有一个 pnpm test 命令——这才是质量保障的核心。
代码的“动态质量”——即它在运行时是否按照预期工作——必须通过自动化测试来保障。
对于从 Python 栈(尤其是 Django)迁移过来的开发者来说,你一定非常熟悉 pytest 和 unittest.TestCase,以及 Django 提供的强大的测试客户端(self.client.get('/my-url/'))和 ORM 级别的测试数据库隔离。
在 Next.js/React 全栈生态中,测试的理念是相似的,但工具和分层策略有所不同。我们将采用一个混合了“测试金字塔”和“测试奖杯”的策略,确保从最小的逻辑单元到最复杂的购买流程都能被覆盖。
本章,我们将配置并实战四种关键的测试类型:
- 单元测试 (Vitest):用于纯粹的、隔离的业务逻辑。
- 组件测试 (RTL):用于独立的 React 组件。
- E2E 测试 (Playwright):用于关键的、跨越前后台的用户旅程。
- 数据库测试 (pgTAP):用于保障数据层的完整性。
16.1. 单元测试 (Vitest)
什么是单元测试? 单元测试(Unit Test)是金字塔的最底层。它只测试一个“单元”——通常是一个独立的函数或模块,完全脱离 React、Next.js 甚至数据库。它必须快,非常快。
为什么选择 Vitest? 在过去,Jest 是 Node.js 测试的王者。但正如 Biome 取代 ESLint+Prettier 一样,Vitest 正在凭借其速度和现代特性(基于 Vite,内置 ESM/TS 支持)成为新的标杆。
- 速度极快:利用现代 JavaScript 特性,按需编译和智能缓存。
- API 兼容:它提供了与 Jest 几乎 1:1 兼容的 API (
describe,it,expect,vi.mock),迁移成本极低。 - 配置简洁:开箱即用地支持 TypeScript、JSX。
[代码解析]:测试一个积分计算辅助函数
假设我们在 src/lib/credit-utils.ts 中有一个简单的辅助函数:
// src/lib/credit-utils.ts
export function calculateCreditsForPlan(planId: 'free' | 'pro' | 'lifetime'): number {
if (planId === 'pro') return 1000;
if (planId === 'lifetime') return 50000;
return 10;
}我们的 Vitest 测试文件 src/lib/credit-utils.test.ts 将如下所示:
// src/lib/credit-utils.test.ts
import { describe, it, expect } from 'vitest';
import { calculateCreditsForPlan } from './credit-utils';
// 'describe' 定义了一个测试套件
describe('Credit Calculation Utilities', () => {
// 'it' 或 'test' 定义了一个单独的测试用例
it('should return 1000 credits for pro plan', () => {
// 'expect' 是断言
expect(calculateCreditsForPlan('pro')).toBe(1000);
});
it('should return 10 credits for free plan', () => {
expect(calculateCreditsForPlan('free')).toBe(10);
});
it('should return 50000 credits for lifetime plan', () => {
expect(calculateCreditsForPlan('lifetime')).toBe(50000);
});
});这个测试运行在 Node.js 环境中,速度快如闪电,它完美地保障了我们最核心的业务逻辑的正确性。
16.2. 组件测试 (React Testing Library)
什么是组件测试?
它位于金字塔的中间层,用于测试 React 组件的渲染和交互。我们不再关心 calculateCreditsForPlan 函数的内部逻辑,我们只关心“当用户点击按钮时,UI 是否正确更新了?”
React Testing Library (RTL) 是此领域的绝对标准(由 Next.js 默认集成)。 RTL 的核心理念是:“你的测试越像用户使用你的软件的方式,它们就越能给你带来信心。”
RTL _不会_让你去检查组件的内部 state 或 props。相反,它迫使你:
- Render:渲染组件。
- Find:像用户一样查找元素(例如通过文本
getByText('Subscribe')或表单标签getByLabelText('Email'))。 - Act:像用户一样与元素交互(例如
fireEvent.click(button))。 - Assert:断言 UI 发生了预期的变化(例如
expect(getByText('Success!')).toBeInTheDocument())。
[代码解析]:测试 NewsletterForm 组件
回忆一下第 13 章的 NewsletterForm。我们将测试当用户输入无效邮箱时,是否会正确显示错误。
// src/components/forms/NewsletterForm.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { NewsletterForm } from './NewsletterForm';
// 模拟 Server Action
// 我们不希望在测试中真的去调用 Resend API
vi.mock('@/actions/newsletter.actions', () => ({
subscribeToNewsletter: vi.fn(),
}));
import { subscribeToNewsletter } from '@/actions/newsletter.actions';
describe('NewsletterForm', () => {
it('should render the form', () => {
render(<NewsletterForm />);
// 检查表单和按钮是否已渲染
expect(screen.getByPlaceholderText('your@email.com')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Subscribe/i })).toBeInTheDocument();
});
it('should show an error message for an invalid email', async () => {
// 模拟 Server Action 返回一个错误
(subscribeToNewsletter as vi.Mock).mockResolvedValueOnce({
error: 'Invalid email.',
});
render(<NewsletterForm />);
const input = screen.getByPlaceholderText('your@email.com');
const button = screen.getByRole('button', { name: /Subscribe/i });
// 模拟用户输入
await fireEvent.change(input, { target: { value: 'not-an-email' } });
// 模拟用户点击
await fireEvent.click(button);
// 等待并断言错误消息出现
// 'findByText' 是 'getByText' 的异步版本,它会等待 UI 更新
expect(await screen.findByText('Invalid email.')).toBeInTheDocument();
});
});这个测试完美地模拟了组件的内部交互,并利用 vi.mock 隔离了外部依赖(Server Action)。
16.3. E2E 测试 (Playwright):模拟用户购买
什么是 E2E 测试? 端到端 (End-to-End) 测试是金字塔的顶端。它不关心任何代码实现,只关心完整的用户旅程。E2E 测试是你的终极“验收标准”。
我们将使用 Playwright (由 Microsoft 开发),它是目前 E2E 测试的标杆。它会启动一个真实的浏览器(Chromium, Firefox, WebKit),打开你的网站(在测试服务器上),然后像真人一样点击、滚动和输入。
[代码解析]:模拟用户购买订阅的完整流程
这是我们 SAAS 应用中最重要的一个测试用例。如果它通过了,你就知道你的核心付费流程是完好的。
策略: 我们不能在测试中真的去刷信用卡。我们必须Mock Stripe。但我们不只是 Mock JS 函数,我们要 Mock 网络请求。Playwright 允许我们拦截浏览器发出的所有请求。
- 当浏览器尝试访问
checkout.stripe.com时,我们拦截它。 - 我们不让它真的访问 Stripe,而是立即将其重定向回我们的
success_url。 - 同时,我们的测试脚本需要一种方式来“假装”Stripe Webhook 已经送达。最可靠的方法是让测试脚本直接操作测试数据库,将用户的状态设置为“Pro”。
// tests/e2e/subscription.spec.ts
import { test, expect } from '@playwright/test';
import { db } from '@/db'; // 假设 E2E 测试可以访问数据库
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';
const TEST_USER_EMAIL = `test-user-${Date.now()}@example.com`;
const TEST_USER_PASSWORD = 'Password123';
test.describe('Subscription Flow', () => {
// 在每个测试用例运行前,重置数据库状态
test.beforeEach(async () => {
// 1. 清理:确保测试用户不存在
await db.delete(users).where(eq(users.email, TEST_USER_EMAIL));
});
test('A new user can register, purchase a Pro plan, and see their credits', async ({ page }) => {
// 步骤 1: 注册
await page.goto('/register');
await page.getByLabel('Email').fill(TEST_USER_EMAIL);
await page.getByLabel('Password').fill(TEST_USER_PASSWORD);
await page.getByRole('button', { name: 'Register' }).click();
// 应该跳转到了仪表盘
await expect(page.getByText('Welcome')).toBeVisible();
// 步骤 2: 导航到定价页
await page.getByRole('link', { name: 'Pricing' }).click();
await expect(page.getByText('Pro Plan')).toBeVisible();
// 步骤 3: 拦截 Stripe Checkout
await page.route('**/api/stripe/checkout', (route) => {
// (这是简化的 Mock, 实际上你可能会拦截 createCheckoutSession 的 Server Action)
// 假设点击按钮会调用一个 API 或 Action,我们拦截它
// 更简单的方法是拦截对 Stripe 的 *外部* 调用
console.log('Intercepted Stripe checkout request');
});
// 步骤 4: 点击升级按钮 (来自 Pro Plan 卡片)
// 假设卡片在 `[data-testid="plan-pro"]` 内
await page.locator('[data-testid="plan-pro"] button').click();
// --- 关键的 Mock 流程 ---
// 在一个真实的 E2E 测试中,点击后会跳转到 Stripe。
// 我们将在这里“作弊”,模拟支付成功。
// 模拟 1: Webhook 处理器被触发
// E2E 测试直接更新数据库,假装 Webhook 已经完成
const testUser = await db.query.users.findFirst({ where: eq(users.email, TEST_USER_EMAIL) });
// 假装 Stripe Webhook 成功了,并授予了积分
await db.update(users)
.set({
subscriptionStatus: 'pro',
totalCredits: 1000 // (来自 price-config)
})
.where(eq(users.id, testUser!.id));
// 模拟 2: 用户被重定向回成功页面
await page.goto('/dashboard?payment=success');
// 步骤 5: 验证结果
// 刷新页面以获取最新的服务器渲染数据
await page.reload();
// 断言:用户现在是 Pro 计划
await expect(page.getByText('Plan: Pro')).toBeVisible();
// 断言:用户获得了积分
await expect(page.getByText(/Credits: 1000/i)).toBeVisible();
});
});这个测试是昂贵且缓慢的(可能需要 10-30 秒),但它提供的信心是无与伦比的。
16.4. Drizzle 数据库测试 (pgTAP)
最后一层,也是最底层:数据本身。我们的应用逻辑依赖于数据库的约束:
users.email必须是唯一的 (UNIQUE)。creditTransactions.amount不能是 NULL。users.totalCredits必须大于等于 0 (CHECK constraint)。
我们如何测试这些数据库_内部_的规则?
答案是 pgTAP,一个用于 PostgreSQL 的测试框架。它允许你……用 SQL 来编写测试!
这对于 Python 开发者来说可能很新奇,Django/Flask 开发者通常在应用层测试这些(例如,捕获 IntegrityError)。pgTAP 让我们能在数据库迁移(第 10 章)后立即验证数据库结构是否符合预期。
[代码解析]:用 SQL 测试 SQL
pgTAP 测试通常存放在 db/tests/ 目录中,并由 pnpm test:db 脚本运行。
-- db/tests/test_schema.sql
BEGIN;
-- 载入 pgTAP 扩展
CREATE EXTENSION IF NOT EXISTS pgtap;
-- 开始测试
SELECT plan(3); -- 声明我们有 3 个测试
-- 测试 1: 检查 'users' 表是否存在
SELECT has_table('public', 'users', 'Table "users" should exist.');
-- 测试 2: 检查 'users' 表是否有 'total_credits' 列
SELECT has_col('public', 'users', 'total_credits', 'Column "users.total_credits" should exist.');
-- 测试 3: 检查 'total_credits' 的 CHECK 约束是否有效
-- 尝试插入一个非法数据 (积分 < 0)
-- 'throws_ok' 期望这个操作失败
SELECT throws_ok(
$$
INSERT INTO users (id, email, total_credits)
VALUES ('test-id', 'check-test@example.com', -50)
$$,
'23514', -- 23514 是 PostgreSQL 中 check_violation 的错误码
'CHECK constraint for total_credits >= 0 should be active.'
);
-- 完成测试
SELECT * FROM finish();
ROLLBACK;这个测试完全在 PostgreSQL 内部运行,为我们的数据完整性提供了最后一道防线。
第 16 章总结
在本章中,我们为我们的 SAAS 应用构建了一个全面、多层次的质量保障体系,完美地补充了第 14 章的 CI/CD 流水线。
- 单元测试 (Vitest):我们使用 Vitest 来快速测试隔离的、纯粹的业务逻辑(如辅助函数)。这是金字塔的基座,确保了基础模块的正确性。
- 组件测试 (RTL):我们使用 React Testing Library 来模拟用户与 React 组件的交互。我们验证了 UI 的渲染和状态转换,而不关心组件的内部实现。
- E2E 测试 (Playwright):我们实现了“皇冠上的明珠”——一个端到端的测试,模拟了用户从注册到购买订阅的完整付费流程。通过拦截网络请求和直接操作测试数据库,我们验证了整个 SAAS 系统的核心价值。
- 数据库测试 (pgTAP):我们深入到数据库层,使用
pgTAP编写 SQL 测试,以确保我们的 Schema 约束(如CHECK约束)按预期工作,保障了数据的最终完整性。
现在,当我们的 GitHub Action 运行 pnpm test 时,它不再是一个空命令。它是一个强大的守护者,从四个不同的维度审查我们的代码,确保只有高质量、功能正确的代码才能被部署到生产环境。
更多文章
第 11 章:支付与订阅 (Stripe)
欢迎来到第五部分,也是我们 SAAS 应用的核心商业逻辑。在前面的章节中,我们构建了坚实的应用基础——从前端 UI、RSC 数据流、安全的 Server Actions 到类型安全的 Drizzle ORM 和 'better-auth' 认证。现在,是时候将我们的应用从一个“项目”转变为一个“产品”了:实现付费订阅。
第 12 章:SAAS 定价:积分与计量系统
在第 11 章中,我们成功地集成了 Stripe Checkout 和 Webhooks,为我们的 SAAS 奠定了“订阅”基础。用户现在可以为“Pro”计划付费了。然而,一个现代 SAAS,尤其是 AI SAAS,仅仅区分“免费”和“付费”是远远不够的。
第 15 章:代码质量 (Biome) 与内容 (Fumadocs)
在第 14 章中,我们建立了一个坚实的 CI/CD 流水线,它充当了我们 SAAS 应用的“守门员”。这个守门员的核心职责之一就是运行 pnpm lint 和 pnpm typecheck。但是,这些命令背后到底是什么在工作?