返回博客

组件测试 RSCs

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

loading
Michael Shilman
@mshilman
最后更新

经过长时间的期待,React Server Components (RSCs) 正在通过模糊传统前端和后端代码之间的界限,来改变我们构建 React 应用的方式。RSCs 帮助您构建更快、更响应式且更简单的应用。但是,尽管有如此大的推动力,关于如何测试它们的工作却很少。这使得在它们之上构建应用变得困难,缺乏信心。

在这篇文章中,我们介绍了 Storybook 组件测试对 RSCs 的支持。我们将展示:

  1. 如今 RSC 测试方面存在差距。
  2. 您可以使用在浏览器中运行的 RSC 集成测试来弥合这一差距。
  3. 您可以使用这些测试来演练完整的应用程序。在本例中,是 Vercel 的 Notes 演示应用。
  4. 您可以模拟复杂的应用程序状态,例如身份验证和直接数据库访问。
  5. 这些测试比同等的端到端 (E2E) 测试快得多。

重要的是首先声明,我们并非建议去除 E2E 测试。E2E 是唯一能够让您对整个应用程序堆栈协同工作充满信心的途径。然而,通常会有数百甚至数千个组件测试来演练许多关键的应用程序状态。而根据我们的经验,由于测试速度较慢且容易出错,这种程度的 E2E 测试是不可行的。

A spectrum titled When to test RSCs, going from In dev to In CI. Component tests in Storybook span the spectrum. End-to-end tests in Playwright only spans the end near In CI.

RSCs 和测试差距

React Server Components (RSCs) 改变了 React 应用程序的编写方式,引入了新的构造,以换取更高的性能和安全性。现在还处于早期阶段,但随着 Next.js 团队的持续工作以及 React 19 即将面向其他框架推出,RSCs 注定会成为一个重要的发展方向。

现在是时候开始弄清楚最佳实践了。

关于 RSCs 的最大问题之一是如何测试它们。 RSCs 在服务器上运行,可以直接引用 Node 代码,但它们也包含仅在浏览器中渲染的客户端组件。由于这种复杂性,React 核心团队建议将端到端 (E2E) 测试作为测试 RSCs 的主要方式。E2E 测试演练整个系统,并且不关心实现细节,因此它们是测试用户实际体验的好方法。然而,它们也很难设置、速度慢且容易出错。

因此,认真的测试人员会结合使用 E2E 测试来测试应用程序的顺畅路径,并使用单元/集成测试来覆盖更多状态。但到目前为止,还没有针对 RSCs 的单元/集成方法,这在测试策略中留下了一个主要的空白。

这就是 Storybook 的用武之地。在这篇文章中,我们介绍了用于 RSCs 的组件测试:在浏览器中运行的小型、自包含的集成测试,可以演练服务器和客户端代码。您可以模拟各种各样的测试用例,并快速且无错误地执行它们。

A trophy labeled with different kinds of tests. Component tests make up the bulk of the trophy.
组件测试在您的测试策略中的位置

组件测试 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 函数中“安排、执行、断言”所有操作

  1. 安排: (a) 初始化 count,(b) mount(...),渲染 story。
  2. 执行: 点击按钮
  3. 断言: 点击按钮后,验证 (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 进行身份验证。

当用户注销时,他们可以查看和搜索笔记

View and searching notes while logged out

登录后,他们还可以添加/编辑/删除笔记

Adding, editing, then deleting a note

我们将 Storybook 组织成两个部分:App 和 Components。App 镜像应用程序的路由/文件夹结构,而 Components 目前是应用程序内部组件的扁平列表。对于更完整的应用程序,这些组件将被组织到子文件夹中

Storybook with expanded subfolders in the sidebar

使用内存数据库进行模拟

数据库是许多 Web 应用程序的核心,Notes 演示应用也不例外。我们使用 Prisma 数据库来存储笔记。使用内存数据库对其进行模拟使我们能够控制渲染到屏幕上的笔记,并拦截何时添加、删除和更新笔记。

这非常棒,原因有很多:

  1. 速度。 我们可以立即将应用程序传送到任何状态并从那里进行测试,从而消除移动部件。更少的网络请求和移动部件意味着这些测试比其 E2E 等效测试快得多,并且错误也少得多。
  2. 覆盖率。 我们在一组集成测试中获得了前端和后端代码的测试覆盖率,因为我们在后端的最后端进行了模拟。这比必须跳过重重障碍来衡量 E2E 运行期间前端和后端的覆盖率,然后在事后将这些报告拼接在一起要容易得多。
  3. 隔离性。 由于数据库是内存数据库,这意味着每个测试都有效地获得了自己的数据库,您永远不必担心不同的测试会覆盖彼此的数据。如果您有针对固定数据库运行的 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,因此您在编写测试时也会获得视觉反馈。

0:00
/0:13
👋
抢先体验 Storybook Test。我们的抢先体验计划包括 Storybook 维护人员的实践帮助、独家活动以及访问用户社区,以帮助为您的项目构建 UI 测试套件。
👉 在此处注册!

立即试用

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 中可用。此外,我们还在努力开发:

  1. 零配置代码覆盖率,用于量化您在开发时进行的测试
  2. 焦点测试,用于快速测试单个 story、组件或目录
  3. 模拟功能,以形式化和可视化我们在此处展示的一些模式

有关我们正在考虑和积极开展的项目的概述,请查看 Storybook 的路线图

加入 Storybook 邮件列表

获取最新的新闻、更新和发布信息

6,730位开发者和计数中

我们正在招聘!

加入 Storybook 和 Chromatic 背后的团队。构建被成千上万开发者在生产环境中使用的工具。远程优先。

查看职位

热门文章

Storybook 标签

组织您的组件和 story,以匹配您的工作方式
loading
Michael Shilman

Storybook 8.5

触手可及的无障碍功能
loading
Michael Shilman

Storybook 8.4

一键在浏览器中进行组件测试
loading
Michael Shilman
加入社区
6,730位开发者和计数中
为什么为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与进来博客
案例展示探索项目组件词汇表
开源软件
Storybook - Storybook 中文

特别鸣谢 Netlify CircleCI