返回博客

Storybook 中的组件测试

UI 测试的未来

loading
Michael Shilman
@mshilman
最后更新

在过去的十年中,Web UI 技术飞速发展。尽管如此,在 2024 年构建/维护生产 UI 仍然比以往任何时候都更加困难。

在 Storybook,我们与全球数千个顶级的 UI 团队合作,包括 Microsoft、Supabase 和 JPMorganChase 等公司。无论团队规模大小,或者最终结果多么完美,我们都看到他们在管理复杂前端开发时面临类似的挑战。

许多团队希望他们的 UI 具有测试覆盖率以捕获回归,但他们无法承担维护大型端到端测试套件的成本(我们将在下面更详细地探讨)。同时,他们通常有数千个单元测试,但这并没有给他们带来多少 UI 信心,因为这些测试是在 Node 中使用模拟浏览器环境运行的。

在一次又一次地看到相同的模式之后,我们押注组件测试是 UI 测试的未来。

组件测试在浏览器中渲染 UI 组件,使其独立于应用程序的其余部分。它还可以与组件交互并进行断言。

组件测试在 UI 测试中找到了一个最佳点,它提供了端到端风格的浏览器保真度,同时兼具单元测试的速度、可靠性和紧凑性。

组件测试不是端到端测试或单元测试的替代品,而是一种完美的补充。请继续阅读以了解更多关于组件测试的信息,它如何融入更广泛的测试领域,以及为什么我们认为它非常适合您的大部分 UI 测试。

什么是组件测试?

如果您在过去的十年中一直在 JavaScript 生态系统中构建,您可能已经见过像这样的单元测试(由 Testing Library 提供)

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Fetch from './fetch';

it('loads and displays greeting', async () => {
  // ARRANGE
  render(<Fetch url="/greeting" />);

  // ACT
  await userEvent.click(
    screen.getByText('Load Greeting')
  );
  await screen.findByRole('heading');

  // ASSERT
  expect(screen.getByRole('heading'))
    .toHaveTextContent('hello there');
  expect(screen.getByRole('button')).toBeDisabled();
});

此测试渲染一个名为 Fetch 的组件,通过 DOM 与其交互,然后根据该交互断言 DOM 的更改。

它看起来有点像端到端 (E2E) 测试,因为它模拟用户与某些应用程序 UI 的交互。组件可以小到一个按钮,也可以大到整个应用程序页面,而且您在树中向上移动得越高,它就越像 E2E。

但它不是 E2E,因为它是在隔离于应用程序其余部分的情况下测试单个组件。这种差异既是组件测试的优势,也是劣势,这取决于您想要测试什么,我们将在下面看到。

此外,此示例 (Jest + Testing Library) 在 Node 中运行,基于 JSDom 等 DOM 模拟层之上。因为它仅在浏览器模拟中运行,所以在 JSDom 中通过的测试在现实世界场景中可能会失败,反之亦然。

像 Storybook、Vitest、Playwright、Cypress、Webdriver 和 Nightwatch 这样的工具也会渲染和测试组件,但在实际浏览器中进行。这些测试是我们定义的组件测试。

因此,组件测试

  • 在浏览器中渲染组件以获得高保真度
  • 模拟用户与实际 UI 的交互,就像 E2E 测试一样
  • 仅测试 UI 的一个单元(例如,单个组件),并且可以深入到实现中来模拟事物或操作数据,就像单元测试一样

组件测试:完美的补充

正如我们在上面看到的,组件测试同时具有单元测试和 E2E 测试的元素。但是为什么组件测试有用?您应该在什么时候使用它们?

让我们从一个简单的声明开始

E2E 测试是保真度最高的测试,因为它们测试的正是用户在使用您的应用程序时将看到的内容。

在没有任何其他考虑因素的情况下,如果您可以使用 E2E 测试来测试 UI 的特定功能,那么您应该这样做。E2E 测试让您最有信心一切都按预期协同工作。

但是,如果 E2E 如此出色(确实如此!),为什么许多团队却很少使用它们呢?

