返回博客

Storybook 中的组件测试

UI 测试的未来

loading
Michael Shilman
@mshilman
最后更新

过去十年,Web UI 技术突飞猛进。尽管如此,在 2024 年构建/维护生产级 UI 比以往任何时候都更加困难。

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

许多团队希望对 UI 进行测试覆盖率以捕获回归,但他们无法承担维护大型端到端测试套件的成本(我们将在下面详细探讨)。与此同时,他们通常拥有数千个单元测试,但这些测试在 Node 中使用模拟浏览器环境运行,并不能让他们对 UI 产生太大的信心。

在一次又一次地看到相同的模式后,我们正在押注组件测试是 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 等工具也会渲染和测试组件,但在实际浏览器中进行。这些测试就是我们定义的组件测试。

因此,组件测试

  • 在浏览器中渲染组件以实现高保真度
  • 像 E2E 测试一样模拟用户与实际 UI 的交互
  • 仅测试 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

我们使用 Chromatic 在 Playwright 中实现了这个流程,对沿途的每个步骤进行视觉测试。该测试导航到每个页面,并在每个状态下拍摄 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('https://: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!');
});

这个单一的测试涵盖了单个流程中的各种状态,模拟了用户在应用程序中的实际体验。在配备 16GB 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 个组件中有很大一部分的测试覆盖率达到了 100%,基于上面展示的 story。

此外,这些测试运行得非常快。在配备 16GB RAM 的 2021 MacBook M1 Pro 上,浏览器中运行的 89 个测试的整个套件需要 8-10 秒,这几乎与上面运行单个 E2E 测试所需的时间差不多。

立即试用

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

npx storybook@latest init

或升级现有项目

npx storybook@latest upgrade

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

下一步是什么?

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

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

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

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

加入 Storybook 邮件列表

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

7,468开发者及更多

我们正在招聘!

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

查看职位

热门帖子

Storybook 8.3

超快的组件测试
loading
Michael Shilman

React Native Storybook 8

React Native 回归了!
loading
Michael Shilman

Storybook 8.2

迈向无妥协的组件测试
loading
Michael Shilman
加入社区
7,468开发者及更多
原因为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与进来博客
展示探索项目组件词汇表
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI