
组件测试 RSCs
在浏览器中快速完整地测试 React Server Components

经过长时间的期待,React Server Components (RSCs) 正在通过模糊传统前端和后端代码之间的界限,来改变我们构建 React 应用的方式。RSCs 帮助您构建更快、更响应式且更简单的应用。但是,尽管有如此大的推动力,关于如何测试它们的工作却很少。这使得在它们之上构建应用变得困难,缺乏信心。
在这篇文章中,我们介绍了 Storybook 组件测试对 RSCs 的支持。我们将展示:
- 如今 RSC 测试方面存在差距。
- 您可以使用在浏览器中运行的 RSC 集成测试来弥合这一差距。
- 您可以使用这些测试来演练完整的应用程序。在本例中,是 Vercel 的 Notes 演示应用。
- 您可以模拟复杂的应用程序状态,例如身份验证和直接数据库访问。
- 这些测试比同等的端到端 (E2E) 测试快得多。
重要的是首先声明,我们并非建议去除 E2E 测试。E2E 是唯一能够让您对整个应用程序堆栈协同工作充满信心的途径。然而,通常会有数百甚至数千个组件测试来演练许多关键的应用程序状态。而根据我们的经验,由于测试速度较慢且容易出错,这种程度的 E2E 测试是不可行的。

RSCs 和测试差距
React Server Components (RSCs) 改变了 React 应用程序的编写方式,引入了新的构造,以换取更高的性能和安全性。现在还处于早期阶段,但随着 Next.js 团队的持续工作以及 React 19 即将面向其他框架推出,RSCs 注定会成为一个重要的发展方向。
现在是时候开始弄清楚最佳实践了。
关于 RSCs 的最大问题之一是如何测试它们。 RSCs 在服务器上运行,可以直接引用 Node 代码,但它们也包含仅在浏览器中渲染的客户端组件。由于这种复杂性,React 核心团队建议将端到端 (E2E) 测试作为测试 RSCs 的主要方式。E2E 测试演练整个系统,并且不关心实现细节,因此它们是测试用户实际体验的好方法。然而,它们也很难设置、速度慢且容易出错。
因此,认真的测试人员会结合使用 E2E 测试来测试应用程序的顺畅路径,并使用单元/集成测试来覆盖更多状态。但到目前为止,还没有针对 RSCs 的单元/集成方法,这在测试策略中留下了一个主要的空白。
这就是 Storybook 的用武之地。在这篇文章中,我们介绍了用于 RSCs 的组件测试:在浏览器中运行的小型、自包含的集成测试,可以演练服务器和客户端代码。您可以模拟各种各样的测试用例,并快速且无错误地执行它们。

组件测试 RSCs
组件测试是小型、类似单元的测试,在浏览器中运行。与端到端 (E2E) 测试可能涉及多次往返服务器甚至更多的数据获取不同,Storybook 组件测试是紧凑、隔离的,并且完全在浏览器中运行。
考虑一个“hello world”示例,该示例在隔离状态下对单个 Button
组件进行操作
// 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);
}
}
对于任何编写过 story 的 play
函数的人来说,这个示例都会很熟悉,它允许您模拟和断言组件的功能和行为。这里唯一的新构造是 mount
函数,它是 Storybook 最近添加的功能,允许您在 play
函数中“安排、执行、断言”所有操作
- 安排: (a) 初始化
count
,(b)mount(...)
,渲染 story。 - 执行: 点击按钮
- 断言: 点击按钮后,验证 (a) 是否渲染了 Submit 按钮,以及 (b) 计数是否已更新。
请注意,如果您不解构 mount
函数,则 play
将在 Storybook 自动渲染 Story 后执行。
这不是最有趣的测试,但正如我们接下来将看到的,构建和测试完整的 RSC 应用程序页面并不比这复杂多少。
Server Components Notes 演示应用
组件测试不仅仅适用于 hello world。过去,我们使用 Storybook 构建了一个 RSC 中的 Hacker News 克隆版本。该示例是在 REST API 之上实现的,我们使用 Mock Service Worker (MSW) 模拟了这些 API 调用。
但并非所有 RSC 应用程序都如此简单。RSC 几乎可以在服务器上执行任何操作,您可能需要其他类型的模拟才能在浏览器中测试该功能。这就是为什么,例如,我们投入了 Storybook 中的类型安全模块模拟。
这一次,我们修改了 Vercel 的 Server Components Notes 演示应用,以隔离构建和测试 RSCs。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 客户端。
接下来,让我们看一下其中一个 story:
// 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();
},
}
使用这段小代码片段,文件中的每个 story 默认都有两个笔记。特定的 story 可以修改数据库内容以实现其所需的状态,例如 EmptyState
,它会重置为空数据库。
请注意,与 Page 组件不同,story 文件直接从 '#lib/db.mock'
导入。这意味着它为模拟提供了完整的类型安全性,例如,包装函数公开其 .mock.calls
和其他字段,用于类型检查和自动完成。
模拟身份验证
现在让我们看一下身份验证。与需要复杂的歌曲和舞蹈来验证用户身份的 E2E 测试不同,在 Storybook 中模拟经过身份验证的状态非常简单。这是另一个 story,显示与上面相同的页面,但处于登录状态:
// 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,因此我们只需设置 auth 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 通过上面的 story 演示了各种类型的模拟,并且在 storybook-rsc-demo 代码仓库中公开提供。截至撰写本文时,它包含 34 个 story。
在配备 16GB 内存的 2021 Macbook M1 Pro 上,将这些 story 作为 Vitest 测试(通过 Storybook Test)执行大约需要 7 秒。它在整个项目中(包括前端和后端代码)产生了 87% 的行覆盖率和 73% 的分支覆盖率。
Storybook Test 可以在 CLI 或 Storybook 本身内部运行您的测试,Storybook 本身提供状态过滤和交互式调试器,可让您在测试步骤中来回跳转。并且由于您的测试是 story,因此您在编写测试时也会获得视觉反馈。
👉 在此处注册!
立即试用
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 中可用。此外,我们还在努力开发:
- 零配置代码覆盖率,用于量化您在开发时进行的测试
- 焦点测试,用于快速测试单个 story、组件或目录
- 模拟功能,以形式化和可视化我们在此处展示的一些模式
有关我们正在考虑和积极开展的项目的概述,请查看 Storybook 的路线图。
🙅 仅仅为了测试 React Server Components (RSCs) 而启动您的整个 E2E 设置
— Storybook (@storybookjs) 2024年12月3日
🙌 在浏览器中组件测试 RSCs,具有模拟的复杂应用程序状态和可靠的速度
🧵 pic.twitter.com/1KQFsXVBg9