預設情況下,測試執行器讓您可以選擇以最少的配置執行多種測試模式(例如,DOM 快照測試、無障礙功能)。但是,如果您願意,您可以擴展它以與其他測試一起執行視覺迴歸測試。例如
.storybook/test-runner.ts
import { TestRunnerConfig, waitForPageReady } from '@storybook/test-runner';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
const customSnapshotsDir = `${process.cwd()}/__snapshots__`;
const config: TestRunnerConfig = {
setup() {
expect.extend({ toMatchImageSnapshot });
},
async postVisit(page, context) {
// Waits for the page to be ready before taking a screenshot to ensure consistent results
await waitForPageReady(page);
// To capture a screenshot for for different browsers, add page.context().browser().browserType().name() to get the browser name to prefix the file name
const image = await page.screenshot();
expect(image).toMatchImageSnapshot({
customSnapshotsDir,
customSnapshotIdentifier: context.id,
});
},
};
export default config;
Storybook 提供了一個 composeStories
公用程式,可協助將 story 檔案中的 stories 轉換為可呈現的元素,這些元素可以在您的 Node 測試中與 JSDOM 重複使用。它還允許您套用在專案中啟用的其他 Storybook 功能(例如,裝飾器、參數),這可讓您的元件正確呈現。這就是所謂的可攜式 stories。
我們建議您關閉目前的 storyshots 測試,以開始遷移過程。為此,請將設定檔(即 storybook.test.ts|js
或類似檔案)重新命名為 storybook.test.ts|js.old
。這會防止偵測到測試,因為您將建立一個具有相同名稱的新測試設定檔。這樣做,您將能夠在遷移到可攜式 stories 之前保留現有的測試,然後再從專案中移除 Storyshots 附加元件。
如果您需要在測試中啟用 ./storybook/preview.js|ts
中包含的專案層級註解(例如,裝飾器、參數、樣式),請調整您的測試設定檔,以匯入註解,如下所示
import { beforeAll } from 'vitest';
// 👇 If you're using Next.js, import from @storybook/nextjs
// If you're using Next.js with Vite, import from @storybook/experimental-nextjs-vite
import { setProjectAnnotations } from '@storybook/react';
// 👇 Import the exported annotations, if any, from the addons you're using; otherwise remove this
import * as addonAnnotations from 'my-addon/preview';
import * as previewAnnotations from './.storybook/preview';
const annotations = setProjectAnnotations([previewAnnotations, addonAnnotations]);
// Run Storybook's beforeAll hook
beforeAll(annotations.beforeAll);
為了協助您從 Storyshots 附加元件遷移到 Storybook 的可攜式 stories,並使用 composeStories
輔助 API,我們準備了一些範例來協助您入門。下面列出的是兩個最受歡迎的測試框架的範例:Jest 和 Vitest。我們建議將程式碼放置在新建的 storybook.test.ts|js
檔案中,並根據您的測試框架調整程式碼。以下兩個範例都會
- 根據 glob 模式匯入所有 story 檔案
- 反覆查看這些檔案,並在其每個模組上使用
composeStories
,產生每個 story 的可呈現元件清單
- 循環瀏覽 stories、呈現它們並建立快照
如果您使用 Vitest 作為測試框架,您可以開始將快照測試遷移到 Storybook 的可攜式 stories,並使用 composeStories
輔助 API,方法是參考以下範例。您需要修改 storybook.test.ts|js
檔案中的程式碼,如下所示
// @vitest-environment jsdom
// Replace your-framework with one of the supported Storybook frameworks (react, vue3)
import type { Meta, StoryFn } from '@storybook/your-framework';
import { describe, expect, test } from 'vitest';
// Replace your-renderer with the renderer you are using (e.g., react, vue3, svelte, etc.)
import { composeStories } from '@storybook/your-renderer';
type StoryFile = {
default: Meta;
[name: string]: StoryFn | Meta;
};
const compose = (entry: StoryFile): ReturnType<typeof composeStories<StoryFile>> => {
try {
return composeStories(entry);
} catch (e) {
throw new Error(
`There was an issue composing stories for the module: ${JSON.stringify(entry)}, ${e}`
);
}
};
function getAllStoryFiles() {
// Place the glob you want to match your story files
const storyFiles = Object.entries(
import.meta.glob<StoryFile>('./stories/**/*.(stories|story).@(js|jsx|mjs|ts|tsx)', {
eager: true,
})
);
return storyFiles.map(([filePath, storyFile]) => {
const storyDir = path.dirname(filePath);
const componentName = path.basename(filePath).replace(/\.(stories|story)\.[^/.]+$/, '');
return { filePath, storyFile, componentName, storyDir };
});
}
// Recreate similar options to Storyshots. Place your configuration below
const options = {
suite: 'Storybook Tests',
storyKindRegex: /^.*?DontTest$/,
storyNameRegex: /UNSET/,
snapshotsDirName: '__snapshots__',
snapshotExtension: '.storyshot',
};
describe(options.suite, () => {
getAllStoryFiles().forEach(({ storyFile, componentName, storyDir }) => {
const meta = storyFile.default;
const title = meta.title || componentName;
if (options.storyKindRegex.test(title) || meta.parameters?.storyshots?.disable) {
// Skip component tests if they are disabled
return;
}
describe(title, () => {
const stories = Object.entries(compose(storyFile))
.map(([name, story]) => ({ name, story }))
.filter(({ name, story }) => {
// Implements a filtering mechanism to avoid running stories that are disabled via parameters or that match a specific regex mirroring the default behavior of Storyshots.
return !options.storyNameRegex?.test(name) && !story.parameters.storyshots?.disable;
});
if (stories.length <= 0) {
throw new Error(
`No stories found for this module: ${title}. Make sure there is at least one valid story for this module, without a disable parameter, or add parameters.storyshots.disable in the default export of this file.`
);
}
stories.forEach(({ name, story }) => {
// Instead of not running the test, you can create logic to skip it, flagging it accordingly in the test results.
const testFn = story.parameters.storyshots?.skip ? test.skip : test;
testFn(name, async () => {
await story.run();
// Ensures a consistent snapshot by waiting for the component to render by adding a delay of 1 ms before taking the snapshot.
await new Promise((resolve) => setTimeout(resolve, 1));
expect(document.body.firstChild).toMatchSnapshot();
});
});
});
});
});
當使用 Vitest 執行測試時,它會產生一個包含專案中所有 stories 的單一快照檔案(即 storybook.test.ts|js.snap
)。但是,如果您想要產生個別快照檔案,可以使用 Vitest 的 toMatchFileSnapshot
API。例如
// ...Code omitted for brevity
describe(options.suite, () => {
// 👇 Add storyDir in the arguments list
getAllStoryFiles().forEach(({ filePath, storyFile, storyDir }) => {
// ...Previously existing code
describe(title, () => {
// ...Previously existing code
stories.forEach(({ name, story }) => {
// ...Previously existing code
testFn(name, async () => {
await story.run();
// Ensures a consistent snapshot by waiting for the component to render by adding a delay of 1 ms before taking the snapshot.
await new Promise((resolve) => setTimeout(resolve, 1));
// 👇 Define the path to save the snapshot to:
const snapshotPath = path.join(
storyDir,
options.snapshotsDirName,
`${componentName}${options.snapshotExtension}`
);
expect(document.body.firstChild).toMatchFileSnapshot(snapshotPath);
});
});
});
});
});
如果您使用 Jest 作為測試框架,您可以開始將快照測試遷移到 Storybook 的可攜式 stories,並使用 composeStories
輔助 API,方法是參考以下範例。您需要修改 storybook.test.ts|js
檔案中的程式碼,如下所示
import path from 'path';
import * as glob from 'glob';
// Replace your-framework with one of the supported Storybook frameworks (react, vue3)
import type { Meta, StoryFn } from '@storybook/your-framework';
import { describe, test, expect } from '@jest/globals';
// Replace your-renderer with the renderer you are using (e.g., react, vue3, svelte, etc.)
import { composeStories } from '@storybook/your-renderer';
type StoryFile = {
default: Meta;
[name: string]: StoryFn | Meta;
};
const compose = (entry: StoryFile): ReturnType<typeof composeStories<StoryFile>> => {
try {
return composeStories(entry);
} catch (e) {
throw new Error(
`There was an issue composing stories for the module: ${JSON.stringify(entry)}, ${e}`
);
}
};
function getAllStoryFiles() {
// Place the glob you want to match your stories files
const storyFiles = glob.sync(
path.join(__dirname, 'stories/**/*.{stories,story}.{js,jsx,mjs,ts,tsx}')
);
return storyFiles.map((filePath) => {
const storyFile = require(filePath);
return { filePath, storyFile };
});
}
// Recreate similar options to Storyshots. Place your configuration below
const options = {
suite: 'Storybook Tests',
storyKindRegex: /^.*?DontTest$/,
storyNameRegex: /UNSET/,
snapshotsDirName: '__snapshots__',
snapshotExtension: '.storyshot',
};
describe(options.suite, () => {
getAllStoryFiles().forEach(({ storyFile, componentName }) => {
const meta = storyFile.default;
const title = meta.title || componentName;
if (options.storyKindRegex.test(title) || meta.parameters?.storyshots?.disable) {
// Skip component tests if they are disabled
return;
}
describe(title, () => {
const stories = Object.entries(compose(storyFile))
.map(([name, story]) => ({ name, story }))
.filter(({ name, story }) => {
// Implements a filtering mechanism to avoid running stories that are disabled via parameters or that match a specific regex mirroring the default behavior of Storyshots.
return !options.storyNameRegex.test(name) && !story.parameters.storyshots?.disable;
});
if (stories.length <= 0) {
throw new Error(
`No stories found for this module: ${title}. Make sure there is at least one valid story for this module, without a disable parameter, or add parameters.storyshots.disable in the default export of this file.`
);
}
stories.forEach(({ name, story }) => {
// Instead of not running the test, you can create logic to skip it, flagging it accordingly in the test results.
const testFn = story.parameters.storyshots?.skip ? test.skip : test;
testFn(name, async () => {
await story.run();
// Ensures a consistent snapshot by waiting for the component to render by adding a delay of 1 ms before taking the snapshot.
await new Promise((resolve) => setTimeout(resolve, 1));
expect(document.body.firstChild).toMatchSnapshot();
});
});
});
});
});
當使用 Jest 執行測試時,它會產生一個包含專案中所有 stories 的單一快照檔案(即 __snapshots__/storybook.test.ts|js.snap
)。但是,如果您想要產生個別快照檔案,可以使用 jest-specific-snapshot
套件。例如
// 👇 Augment expect with jest-specific-snapshot
import 'jest-specific-snapshot';
// ...Code omitted for brevity
describe(options.suite, () => {
//👇 Add storyDir in the arguments list
getAllStoryFiles().forEach(({ filePath, storyFile, storyDir }) => {
// ...Previously existing code
describe(title, () => {
// ...Previously existing code
stories.forEach(({ name, story }) => {
// ...Previously existing code
testFn(name, async () => {
await story.run();
// Ensures a consistent snapshot by waiting for the component to render by adding a delay of 1 ms before taking the snapshot.
await new Promise((resolve) => setTimeout(resolve, 1));
//👇 Define the path to save the snapshot to:
const snapshotPath = path.join(
storyDir,
options.snapshotsDirName,
`${componentName}${options.snapshotExtension}`
);
expect(document.body.firstChild).toMatchSpecificSnapshot(snapshotPath);
});
});
});
});
});
如果您選擇在測試中使用可攜式 stories,您將會有一個可以在 JSDOM 環境中執行的單一測試檔案,其中會呈現您所有的 stories 並建立快照。但是,隨著專案的成長,您可能會遇到之前使用 Storyshots 時遇到的限制
- 您不是針對真實的瀏覽器進行測試。
- 您必須模擬許多瀏覽器公用程式(例如,canvas、window API 等)。
- 您的除錯體驗不會那麼好,因為您無法在測試中存取瀏覽器。
或者,您可能會想考慮遷移到 Storybook 快照測試的其他可用選項:測試執行器,它是一種更健全的解決方案,可在真實的瀏覽器環境中使用 Playwright 執行測試。
由於使用 Storybook 和測試執行器執行快照測試可能會導致一些技術限制,而這些限制可能會阻止您成功設定或執行測試,因此我們準備了一組說明,以協助您排解可能遇到的任何問題。
如果您在使用測試執行器時遇到間歇性的測試失敗,則當您的測試在瀏覽器中執行時,可能會發生未捕獲的錯誤。如果您之前使用的是 Storyshots 附加元件,可能不會捕獲這些錯誤。預設情況下,測試執行器會將這些未捕獲的錯誤視為失敗的測試。但是,如果這些錯誤是預期的,您可以在您的 stories 和測試執行器設定檔中啟用自訂 story 標籤來忽略它們。如需更多資訊,請參閱 測試執行器文件。
如果您已將測試執行器設定為執行快照測試,您可能會注意到快照檔案的路徑和名稱與 Storyshots 附加元件先前產生的不同。這是因為測試執行器對快照檔案使用了不同的命名慣例。使用自訂快照解析器,您可以設定測試執行器使用您先前使用的相同命名慣例。
執行以下命令,為測試執行器產生一個自訂設定檔,您可以使用該設定檔來設定 Jest
npm run test-storybook -- --eject
更新檔案並啟用 snapshotResolver
選項以使用自訂快照解析器
./test-runner-jest.config.js
import { getJestConfig } from '@storybook/test-runner';
const defaultConfig = getJestConfig();
const config = {
// The default Jest configuration comes from @storybook/test-runner
...defaultConfig,
snapshotResolver: './snapshot-resolver.js',
};
export default config;
最後,建立一個 snapshot-resolver.js
檔案,以實作自訂快照解析器
import path from 'path';
export default {
resolveSnapshotPath: (testPath) => {
const fileName = path.basename(testPath);
const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '');
const modifiedFileName = `${fileNameWithoutExtension}.storyshot`;
// Configure Jest to generate snapshot files using the following naming convention (__snapshots__/Button.storyshot)
return path.join(path.dirname(testPath), '__snapshots__', modifiedFileName);
},
resolveTestPath: (snapshotFilePath, snapshotExtension) =>
path.basename(snapshotFilePath, snapshotExtension),
testPathForConsistencyCheck: 'example.storyshot',
};
預設情況下,測試執行器會使用 jest-serializer-html
來序列化 HTML 快照。即使您使用特定的 CSS-in-JS 函式庫,如 Emotion、Angular 的 ng
屬性,或其他類似的、會為 CSS 類別產生雜湊識別符的函式庫,這都可能導致格式與您現有的快照產生差異。然而,您可以設定測試執行器使用自訂快照序列化器來解決此問題,方法是將隨機類別名稱覆寫為每個測試執行都會相同的靜態名稱。
執行以下命令,為測試執行器產生一個自訂設定檔,您可以使用該檔案來提供額外的設定選項。
npm run test-storybook -- --eject
更新該檔案並啟用 snapshotSerializers
選項,以使用自訂快照解析器。例如:
./test-runner-jest.config.js
import { getJestConfig } from '@storybook/test-runner';
const defaultConfig = getJestConfig();
const config = {
...defaultConfig,
snapshotSerializers: [
// Sets up the custom serializer to preprocess the HTML before it's passed onto the test-runner
'./snapshot-serializer.js',
...defaultConfig.snapshotSerializers,
],
};
export default config;
最後,建立一個 snapshot-serializer.js
檔案來實作自訂快照序列化器
// The jest-serializer-html package is available as a dependency of the test-runner
const jestSerializerHtml = require('jest-serializer-html');
const DYNAMIC_ID_PATTERN = /"react-aria-\d+(\.\d+)?"/g;
module.exports = {
/*
* The test-runner calls the serialize function when the test reaches the expect(SomeHTMLElement).toMatchSnapshot().
* It will replace all dynamic IDs with a static ID so that the snapshot is consistent.
* For instance, from <label id="react-aria970235672-:rl:" for="react-aria970235672-:rk:">Favorite color</label> to <label id="react-mocked_id" for="react-mocked_id">Favorite color</label>
*/
serialize(val) {
const withFixedIds = val.replace(DYNAMIC_ID_PATTERN, 'mocked_id');
return jestSerializerHtml.print(withFixedIds);
},
test(val) {
return jestSerializerHtml.test(val);
},
};
當測試執行器執行您的測試時,它會檢視產生的 HTML,並在建立元件快照之前,使用正規表示式提供的靜態屬性來取代任何動態產生的屬性。