
RSC 组件测试
在浏览器中快速、全面地测试 React 服务器组件

备受期待的 React 服务器组件 (RSC) 正在改变我们构建 React 应用的方式,模糊了传统前端和后端代码的界限。RSC 帮助你构建更快、响应更迅速、更简洁的应用。但尽管大力推广,如何测试它们的工作却进展甚微。这使得基于它们进行构建变得困难。
在这篇文章中,我们介绍 Storybook 的 RSC 组件测试功能。我们将展示
- 目前 RSC 测试存在空白。
- 你可以通过在浏览器中运行的 RSC 集成测试来弥补这一空白。
- 你可以使用这些测试来测试完整的应用程序。例如,Vercel 的 Notes 演示应用。
- 你可以模拟复杂的应用状态,例如认证和直接数据库访问。
- 这些测试比相应的端到端 (E2E) 测试快得多。
首先声明,我们并非建议放弃 E2E 测试。E2E 是唯一能确保整个应用技术栈协同工作的测试方式。然而,通常会有数百甚至数千个组件测试,覆盖许多关键的应用状态。根据我们的经验,由于测试速度慢且不稳定,使用 E2E 进行这种级别的测试是不可行的。

RSC 和测试空白
React 服务器组件 (RSC) 改变了 React 应用的编写方式,带来了新的构建方式,以换取性能和安全性的提升。尽管仍处于早期阶段,但随着 Next.js 团队的持续工作以及 React 19 即将支持其他框架,RSC 注定会成为一个重要的技术。
现在是时候开始探索最佳实践了。
围绕 RSC 的最大问题之一是如何测试它们。RSC 在服务器上运行,可以直接引用 Node 代码,但也包含仅在浏览器中渲染的客户端组件。由于这种复杂性,React 核心团队建议将端到端 (E2E) 测试作为测试 RSC 的主要方式。E2E 测试覆盖整个系统,不关心实现细节,因此是测试用户实际体验的绝佳方式。然而,它们也可能难以设置、速度慢且不稳定。
因此,专业的测试人员会结合使用 E2E 测试来测试应用中的主要流程,并使用单元/集成测试来覆盖更多状态。但到目前为止,还没有针对 RSC 的单元/集成测试方法,这在测试策略上留下了巨大的空白。
Storybook 在这里就派上用场了。在这篇文章中,我们介绍针对 RSC 的组件测试:一种小型、自包含的集成测试,可在浏览器中同时测试服务器和客户端代码。你可以模拟各种测试用例,并快速且稳定地执行它们。

RSC 组件测试
组件测试是在浏览器中运行的小型、类似单元测试的测试。与可能涉及多次服务器往返甚至更多数据获取的端到端 (E2E) 测试不同,Storybook 组件测试紧凑、隔离,并且完全在浏览器中运行。
考虑一个孤立地操作单个 Button
组件的“hello world”示例
// Button.stories.jsx
export const TestEvents = {
async play({ mount, args }) {
// Arrange
let count = 0;
const canvas = await mount(
<Button label="Submit" onClick={() => { count++ }} />
);
// Act
await userEvent.click(canvas.getByRole('button'));
// Assert
await expect(canvas.getByText('Submit')).toBeDefined();
await expect(count).toBe(1);
}
}
这个例子对于编写过故事的 play
函数的人来说会很熟悉,它允许你在组件的功能和行为上进行模拟和断言。这里唯一的新构造是 mount
函数,它是 Storybook 最近添加的功能,允许你在 play
函数中完成“安排、执行、断言”的所有操作
- 安排 (Arrange): (a) 初始化
count
,(b)mount(...)
,它渲染故事。 - 执行 (Act): 点击按钮
- 断言 (Assert): 点击按钮后,验证 (a) 提交按钮已渲染,以及 (b) 计数已更新。
注意,如果你没有解构 mount
函数,play
将在 Storybook 自动渲染故事后执行。
这不是最有趣的测试,但正如我们接下来会看到的那样,构建和测试完整的 RSC 应用页面所需的步骤并不复杂。
服务器组件 Notes 演示应用
组件测试不仅仅适用于“hello world”示例。过去,我们使用 Storybook 构建了一个 RSC 版本的 Hacker News 克隆。该示例是基于 REST API 实现的,我们使用 Mock Service Worker (MSW) 模拟了这些 API 调用。
但并非所有 RSC 应用都如此简单。RSC 几乎可以在服务器上做任何事情,你可能需要其他类型的模拟来在浏览器中测试这些功能。这就是为什么我们投资了 Storybook 中的类型安全模块模拟。
这一次,我们修改了 Vercel 的 Server Components Notes 演示应用,以孤立地构建和测试 RSC。Notes 应用使用 Prisma 数据库存储笔记,并使用 Github OAuth 进行认证。
当用户未登录时,他们可以查看和搜索笔记

