返回博客

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 测试取得了许多进展,但仍存在一些实际限制,使得对 UI 的各个方面进行 E2E 测试变得具有挑战性。

挑战包括

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

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

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

  • E2E 测试可以覆盖应用中的少量正常流程
  • 组件测试可以覆盖广泛的其他重要 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 测试

通过此应用的正常流程始于主页,导航到一家餐厅,将商品添加到购物车,然后结账。

0:00
/0:15

我们使用 Playwright 实现了此流程,并使用 Chromatic 对每一步进行视觉测试。测试导航到每个页面,并在每个状态下获取 UI 的视觉快照,以确保页面正确渲染。它还在过程中对 DOM 进行了一些关键断言。为了简洁起见,下方是精简后的测试;完整的测试可在 Mealdrop 仓库中找到。

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% 的测试覆盖率。

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

立即尝试

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

npx storybook@latest init

或升级现有项目

npx storybook@latest upgrade

本文所示的完整示例,请参见Mealdrop 仓库。要了解更多信息,请参阅 Storybook 的组件测试文档

下一步是什么?

端到端 (E2E) 测试功能强大,因为它们精确地测试用户看到的应用程序。但由于测试不稳定、执行速度慢以及其他实际考虑,编写和维护覆盖复杂应用中所有关键 UI 状态所需的大量 E2E 测试具有挑战性。组件测试提供了完美的补充,这就是为什么我们全力以赴将 Storybook 打造成组件测试的主力军。

在接下来的几个月里,我们将在 Storybook 中推出各种 UI 测试改进。这些变化包括

  1. 使 Storybook 的 Story 格式与其他测试工具保持一致。
  2. 与 Vitest 合作实现闪电般的测试执行速度。
  3. 从 Storybook 的 UI 中运行测试并查看结果的功能。
  4. 在单次运行中组合多种类型的测试,包括功能测试、视觉测试、无障碍测试等。
  5. 一种独特的方式,可以在开发环境和 CI 中无缝调试测试失败。

要概览我们正在考虑和积极进行的项目,请查看 Storybook 的路线图

加入 Storybook 邮件列表

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

7,180开发者,并且还在增加

我们正在招聘!

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

查看职位

热门文章

Storybook 8.3

极速组件测试
loading
Michael Shilman

React Native Storybook 8

React Native 回归!
loading
Michael Shilman

Storybook 8.2

迈向毫不妥协的组件测试
loading
Michael Shilman
加入社区
7,180开发者,并且还在增加
为何使用为何选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与贡献博客
展示探索项目组件词汇表
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI