Component testing RSCs
Fully test React Server Components in the browser, fast
After much anticipation, React Server Components (RSCs) are transforming the way we build React apps by blurring the lines between traditional frontend and backend code. RSCs help you build a faster, more responsive, and less complex app. But despite this huge push, there has been little work on how to test them. That makes it hard to build on them with confidence.
In this post, we introduce Storybook Component Testing for RSCs. We show:
- That there is a testing gap for RSC today.
- You can close this gap with RSC integration tests that run in the browser.
- You can exercise full applications with these tests. In this case, Vercel’s Notes demo app.
- You can mock complex app states, like auth and direct database access.
- These tests are significantly faster than the equivalent end-to-end (E2E) tests.
It is important to state up front that we are NOT proposing to get rid of E2E tests. E2E is the only way to get confidence about your entire app’s stack working together. However, it’s common to have hundreds or even multiple thousands of component tests that exercise many key application states. And in our experience, this level of testing with E2E is infeasible due to slower test speed and higher flake.
RSCs and the testing gap
React Server Components (RSCs) change the way React applications are written, bringing new constructs in exchange for improved performance and security. It’s still early days, but with ongoing work by the Next.js team and React 19 on its way for other frameworks, RSCs are destined to be a big deal.
Now it’s time to start figuring out best practices.
One of the biggest questions around RSCs is how to test them. RSCs run on the server and can directly reference Node code, but they also contain client components that render only in the browser. Due to this complexity, the React core team suggests end-to-end (E2E) testing as the primary way to test RSCs. E2E tests exercise the entire system and don’t care about implementation, so they are a great way to test what the user actually experiences. However, they can also be hard to set up, slow, and flaky.
As a consequence, serious testers use a combination of E2E tests to test happy paths through the application and unit/integration tests to cover more states. But until now there is no unit/integration approach for RSCs, leaving a major gap in the testing strategy.
That’s where Storybook comes in. In this post, we introduce Component Testing for RSCs: small, self-contained integration tests that exercise both server and client code, in the browser. You can simulate a wide variety of test cases, and execute them quickly and without flake.
Component testing RSCs
Component tests are small, unit-like tests that run in the browser. Unlike end-to-end (E2E) tests that can involve multiple round trips to a server and even more data fetching beyond that, Storybook component tests are compact, isolated, and run entirely in the browser.
Consider a “hello world” example that operates on a single Button
component in isolation:
// 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);
}
}
This example will look familiar to 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 demo app
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:
Mocking using an in-memory database
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!
Try it today
Component testing for RSC is available (experimentally) in Storybook. Try it in a new Next.js project:
npx storybook@latest init
Or upgrade an existing project:
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.
What’s next?
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.