当用户登录时,他们还可以添加/编辑/删除笔记

我们将 Storybook 分为两个部分:App 和 Components。App 部分反映了应用的路由/文件夹结构,Components 部分目前是应用内部组件的平面列表。对于更完整的应用,这些组件会组织到子文件夹中

使用内存数据库进行模拟
数据库是许多 Web 应用的核心,Notes 演示也不例外。我们使用 Prisma 数据库存储笔记。使用内存数据库进行模拟,使我们能够控制哪些笔记被渲染到屏幕上,并在笔记被添加、删除和更新时进行拦截。
这有很多令人惊奇的原因
- 速度。我们可以立即将应用传送到任何状态并从那里进行测试,从而消除活动部分。更少的网络请求和活动部分意味着这些测试比相应的 E2E 测试快得多,并且更加稳定。
- 覆盖率。我们在同一组集成测试中获得了前端和后端代码的测试覆盖率,因为我们是在后端的最深层进行模拟。这比在 E2E 运行期间费力地测量前端和后端的覆盖率,然后再将这些报告拼接起来要容易得多。
- 隔离性。由于数据库是内存式的,这意味着每个测试实际上都有自己的数据库,你完全无需担心不同的测试会相互覆盖数据。如果你的 E2E 测试针对固定的数据库运行,那么在单次运行中并行运行测试或进行多次运行时,总是需要担心资源竞争。在这里,这从来不是问题。
稍后我们将量化这些优势,但首先让我们看看它是如何工作的。这是实现 Note 页面的服务器组件
// apps/notes/[id]/page.tsx
import NoteUI from '#components/note-ui';
import { db } from '#lib/db';
type Props = { params: { id: string } };
export default async function Page({ params }: Props) {
const note = await db.note.findUnique({ where: { id: Number(params.id) } });
if (note === null) { /* error */ }
return <NoteUI note={note} isEditing={false} />;
}
所有的神奇之处都在这一行
import { db } from '#lib/db';
我们使用了一种名为子路径导入的标准来启用类型安全模块模拟。当在应用环境 (Next.js) 中运行时,它会从/lib/db.ts
导入,该文件导出一个连接到真实数据库的 Prisma 客户端。当在 Storybook 中运行时,它会从/lib/db.mock.ts
导入,该文件导出一个连接到内存数据库的 Prisma 客户端。
接下来,我们看看其中一个故事
// app/notes/[id]/page.stories.jsx
import type { Meta, StoryObj } from '@storybook/react';
import { db, initializeDB } from '#lib/db.mock';
import Page from './page';
import { PageDecorator } from '#.storybook/decorators';
export default {
component: Page,
async beforeEach() {
await db.note.create({
data: {
title: 'Module mocking in Storybook?',
body: "Yup, that's a thing now! 🎉",
createdBy: 'storybookjs',
},
});
await db.note.create({ /* another */ });
},
decorators: [PageDecorator],
parameters: {
layout: 'fullscreen',
nextjs: {
navigation: { pathname: '/note/1' },
},
},
args: { params: { id: '1' } },
};
export const NotLoggedIn = {}
export const EmptyState = {
async play({ mount }) {
initializeDB({});
await mount();
},
}
通过这段简短的代码,文件中的每个故事默认都会有两条笔记。特定的故事可以修改数据库内容以达到所需的状态,例如 EmptyState
会将数据库重置为空。
注意,与 Page 组件不同,故事文件直接从 '#lib/db.mock'
导入。这意味着它获得了模拟的完全类型安全,例如,包装的函数会暴露其 .mock.calls
和其他字段,用于类型检查和自动补全。
模拟认证
现在我们来看看认证。与需要复杂流程才能认证用户的 E2E 测试不同,在 Storybook 中模拟认证状态非常简单。这是另一个故事,展示了与上面相同的页面,但处于登录状态
// app/notes/[id]/page.stories.jsx
// ...Continuing from above
import { cookies } from '@storybook/nextjs/headers.mock';
import { createUserCookie, userCookieKey } from '#lib/session';
// export default { ... } from above
export const LoggedIn = {
async beforeEach() {
cookies().set(userCookieKey, await createUserCookie('storybookjs'));
}
}
由于我们的认证是基于 cookie 的,并且 Storybook 的 Next.js 框架会自动模拟 cookie,我们只需设置认证 cookie 即可完成。通过这个小修改,RSC 的外观和行为就会如同用户 storybookjs 已登录一样。
测试用户工作流程
模拟数据库和模拟认证这两个简单的原语为我们带来了很多好处。我们可以编写像 E2E 一样快速稳定的测试,同时还能检查系统的任何部分。例如,这是一个添加新笔记的测试
// app/note/edit/page.stories.jsx
// ...Continuing from above
export const SaveNewNote = {
play: async ({ mount }) => {
// Arrange
cookies().set(userCookieKey, await createUserCookie('storybookjs'));
const canvas = await mount();
// Act
const titleInput = await canvas.findByLabelText(
'Enter a title for your note',
)
const bodyInput = await canvas.findByLabelText(
'Enter the body for your note',
)
await userEvent.clear(titleInput)
await userEvent.type(titleInput, 'New Note Title')
await userEvent.type(bodyInput, 'New Note Body')
await userEvent.click(
await canvas.findByRole('menuitem', { name: /done/i }),
)
// Assert
await waitFor(() =>
expect(getRouter().push).toHaveBeenLastCalledWith('/note/1', expect.anything()),
);
await expect(await db.note.findUnique({ where: { id: 1 } })).toEqual(
expect.objectContaining({
title: 'New Note Title',
body: 'New Note Body',
}),
)
},
}
总结
在文章开头,我们声称组件测试是测试 RSC 应用的一种快速且稳定的方式。那么我们做得怎么样呢?
我们的 Storybook 通过上述故事展示了各种类型的模拟,并且在 storybook-rsc-demo 代码仓库中公开可用。截至本文撰写时,它包含了 34 个故事。
在配备 16GB RAM 的 2021 款 Macbook M1 Pro 上,将这些故事作为 Vitest 测试运行(通过 Storybook Test)大约需要 7 秒。它在整个项目(包括前端和后端代码)中实现了 87% 的行覆盖率和 73% 的分支覆盖率。
Storybook Test 可以在命令行或 Storybook 内部运行你的测试,它提供了状态过滤和交互式调试器,可以逐步回退和前进你的测试步骤。而且,由于你的测试就是故事,你在编写测试时也会获得视觉反馈。
👉 在此注册!
今天就试试
RSC 的组件测试在 Storybook 中已可用(实验性功能)。在新的 Next.js 项目中尝试使用它
npx storybook@latest init
或者升级现有项目
npx storybook@latest upgrade
请确保启用 RSC 支持的功能标志
// .storybook/main.js
export default {
// ...
features: {
experimentalRSC: true,
},
};
有关本文所示的完整示例,请参阅 storybook-rsc-demo 代码仓库。要了解更多信息,请参阅 RSC 和 组件测试 文档。
下一步是什么?
本文中作为基准测试的 Storybook Test 已在 Storybook 8.4 中可用。此外,我们还在努力开发
- 零配置代码覆盖率,可在开发过程中量化你的测试效果
- 聚焦测试,快速测试单个故事、组件或目录
- 模拟功能,以规范化和可视化本文展示的一些模式
有关我们正在考虑和积极进行的项目概述,请查看 Storybook 的路线图。
🙅 仅仅为了测试 React 服务器组件 (RSC) 就启动整个 E2E 设置
— Storybook (@storybookjs) 2024年12月3日
🙌 在浏览器中进行 RSC 组件测试,模拟复杂应用状态,速度可靠
🧵 pic.twitter.com/1KQFsXVBg9