问题在于“其他考虑因素”。尽管 E2E 测试取得了很大进展,但仍然存在一些实际限制,使得 E2E 测试 UI 的每个方面都具有挑战性。

挑战包括

  • 较慢的测试运行,容易出现不稳定性
  • 许多“难以到达”的状态
  • 设置和测试后端的开销
  • 黑盒环境只能从外部进行操作

所有这些挑战都可以通过组件测试来解决,但代价是不测试整个系统。

这使得这两种技术成为完美的补充

  • E2E 测试可以覆盖应用程序中的少量 happy path
  • 组件测试可以覆盖更广泛的其他重要 UI 状态

而这正是我们认为应该如何测试 UI 的方式。

An illustration showing a "happy path" of critical components and all of the other variations and other components surrounding them

Mealdrop 示例应用程序

为了使这个命题具体化,让我们考虑 Mealdrop,一个实现食品配送服务的示例项目

Screenshot of the MealDrop app homepage, showing three restaurant cards, the first of which is called Burger Kingdom

E2E 测试

此应用程序的 happy path 从主页开始,导航到餐厅,将商品添加到购物车,然后结账。

0:00
/0:15

我们已在 Playwright 中实现了此流程,并使用 Chromatic 对沿途的每个步骤进行可视化测试。该测试导航到每个页面,并在每个状态下拍摄 UI 的可视化快照,以确保页面正确渲染。它还在沿途对 DOM 进行了一些关键断言。为了简洁起见,测试在下面进行了压缩;完整测试可在 Mealdrop repo 中找到。

import { test, expect } from '@playwright/test';

test('should complete the full user journey from home to success page', async ({ page }) => {
  await page.goto('http://localhost:3000');

  // Navigate to Restaurants page
  await page.getByText('View all restaurants').click();

  // Select "Burgers" category
  await page.getByText('Burgers').click();

  // Select the first restaurant from the list
  const restaurantCards = await page.getAllByTestId('restaurant-card');
  await restaurantCards.first().click();

  // Add Cheeseburger to the cart
  const foodItem = await page.getByText(/Cheeseburger/i);
  await foodItem.click();

  // Go to "Checkout" page
  await page.getByText(/checkout/i).click();

  // Fill in order details...

  // Complete the order
  await page.getByRole('button', { name: 'Complete order' }).click();

  await expect(page.locator('h1')).toContainText('Order confirmed!');
});

这个单一测试在一个流程中涵盖了各种状态,模拟了用户在应用程序中的实际体验。在配备 16G RAM 的 2021 MacBook M1 Pro 上运行需要 5-6 秒。

但是,还有各种各样的状态未被覆盖,例如加载和错误状态、表单验证检查等等。为了覆盖它们,我们可以添加更多 E2E 测试,这些测试采用不同的应用程序路径并有意触发我们遗漏的状态。但是,根据我们上面的论点,我们选择使用组件测试来覆盖这些状态。

组件测试

为了覆盖遗漏的状态,我们使用 Storybook 进行组件测试。每个 story 都是一个小代码片段,用于将组件配置为关键 UI 状态。让我们考虑 Mealdrop 的 RestaurantDetailPage 组件的几个 story

最简单的 story,Success,几乎不像一个测试。它通过使用 Mock Service Worker 模拟 RestaurantDetailPage 组件使用的数据并验证组件是否成功渲染来执行冒烟测试

// RestaurantDetailPage.stories.tsx
import { Meta, StoryObj } from '@storybook/react';
import { http, HttpResponse } from 'msw';
import { expect } from '@storybook/test';

import { BASE_URL } from '../../api';
import { restaurants } from '../../stub/restaurants';
import { RestaurantDetailPage } from './RestaurantDetailPage';

const meta = {
  component: RestaurantDetailPage,
  // All stories render the component and a spot to render the modal
  render: () => {
    return (
      <>
        <RestaurantDetailPage />
        <div id="modal" />
      </>
    );
  },
} satisfies Meta<typeof RestaurantDetailPage>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Success = {
  parameters: {
    // Mock data dependency
    msw: {
      handlers: [
        http.get(BASE_URL, () => HttpResponse.json(restaurants[0])),
      ],
    },
  },
} satisfies Story;
Screenshot of Success story of the RestaurantDetailPage in Storybook

