返回部落格

Storybook 中的型別安全模組模擬

一種新的、基於標準的模擬方法

loading
Jeppe Reinhold
@DrReinhold
loading
Kasper Peulen
@KasperPeulen

一致性對於隔離開發和測試 UI 至關重要。

理想情況下,無論何時何地、由誰查看您的 Storybook 故事,以及後端是否正常運作,它們都應始終呈現相同的 UI。故事的相同輸入應始終產生相同的輸出

當 UI 的唯一輸入是傳遞給元件的 props 時,這很簡單。如果您的元件依賴於來自 context providers 的資料,您可以透過使用 decorators 包裝您的故事來模擬它們。對於輸入是從網路取得的 UI,有非常流行的 Mock Service Worker 擴充功能,它可以確定性地模擬網路請求

但是,如果您的元件依賴於另一個來源,例如瀏覽器 API,像是讀取使用者的主題偏好設定、localStorage 中的資料或 cookies 呢?或者,如果您的元件根據目前的日期或時間而有不同的行為呢?或者,您的元件可能使用了像是 Next.js 的 next/router 之類的元框架 API 嗎?

在 Storybook 中,模擬這些類型的輸入在歷史上一直很困難。而這正是我們今天要透過 Storybook 8.1 中的模組模擬來解決的問題!我們的方法簡單、型別安全且基於標準。相較於不透明/專有的模組 API,它更傾向於明確性和除錯清晰度。而且我們有很好的夥伴:Epic Stack 創建者 Kent C. Dodd 建議使用類似的方法來處理絕對路徑導入,而 React Server Component 架構師 Seb Markbåge 直接啟發了 Storybook 模擬。

👉
注意:這項工作也讓我們能夠模擬僅限 Node 的程式碼,並在瀏覽器中測試 Storybook 中的 React Server Components (RSCs)。我們將在未來的部落格文章中分享更多相關資訊。敬請期待!

什麼是模組模擬?

模組模擬是一種技術,您可以使用一致的、獨立的替代方案來替換元件直接或間接導入的模組。在單元測試中,這可以幫助在可重現的狀態下測試程式碼。在 Storybook 中,這可以用於呈現和測試以有趣的方式檢索資料的元件。

例如,考慮一個使用者可配置的 Dashboard 元件,該元件允許使用者選擇要顯示哪些資訊,並將這些設定儲存在瀏覽器的本機儲存空間中

Network Monitoring Dashboard. Monitor the health and performance of your network. 3 cards. 1: Network Utilization. 72%. Arrow up 5%. 2: Bandwidth Usage. 250 Mbps. Arrow up 10%. 3: Device Uptime. 98.7%. Arrow up 0.5%.
儀表板元件

這實作為一個 settings 資料存取層,用於讀取和寫入使用者的設定到本機儲存空間,以及一個顯示元件 Dashboard,負責 UI

// lib/settings.ts
export const getDashboardLayout = () => {
  const layout = window.localStorage.getItem('dashboard.layout');
  return layout ? parseLayout(layout) : [];
};
// components/Dashboard.tsx
import { getDashboardLayout } from '../lib/settings.ts';

export const Dashboard = (props) => {
  const layout = getDashboardLayout();
  // logic to display layout
} 

為了測試 Dashboard 元件,我們想要建立一組不同版面配置的範例,以練習關鍵狀態。為了簡單起見,並且不失一般性,我們僅關注讀取版面配置的部分。

在整篇文章中,我們將以此作為一個持續運行的範例來說明模組模擬、我們如何實現它,以及我們的方法相較於其他實作的優勢。

現有方法:專有 API

流行的單元測試工具(如 Jest 和 Vitest)都提供了靈活的模組模擬機制。例如,它們會自動在相鄰的 mocks 目錄中尋找模擬檔案

// lib/__mocks__/settings.ts
export const getDashboardLayout = () => ([ /* dummy data here */ ]);

或者,它們提供命令式 API,以便在您的測試檔案中宣告模擬

// components/Dashboard.test.ts
import { vi, fn } from 'vitest';
import { getDashboardLayout } from '../lib/settings.ts';

