返回部落格

Storybook 中的元件測試

UI 測試的未來

loading
Michael Shilman
@mshilman
上次更新

在過去十年中,網頁 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% 的測試覆蓋率。

此外,這些測試運行速度非常快。在配備 16G 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. 在單次運行中組合多種類型的測試,包括功能測試、視覺化測試、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