但是 story 也可以使用 play 函数与浏览器交互并断言其内容。例如,WithModalOpen 单击餐厅的菜单项之一,并验证结果模态框是否在 DOM 中存在

// RestaurantDetailPage.stories.tsx

export const WithModalOpen = {
  ...Success,
  play: async ({ canvas, userEvent }) => {
    const item = await canvas.findByText(/Cheeseburger/i);
    await userEvent.click(item);
    await expect(canvas.getByTestId('modal')).toBeInTheDocument();
  },
} satisfies Story;
Screenshot of With Modal Open story of the RestaurantDetailPage in Storybook, you can see the scripted interactions in a list, each with a green checkmark

最后,我们可以模拟网络请求来模拟访问错误,例如这个 404 NotFound story

// RestaurantDetailPage.stories.tsx

export const NotFound = {
  parameters: {
    msw: {
      handlers: [
        // Mock a 404 response
        http.get(BASE_URL, () => HttpResponse.json(null, { status: 404 })),
      ],
    },
  },
  play: async ({ canvas }) => {
    const item = await canvas.findByText(/We can't find this page/i);
    await expect(item).toBeInTheDocument();
  },
} satisfies Story;
Screenshot of Not Found story of the RestaurantDetailPage in Storybook, you can see that the 404 page rendered

与将整个应用程序作为黑盒交互的 E2E 测试不同,组件测试可以自由地模拟或监视堆栈的任何级别,只要作者认为合适。

因此,可以达到任何 UI 状态——这在 E2E 测试中可能非常具有挑战性。Mealdrop 的 45 个组件中的大多数都基于如上所示的 story 实现了 100% 的测试覆盖率。

此外,这些测试运行速度非常快。在配备 16G RAM 的 2021 MacBook M1 Pro 上,整个 89 个测试套件在浏览器中运行仅需 8-10 秒,这仅仅比运行上面的单个 E2E 测试稍长一点。

立即尝试

Storybook 8.2 支持组件测试。在新项目中尝试一下

npx storybook@latest init

或者升级现有项目

npx storybook@latest upgrade

对于本文中显示的完整示例,请参阅 Mealdrop repo。要了解更多信息,请参阅 Storybook 的组件测试文档

下一步是什么?

端到端 (E2E) 测试功能强大,因为它们完全按照用户看到的方式测试您的应用程序。但是,由于测试不稳定、执行速度和其他实际考虑因素,编写和维护大量必要的 E2E 测试来覆盖复杂应用程序中的所有关键 UI 状态具有挑战性。组件测试提供了完美的补充,这就是为什么我们全力以赴将 Storybook 变成组件测试强者的原因。

在接下来的几个月中,我们将在 Storybook 中发布各种 UI 测试改进。这些更改包括

  1. 使 Storybook 的 story 格式与其他测试工具达到同等水平。
  2. 与 Vitest 合作实现闪电般的快速测试执行。
  3. 能够从 Storybook 的 UI 运行测试并查看结果。
  4. 在单次运行中结合多种类型的测试,包括功能测试、可视化测试、a11y 测试等。
  5. 一种在开发和 CI 中无缝调试测试失败的独特方法。

要了解我们正在考虑和积极开展的项目的概览,请查看 Storybook 的路线图

加入 Storybook 邮件列表

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

6,730位开发者,持续增加中

我们正在招聘!

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

查看职位

热门文章

Storybook 8.3

闪电般快速的组件测试
loading
Michael Shilman

React Native Storybook 8

React Native 回归了!
loading
Michael Shilman

Storybook 8.2

迈向毫不妥协的组件测试
loading
Michael Shilman
加入社区
6,730位开发者,持续增加中
为什么为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与进来博客
展示探索项目组件术语表
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI