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

经过长时间的期待,React Server Components (RSCs) 正在彻底改变我们构建 React 应用的方式,模糊了传统前端和后端代码之间的界限。RSCs 帮助您构建更快、响应更迅速、更简单的应用程序。但尽管有巨大的推动,但关于如何测试它们的工作却很少。这使得建立在它们之上时缺乏信心。
在本文中,我们介绍了 Storybook 组件测试 for 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);
}
}Anyone who has written a play function for a story, which allows you to simulate and assert on component functionality and behavior. The only new construct here is the mount function, which is a recent addition to Storybook that allows you to “arrange, act, assert” all within the play function
- Arrange: (a) initialize
count, (b)mount(...), which renders the story. - Act: Click the button
- Assert: After the button is clicked, verify (a) that the Submit button is rendered, and (b) that the count was updated.
Note that if you don’t destructure the mount function, play will execute after Storybook has automatically rendered the Story.
This is not the most interesting test, but as we’ll see next, it doesn’t take much more than this to build and test full RSC application pages.
Server Components Notes 演示应用程序
Component tests are not just for hello world. In the past, we used Storybook to build a Hacker News clone in RSC. That example was implemented on top of a REST API, and we mocked out those API calls using Mock Service Worker (MSW).
But not all RSC apps are that simple. An RSC can do just about anything on the server, and you might need other kinds of mocking to test that functionality in the browser. That’s why, for example, we invested in Typesafe Module Mocking in Storybook.
This time around, we’ve modified Vercel’s Server Components Notes demo app to build and test RSCs in isolation. The Notes app uses a Prisma database to store notes and Github OAuth for authentication.
When the user is logged out, they can view and search notes

When logged in, they can also add/edit/delete them

We’ve organized our Storybook into two sections: App and Components. App mirrors the route/folder structure of the application and Components is currently a flat list of the components inside the app. For a more complete app, these components would be organized into subfolders

使用内存数据库进行模拟
Databases are the heart of many web applications, and the Notes demo is no different. We use a Prisma database to store notes. Mocking it with an in-memory database allows us to control what notes get rendered to the screen and intercept when notes are added, removed, and updated.
This is amazing for multiple reasons
- Speed. We can instantly teleport our app into any state and test from there, eliminating moving parts. Far fewer network requests and moving parts means that these tests are MUCH faster than their E2E equivalents and have a lot less flake.
- Coverage. We are getting test coverage for both our frontend and backend code in a single set of integration tests, since we are mocking at the very back of the backend. This is much easier to deal with than having to jump through hoops to measure coverage on the front and back ends during an E2E run and then having to stitch those reports together after the fact.
- Isolation. Since the database is in-memory, it means that each test effectively gets its own database and you don’t have to ever worry about different tests overwriting each others’ data. If you have E2E tests running against a fixed database, you always need to worry about resource contention if you’re running tests in parallel within a single run or have multiple runs. That’s never a problem here.
We’ll quantify these benefits later, but first let’s see how it all works. Here’s the server component that implements the Note page
// 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} />;
}All the magic is happening on this line
import { db } from '#lib/db';We’re using a standard called subpath imports to enable typesafe module mocking. When run in the application environment (Next.js), this imports from /lib/db.ts , which exports a Prisma client connected to a real database. When run in Storybook, it imports from /lib/db.mock.ts, which exports a Prisma client that’s connected to an in-memory database.
Next, let’s look at one of the stories
// 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();
},
}With this small snippet of code, every story in the file will have two notes by default. Specific stories can modify the database contents to achieve their desired state, such as EmptyState which resets to an empty database.
Note that the unlike the Page component, the story file is importing from '#lib/db.mock' directly. That means that it gets full type safety for the mocks, e.g. wrapped functions expose their .mock.calls and other fields for both type checking and autocompletion.
Mocking authentication
Now let’s take a look at authentication. Unlike E2E tests that need a complex song and dance to authenticate your user, mocking an authenticated state in Storybook is dead simple. Here’s another story that shows the same page as above, but in a logged in state
// 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'));
}
}Since our authentication is cookie-based, and Storybook’s Next.js framework automatically mocks out cookies, we can just set the auth cookie and we’re done. With this small modification, the RSC will look and behave as if the user storybookjs is logged in.
Testing a user workflow
The two simple primitives of mocking the database and mocking authentication get us a lot. We can write E2E-style tests that are blazing fast and flake free, and also allow us to inspect any part of the system. For example, here’s a test to add a new note
// 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',
}),
)
},
}Putting it all together
At the beginning of the post, we claimed that component tests can be a fast and flake-free way to test RSC apps. So how did we do?
Our Storybook demonstrates various types of mocking via stories like the ones above, and is available publicly in the storybook-rsc-demo repo. As of this writing it contains 34 stories.
Executing those stories as Vitest tests (via Storybook Test) takes ~7s on a 2021 Macbook M1 Pro with 16GB of RAM. It results in 87% line and 73% branch coverage across the entire project including both frontend and backend code.
Storybook Test can run your tests in the CLI or inside Storybook itself, which offers status filtering and an interactive debugger to step back and forward through your test’s steps. And because your tests are stories, you get visual feedback as you write them, too.
👉 Register here!
立即试用
Component testing for RSC is available (experimentally) in Storybook. Try it in a new Next.js project
npx storybook@latest init
或升级现有项目
npx storybook@latest upgrade
Make sure you enable the feature flag for RSC support
// .storybook/main.js
export default {
// ...
features: {
experimentalRSC: true,
},
};
For the full example shown in this post, please see the storybook-rsc-demo repo. And to learn more, please see the RSC and component testing docs.
下一步是什么?
Storybook Test as benchmarked in this post is available in Storybook 8.4. In addition, we’re also working on
- Zero-config code coverage to quantify your tests are you’re developing
- Focused tests to quickly test a single story, component, or directory
- Mocking features to formalize and visualize some of the patterns we’ve shown here
For an overview of projects we’re considering and actively working on, please check out Storybook’s roadmap.
🙅 Firing up your whole E2E setup just to test React Server Components (RSCs)
— Storybook (@storybookjs) December 3, 2024
🙌 Component testing RSCs in the browser, with mocked complex app states and reliable speed
🧵 pic.twitter.com/1KQFsXVBg9