vi.mock('../lib/settings.ts', () => ({
  getDashboardLayout: fn(() => ([ /* dummy data here */])),
});

這看起來像是一個簡單的 API,但在底層,這段程式碼實際上觸發了一個複雜、有些神奇的檔案轉換,以將導入替換為其模擬。因此,程式碼的微小變更可能會以令人困惑的方式破壞模擬。例如,以下變體會失敗

// components/Dashboard.test.ts
import { vi, fn } from 'vitest';
import { getDashboardLayout } from '../lib/settings.ts';

const dummyLayout = [ /* dummy data here */];
vi.mock('../lib/settings.ts', () => ({
  getDashboardLayout: fn(() => dummyLayout), // FAIL!!!
});

但我們的目標不是要批評這些優秀的工具。相反,我們希望探索如何使用新的、基於標準的方法來更好地進行模擬。

我們的方法:子路徑導入

Storybook 中的模組模擬利用子路徑導入標準,可透過 package.jsonimports 欄位(任何 JS 專案的核心)進行配置,作為在整個專案中導入模擬的管道。

就我們的目的而言,這種方法的超能力之一是,就像 package.json exports 一樣,package.json imports 可以是條件式的,根據執行環境改變導入路徑。這表示您可以客製化您的 package.json 以在 Storybook 中導入模擬模組,同時在其他地方導入真實模組!

子路徑導入首先在 Node.js 中引入,但現在也受到整個 JS 生態系統的支援,包括 TypeScript(自 5.4 版本起)、Webpack、Vite、Jest、Vitest 等。

繼續上面的範例,以下是如何模擬 ./lib/settings.ts 中的模組

{
  "imports": {
    "#lib/settings": {
      "storybook": "./lib/settings.mock.ts",
      "default": "./lib/settings.ts"
    },
    "#*": [ // fallback for non-mocked absolute imports
      "./*",
      "./*.ts",
      "./*.tsx"
    ]
  }
}

在這裡,我們指示模組解析器,所有從 #lib/settings 導入的內容在 Storybook 中應解析為 ../lib/settings.mock.ts,但在您的應用程式中應解析為 ../lib/settings.ts

這也需要修改您的元件,以從以 # 符號作為前綴的絕對路徑導入,根據 Node.js 規範,以確保路徑或套件導入沒有歧義。

// Dashboard.test.ts

- import { getDashboardLayout } from '../lib/settings';
+ import { getDashboardLayout } from '#lib/settings';

這看起來可能很麻煩,但它有一個好處,可以清楚地向閱讀檔案的開發人員傳達,模組可能會根據執行環境而有所不同。事實上,我們建議將此標準用於一般絕對路徑導入,因為它對於模擬來說非常棒(見下文)。

針對每個 Story 的模擬

使用子路徑導入,我們能夠使用基於標準的方法,將整個 settings.ts 檔案替換為一個新模組。但是,如果我們想要為每個測試(或者在我們的案例中,Storybook 故事)改變其實作,我們應該如何建構 settings.mock.ts 呢?

這是一個用於模擬任何模組的樣板結構。因為我們可以完全控制程式碼,所以我們可以修改它以適應任何特殊情況(例如,移除 Node 程式碼,使其不在瀏覽器中執行,反之亦然)。

// lib/settings.mock.ts
import { fn } from '@storybook/test';
import * as actual from './settings'; // 👈 Import the actual implementation

// 👇 Re-export the actual implementation.
// This catch-all ensures that the exports of the mock file always contains
// all the exports of the original. It is up to the user to override
// individual exports below as appropriate.
export * from './settings';

// 👇 Export a mock function whose default implementation is the actual implementation.
// With a useful mockName, it displays nicely in Storybook's Actions addon
// for debugging.
export const getDashboardLayout = fn(actual.getDashboardLayout)
  .mockName('settings::getDashboardLayout');

現在,每當導入 #lib/settings 時,Storybook 都將使用此模擬檔案。它沒有做太多事情,除了包裝實際實作——這才是重要的部分。

現在讓我們在 Storybook 故事中使用它

// components/Dashboard.stories.ts

import type { Meta, StoryObj } from '@storybook/react';
import { expect } from '@storybook/test';

// 👇 You can use subpaths as an absolute import convention even
// for non-conditional paths
import { Dashboard } from '#components/Dashboard';

// 👇 Import the mock file explicitly, as that will make
// TypeScript understand that these exports are the mock functions
import { getDashboardLayout } from '#lib/settings.mock'

const meta = {
  component: Dashboard,
} satisfies Meta<typeof Dashboard>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Empty: Story = {
  beforeEach: () => {
    // 👇 Mock return an empty layout
    getDashboardLayout.mockReturnValue([]);
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    // 👇 Expect the UI to prompt when the dashboard is empty
    await expect(canvas).toHaveTextContent('Configure your dashboard');
    // 👇 Assert directly on the mock function that it was called as expected
    expect(getDashboardLayout).toHaveBeenCalled();
  },
};

export const Row: Story = {
  beforeEach: () => {
    // 👇 Mock return a different, story-specific layout
    getDashboardLayout.mockReturnValue([ /* hard-coded "row" layout data */ ]);
  },
};

在 Storybook 中,使用模擬函式 fn 意味著

  1. 我們可以使用 Storybook 新的 beforeEach 鉤子來修改每個故事的行為
  2. Actions 面板現在將在每次呼叫函式時記錄
  3. 我們可以在 play 函式中斷言呼叫
需要斷言的不僅僅是文字?使用 Chromatic 的 Visual Tests 擴充功能,快速測試您的元件在任何狀態下的實際外觀,以在多個瀏覽器和視窗中捕捉 UI 錯誤。

我們方法的優勢

我們現在已經看到了基於子路徑導入 package.json 標準的 Storybook 中的端對端模組模擬範例。與 Jest 和 Vitest 採用的專有方法相比,這種方法是明確的、型別安全且基於標準的。

明確性

一些模擬框架背後的魔法可能會讓人難以理解模擬是如何以及何時應用的。例如,我們在上面看到,從 vi.mock 呼叫中引用外部定義的變數會導致模擬錯誤,即使它是有效的 JavaScript。

相較之下,由於所有模擬都明確定義在 package.json 中,我們的解決方案提供了一種清晰且可預測的方式來理解模組在不同環境中是如何解析的。這種透明度簡化了除錯,並使您的測試更具可預測性。

型別安全

模擬框架引入了開發人員需要熟悉的慣例、語法風格和特定 API。此外,這些解決方案通常缺乏對型別檢查的支援。

透過使用您現有的 package.json,我們的解決方案需要最少的設定。此外,它自然地與 TypeScript 整合,特別是自 TypeScript 現在支援 package.json 子路徑導入和自動完成功能(自 TypeScript 5.4,2024 年 3 月起)。

基於標準

最重要的是,由於 Storybook 的方法 100% 基於標準,這表示您可以在任何工具鏈或環境中使用您的模擬。

這很有用,因為您可以學習該標準,然後在任何地方重複使用該知識,而不必學習每個工具的模擬細節。例如,vi.mock 的用法與 Jest 的模擬相似,但不完全相同。

這也表示您可以將多個工具一起使用。例如,使用者通常會為其元件編寫故事,然後使用我們的 Portable Stories 功能在其他測試工具中重複使用這些故事。

此外,您可以在多個環境中使用這些模擬。例如,Storybook 的模擬在 Node 中「免費」運作,因為它們是 Node 標準的一部分,但由於該標準由 Webpack 和 Vite 實作,因此它們在使用其中一個建構工具的瀏覽器中也能正常運作。

最後,由於我們與 ESM 標準保持一致,因此確保我們的解決方案與未來的 JS 變更向前相容。我們押注於平台。我們相信這是模組模擬的未來,並且每個測試工具都應該採用它。

今天就試試看

模組模擬在 Storybook 8.1 中可用。在新專案中試試看

npx storybook@latest init

或升級現有專案

npx storybook@latest upgrade

若要了解更多關於模組模擬的資訊,請參閱 Storybook 文件,以取得更多範例和完整的 API。我們建立了一個使用我們的模組模擬方法測試的 Next.js React Server Components (RSC) 應用程式的完整示範。我們計劃在即將發布的部落格文章中進一步記錄這一點。

下一步

Storybook 的模組模擬功能完整且準備就緒。我們正在考慮以下增強功能

  1. 一個 CLI 工具,用於為給定模組自動生成模擬樣板
  2. 支援從 UI 可視化/編輯模擬資料

除了模組模擬之外,我們還致力於許多測試改進。例如,我們建立了一種在瀏覽器中單元測試 React Server Components 的新穎方法。我們正在努力使 Storybook 的測試更接近 Jest/Vitest 受 Jasmine 啟發的結構。

如需我們正在考慮和積極進行的專案概述,請查看 Storybook 的路線圖

加入 Storybook 郵件列表

獲取最新消息、更新和發布

6,730開發者持續增加中

我們正在招聘!

加入 Storybook 和 Chromatic 背後的團隊。建立被數十萬開發者用於生產環境的工具。遠端優先。

查看職位

熱門文章

互動式故事生成

無需離開瀏覽器,幾秒鐘內創建你的第一個故事!
loading
Valentin Palkovic

視覺化測試:UI 開發中最棒的技巧

以更少的維護獲得更多信心
loading
Michael Shilman

Storybook 8.1

更高效、更有條理且更可預測的 Storybook
loading
Michael Shilman
加入社群
6,730開發者持續增加中
為何為何選擇 Storybook元件驅動的 UI
文件指南教學更新日誌遙測
社群擴充功能參與部落格
展示探索專案元件詞彙表
開源軟體
Storybook - Storybook 繁體中文

特別感謝 Netlify CircleCI