本編はこちらです。
Storybook というフレームワークを導入すると、作成した UI コンポーネントをカタログのように管理することができます。
UI コンポーネントをカタログ化することで再利用が促進されますし、例えばアプリを操作して特定の手順を踏まないと表示されないような UI コンポーネントも簡単に確認できるようになります。
また、カタログ化された各コンポーネントのスクリーンショットを記録することができるため、視覚情報としての差分検出も可能です。
カタログにはコンポーネント毎に登録を行う必要がありますが、必要な作業は MDX (Markdown と JSX を組み合わせて書ける) ファイルの作成だけで特別な操作は不要ですし、本記事では MDX の雛形をコードスニペット化してしまいますので本当に簡単に作成できます。
本題に入る前に
せっかくカタログ化を行うので、ここで下記のようなフォルダ階層[^を導入してみます。ついでに新しく Message コンポーネントも用意してしまいます。
-
[src]
-
[client]
-
[pages]
- Home.tsx
- Home.spec.tsx
- HomeWork.tsx
- HomeWork.spec.tsx
-
[parts]
- Message.tsx
- Message.spec.tsx
- App.tsx
- App.spec.tsx
- App.styl
- favicon.ico
- index.html
- index.tsx
-
[pages]
-
[client]
pages
フォルダと parts
フォルダを作成し、Home.* と HomeWork.* を pages
フォルダに移動してください。
App.tsx
と App.spec.tsx
は下記のようにリンクを修正します。
import * as React from "react";
import { Switch, Route, Link } from "react-router-dom";
import * as styles from "./App.styl";
import Home from "./pages/Home";
import HomeWork from "./pages/HomeWork";
const App = () => {
return (
<>
<Link className={styles.menuButton} to="/">Home</Link>
<Link className={styles.menuButton} to="/homework">Home Work</Link>
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/homework" component={HomeWork} />
</Switch>
</>
);
};
export default App;
import * as React from "react";
import { MemoryRouter } from "react-router-dom";
import { render, cleanup, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
const homeMock = jest.fn(() => <></>);
jest.mock("./pages/Home", () => ({ __esModule: true, default: homeMock } as any));
const homeworkMock = jest.fn(() => <></>);
jest.mock("./pages/HomeWork", () => ({ __esModule: true, default: homeworkMock } as any));
import App from "./App";
afterEach(cleanup);
afterEach(jest.clearAllMocks);
afterAll(() => {
jest.unmock("./pages/Home");
jest.unmock("./pages/HomeWork");
});
describe("App", () => {
it("最初に Home が表示されること", () => {
render(<MemoryRouter><App /></MemoryRouter>);
expect(homeMock).toBeCalled();
});
it("メニューから Home を表示できること", () => {
const root = render(<MemoryRouter><App /></MemoryRouter>);
expect(homeMock).toBeCalled();
homeMock.mockClear();
const menuHomeButton = root.getByText("Home", { exact: true });
fireEvent.click(menuHomeButton);
expect(homeMock).toBeCalled();
});
it("メニューから Home Work を表示できること", () => {
const root = render(<MemoryRouter><App /></MemoryRouter>);
expect(homeworkMock).not.toBeCalled();
const menuHomeWorkButton = root.getByText("Home Work", { exact: true });
fireEvent.click(menuHomeWorkButton);
expect(homeworkMock).toBeCalled();
});
});
Home.tsx と HomeWork.tsx は下記のように Message コンポーネントを使用するよう修正します。
import * as React from "react";
import Message from "../parts/Message";
const Home = () => {
return (
<>
<Message message="Hello World." />
</>
);
};
export default Home;
import * as React from "react";
import { useState, useEffect } from "react";
import Message from "../parts/Message";
const HomeWork = () => {
const [message, setMessage] = useState("Loading...");
useEffect(() => {
fetch("/api/hello")
.then(response => response.json())
.then(json => setMessage(json.message));
}, []);
return (
<>
<Message message={message} />
</>
);
};
export default HomeWork;
Message.tsx と Message.spec.tsx は下記の内容で作成してください。
import * as React from "react";
interface MessageProps {
/** メッセージテキスト。 */
message: string;
}
const Message = (props: MessageProps) => {
return (
<h1>{props.message}</h1>
);
};
export default Message;
import * as React from "react";
import { render, cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import Message from "./Message";
afterEach(cleanup);
afterEach(jest.clearAllMocks);
describe("Message", () => {
it("メッセージが表示されること", () => {
const root = render(<Message message="Hello." />);
const heading = root.getByRole("heading");
expect(heading).toHaveTextContent("Hello.");
});
});
準備が整いました。
ここからが本題となります。
開発コンテナの設定変更
Dockerfile
スナップショットはヘッドレス Chrome (GUI としての表示を行わずにバックグラウンドで Chrome を起動するモード) を使用して記録されます。
Chrome をインストールするよう Dockerfile を変更しておきます。
.devcontainer/Dockerfile
ファイルを下記の内容に書き換えてください。
FROM node:12.16-buster-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
#
# Verify git, process tools installed
&& apt-get -y install --no-install-recommends git openssh-client iproute2 procps \
#
#####
# https://github.com/Microsoft/vscode-dev-containers/tree/master/containers/docker-in-docker#how-it-works--adapting-your-existing-dev-container-config
# Note that no recommended packages are required, except for gnupg-agent.
#
# Install Docker CE CLI
&& apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl software-properties-common lsb-release jq \
&& apt-get install -y gnupg-agent \
&& curl -fsSL https://download.docker.com/linux/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/gpg | apt-key add - 2>/dev/null \
&& add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/$(lsb_release -is | tr '[:upper:]' '[:lower:]') $(lsb_release -cs) stable" \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli \
#
# Install Docker Compose
&& curl -sSL "https://github.com/docker/compose/releases/download/$(curl https://api.github.com/repos/docker/compose/releases/latest | jq .name -r)/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose \
&& chmod +x /usr/local/bin/docker-compose \
#
#####
# Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others)
# Note: this installs the necessary libs to make the bundled version of Chromium that Puppeteer
# installs, work.
&& curl -fsSL https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
&& sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
&& apt-get update \
&& apt-get install -y google-chrome-unstable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf --no-install-recommends \
#
#####
# Clean up
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
ENV DEBIAN_FRONTEND=dialog
devcontainer.json
Storybook は Web アプリケーションです。ポート6006を使用するのでポートフォワーディング設定を追加します。
.devcontainer/devcontainer.json
ファイルの forwardPorts
に下記を追加してください。
6006, // Storybook server
MDX 形式ファイル用の拡張機能 (シンタックスハイライトのみですが) も追加します。
.devcontainer/devcontainer.json
ファイルの extensions
に下記を追加してください。
"silvenon.mdx",
リビルド
開発コンテナの設定を変えましたので、一度リビルド を行ってください。
パッケージの設定変更
Node モジュールのインストール
下記のコマンドにて、Storybook 関係のモジュールをインストールします。
npm i -D \
@storybook/cli \
@storybook/react \
@storybook/addons \
@storybook/addon-actions \
@storybook/addon-docs \
@storybook/addon-knobs \
@storybook/addon-links \
@storybook/addon-storyshots \
@storybook/addon-storyshots-puppeteer \
@storybook/addon-viewport \
react-docgen-typescript-loader \
@babel/core \
babel-loader \
puppeteer \
@types/puppeteer
スクリプトの変更
package.json
ファイルの scripts
を下記の内容に書き換えてください。(test
コマンドの変更及び test-snapshots
コマンド、accept-snapshots
コマンド、storybook
コマンドの追加。)
"scripts": {
"start": " export NODE_ENV=production && ts-node ./src/server/server.ts",
"start:dev": " export NODE_ENV=development && ts-node-dev --nolazy --inspect=9229 ./src/server/server.ts",
"build": " export NODE_ENV=production && webpack --config ./webpack.config.ts",
"build:dev": " export NODE_ENV=development && webpack --config ./webpack.config.ts",
"run": " export NODE_ENV=production && npm run build && npm start",
"run:dev": " export NODE_ENV=development && npm run build:dev && npm run start:dev",
"run:hmr": " export NODE_ENV=development && export HMR=true && concurrently \"npm run start:dev\" \"webpack-dev-server --config ./webpack.config.hmr.ts\"",
"test": " export NODE_ENV=test && jest --coverage --testPathIgnorePatterns=src/client/storyshots/",
"test-snapshots": " export NODE_ENV=test && jest src/client/storyshots/",
"accept-snapshots": " export NODE_ENV=test && jest -u src/client/storyshots/",
"storybook": " export NODE_ENV=development && start-storybook -p 6006"
},
Git Ignore の変更
.gitignore
ファイルの末尾に下記を追加してください。(先頭ではなく末尾です。)
storybook-static/
__snapshots__/
__image_snapshots__/
!.vscode/*.code-snippets
Docker Ignore の変更
.dockerignore
ファイルに下記を追加してください。
**/storybook-static
**/__snapshots__
**/__image_snapshots__
Storybook の設定
基本設定 (main.ts)
.storybook/main.ts
ファイルを作成し、下記の内容で保存してください。
import { StorybookConfig } from "@storybook/core/types";
import myConfig from "../webpack.config";
module.exports = <StorybookConfig>{
stories: ["../src/client/**/*.stories.mdx"],
addons: [
"@storybook/addon-actions",
"@storybook/addon-docs",
"@storybook/addon-knobs",
"@storybook/addon-links",
"@storybook/addon-viewport",
],
webpackFinal: (config, options) => {
config.resolve = config.resolve ?? {};
config.resolve.extensions = config.resolve.extensions ?? [];
config.resolve.modules = config.resolve.modules ?? [];
config.module = config.module ?? { rules: [] };
return {
...config,
resolve: {
...config.resolve,
extensions: [
...config.resolve.extensions,
...myConfig.resolve!.extensions!,
],
modules: [
...config.resolve.modules,
...myConfig.resolve!.modules!,
],
},
module: {
...config.module,
rules: [
...config.module.rules,
myConfig.module!.rules!.find(rule => (rule.test as RegExp)?.source === /\.tsx?$/.source)!,
myConfig.module!.rules!.find(rule => (rule.test as RegExp)?.source === /\.styl$/.source)!,
],
},
performance: false,
};
}
};
リバースプロキシの設定 (middleware.ts)
.storybook/middleware.js
ファイルを作成し、下記の内容で保存してください。(.ts
ではなく .js
です。.ts
は非対応のようです。)
const proxy = require("http-proxy-middleware");
module.exports = router =>
router.use(
"^/api",
proxy({
target: "http://localhost:3000"
})
);
スナップショットテストの設定
Jest 設定
jest.config.json
ファイルを下記の内容に書き換えてください。
{
"preset": "ts-jest",
"modulePaths": [
"src"
],
"moduleNameMapper": {
"\\.(css|styl)$": "<rootDir>/node_modules/jest-css-modules"
},
"transform": {
"\\.mdx$": "@storybook/addon-docs/jest-transform-mdx",
"\\.jsx?$": [
"babel-jest",
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
]
}
]
},
"coveragePathIgnorePatterns": [
"/node_modules/",
"/\\.storybook/",
"\\.stories\\."
]
}
スナップショットテストの作成
src/client/storyshots/default.spec.js
ファイルを作成し、下記の内容で保存してください。
import initStoryshots from "@storybook/addon-storyshots";
import { imageSnapshot } from "@storybook/addon-storyshots-puppeteer";
initStoryshots({
suite: "Image storyshots",
test: imageSnapshot({
storybookUrl: "http://localhost:6006/",
beforeScreenshot: (page, options) => {
return new Promise(resolve => setTimeout(() => resolve(), 1000));
}
})
});
コードスニペットの設定
.vscode/storybook.code-snippets
ファイルを作成し、下記の内容で保存してください。
{
"Body": {
"scope": "markdown",
"prefix": "sb/body",
"body": [
"import { Meta, Story, Preview, Props } from \"@storybook/addon-docs/blocks\";",
"import * as knobs from \"@storybook/addon-knobs\";",
"import { action } from \"@storybook/addon-actions\";",
"import { linkTo } from \"@storybook/addon-links\";",
"import { MemoryRouter } from \"react-router-dom\";",
"import ${TM_FILENAME_BASE/\\..*//} from \"./${TM_FILENAME_BASE/\\..*//}\";",
"",
"",
"<Meta title=\"${TM_DIRECTORY/(.*)(?<=src\\/client)(\\/?)(.*)/$3$2/}${TM_FILENAME_BASE/\\..*//}\" component={${TM_FILENAME_BASE/\\..*//}} />",
"",
"",
"# ${TM_FILENAME_BASE/\\..*//}",
"",
"## 概要",
"",
"$1${TM_FILENAME_BASE/\\..*//} の概要。",
"",
"",
"## サンプル",
"",
"<Preview withSource=\"open\">",
" <Story name=\"default\">",
" <MemoryRouter>",
" <${TM_FILENAME_BASE/\\..*//} />",
" </MemoryRouter>",
" </Story>",
"</Preview>",
"",
"",
"## プロパティ",
"",
"<Props of={${TM_FILENAME_BASE/\\..*//}} />",
"",
]
},
"Preview": {
"scope": "markdown",
"prefix": "sb/preview",
"body": [
"<Preview withSource=\"open\">",
" <Story name=\"${1}\">",
" <MemoryRouter>",
" <${TM_FILENAME_BASE/\\..*//} />",
" </MemoryRouter>",
" </Story>",
"</Preview>",
]
},
"Text": {
"scope": "mdx",
"prefix": "sb/prop/text",
"body": [
"${1:name}={knobs.text(\"${1:name}\", \"${2:default value}\")}",
]
},
"Boolean": {
"scope": "mdx",
"prefix": "sb/prop/boolean",
"body": [
"${1:name}={knobs.boolean(\"${1:name}\", ${2|true,false|})}",
]
},
"Number": {
"scope": "mdx",
"prefix": "sb/prop/number",
"body": [
"${1:name}={knobs.number(\"${1:name}\", ${2:123})}",
]
},
"Slider": {
"scope": "mdx",
"prefix": "sb/prop/slider",
"body": [
"${1:name}={knobs.number(\"${1:name}\", ${2:123}, { range: true, min: ${3:0}, max: ${4:255}, step: ${5:1}})}",
]
},
"Color": {
"scope": "mdx",
"prefix": "sb/prop/color",
"body": [
"${1:name}={knobs.color(\"${1:name}\", \"${2:#ffffff}\")}",
]
},
"Object": {
"scope": "mdx",
"prefix": "sb/prop/object",
"body": [
"${1:name}={knobs.object(\"${1:name}\", ${2:{\\}})}",
]
},
"Array": {
"scope": "mdx",
"prefix": "sb/prop/array",
"body": [
"${1:name}={knobs.array(\"${1:name}\", ${2:[]}, \",\")}",
]
},
"Select box": {
"scope": "mdx",
"prefix": "sb/prop/select",
"body": [
"${1:name}={knobs.select(\"${1:name}\", ${2:{ Opt1: \"Option 1\", Opt2: \"Option 2\"\\}}, ${3:\"Option 1\"})}",
]
},
"Radio buttons": {
"scope": "mdx",
"prefix": "sb/prop/radios",
"body": [
"${1:name}={knobs.radios(\"${1:name}\", ${2:{ Opt1: \"Option 1\", Opt2: \"Option 2\"\\}}, ${3:\"Option 1\"})}",
]
},
"Options": {
"scope": "mdx",
"prefix": "sb/prop/options",
"body": [
"${1:name}={knobs.optionsKnob(\"${1:name}\", ${2:{ Opt1: \"Option 1\", Opt2: \"Option 2\"\\}}, ${3:\"Option 1\"}, { display: \"${4|radio,inline-radio,check,inline-check,select,multi-select|}\" })}",
]
},
"File": {
"scope": "mdx",
"prefix": "sb/prop/file",
"body": [
"${1:name}={knobs.files(\"${1:name}\", \"${2:.jpg, .png}\", [])}",
]
},
"Date": {
"scope": "mdx",
"prefix": "sb/prop/date",
"body": [
"${1:name}={knobs.date(\"${1:name}\", new Date(\"Jan 20 2017\"))}",
]
},
}
MDX の作成
カタログには全ての UI コンポーネントを登録します。
App コンポーネント
src/client
フォルダに App.stories.mdx
ファイルを作成してください。
Ctrl + Space を押して sb/body
を選択すると下記の内容が自動挿入されます。
import { Meta, Story, Preview, Props } from "@storybook/addon-docs/blocks";
import * as knobs from "@storybook/addon-knobs";
import { action } from "@storybook/addon-actions";
import { linkTo } from "@storybook/addon-links";
import { MemoryRouter } from "react-router-dom";
import App from "./App";
<Meta title="App" component={App} />
# App
## 概要
App の概要。
## サンプル
<Preview withSource="open">
<Story name="default">
<MemoryRouter>
<App />
</MemoryRouter>
</Story>
</Preview>
## プロパティ
<Props of={App} />
概要を "アプリケーション。" に変更しておきます。
## 概要
アプリケーション。
Home コンポーネント
src/client/pages
フォルダに Home.stories.mdx
ファイルを作成してください。
Ctrl + Space を押して sb/body
を選択すると下記の内容が自動挿入されます。
import { Meta, Story, Preview, Props } from "@storybook/addon-docs/blocks";
import * as knobs from "@storybook/addon-knobs";
import { action } from "@storybook/addon-actions";
import { linkTo } from "@storybook/addon-links";
import { MemoryRouter } from "react-router-dom";
import Home from "./Home";
<Meta title="pages/Home" component={Home} />
# Home
## 概要
Home の概要。
## サンプル
<Preview withSource="open">
<Story name="default">
<MemoryRouter>
<Home />
</MemoryRouter>
</Story>
</Preview>
## プロパティ
<Props of={Home} />
こちらも概要を "Home ページ。" に変更しておいてください。
HomeWork コンポーネント
src/client/pages
フォルダに HomeWork.stories.mdx
ファイルを作成してください。
Ctrl + Space を押して sb/body
を選択すると下記の内容が自動挿入されます。
import { Meta, Story, Preview, Props } from "@storybook/addon-docs/blocks";
import * as knobs from "@storybook/addon-knobs";
import { action } from "@storybook/addon-actions";
import { linkTo } from "@storybook/addon-links";
import { MemoryRouter } from "react-router-dom";
import HomeWork from "./HomeWork";
<Meta title="pages/HomeWork" component={HomeWork} />
# HomeWork
## 概要
HomeWork の概要。
## サンプル
<Preview withSource="open">
<Story name="default">
<MemoryRouter>
<HomeWork />
</MemoryRouter>
</Story>
</Preview>
## プロパティ
<Props of={HomeWork} />
概要を "HomeWork ページ。" に変更しておいてください。
Message コンポーネント
src/client/parts
フォルダに Message.stories.mdx
ファイルを作成してください。
Ctrl + Space を押して sb/body
を選択すると下記の内容が自動挿入されます。
import { Meta, Story, Preview, Props } from "@storybook/addon-docs/blocks";
import * as knobs from "@storybook/addon-knobs";
import { action } from "@storybook/addon-actions";
import { linkTo } from "@storybook/addon-links";
import { MemoryRouter } from "react-router-dom";
import Message from "./Message";
<Meta title="parts/Message" component={Message} />
# Message
## 概要
Message の概要。
## サンプル
<Preview withSource="open">
<Story name="default">
<MemoryRouter>
<Message />
</MemoryRouter>
</Story>
</Preview>
## プロパティ
<Props of={Message} />
Message コンポーネントは message
というプロパティを持っていて、このプロパティに設定されたテキストを UI 表示するようになっています。
このままですと message
プロパティに何も設定していないため、カタログには何のメッセージも表示されません。
ここでプロパティに適当なテキストを設定すれば、勿論カタログに反映されます。それでも良いのですが、本記事では Knobs というアドオンを利用し、カタログ上で自由にテキストを設定できるよう対応させます。
対応させるには、プロパティを設定する際に knobs.text
関数を通して値を渡せば良いのですが、これもコードスニペットを用意してあります。
<Message />
タグの中にカーソルを移動し、Ctrl + Space を押して sb/props/text
を選択してください。プロパティ名と初期値を入力する必要がありますので、プロパティ名を入力したら Tab キーを押してカーソルを移動させ初期値を入力します。
import { Meta, Story, Preview, Props } from "@storybook/addon-docs/blocks";
import * as knobs from "@storybook/addon-knobs";
import { action } from "@storybook/addon-actions";
import { linkTo } from "@storybook/addon-links";
import { MemoryRouter } from "react-router-dom";
import Message from "./Message";
<Meta title="parts/Message" component={Message} />
# Message
## 概要
Message パーツ。
## サンプル
<Preview withSource="open">
<Story name="default">
<MemoryRouter>
<Message message={knobs.text("message", "Hello.")} />
</MemoryRouter>
</Story>
</Preview>
## プロパティ
<Props of={Message} />
カタログの閲覧
Storybook の起動
Storybook を起動するには、Explorer サイドバーの NPM Scripts から、storybook を実行します。
Storybook も HMR (Hot Module Replacement) で動作するので、常時起動させておいて大丈夫です。
<注意>
HomeWork コンポーネントのようにサーバーサイドの API にアクセスするコンポーネントを正常に表示させるために、開発サーバーも起動 (run:hmr) させておく必要があります。
Storybook にアクセス
ブラウザで http://localhost:6006
にアクセスすると Storybook が開きます。
Message コンポーネントの message
プロパティも変更可能になっています。
ちなみにプロパティの説明文は JS Doc から自動で読み込まれます。
スナップショットテスト
テスト実行
スナップショットテストを実行するには、Explorer サイドバーの NPM Scripts から、test-snapshots を実行します。 (予め Storybook を起動しておく必要があります。)
スナップショットがまだ記録されていない場合は記録が行われます。
test-snapshots を実行しスナップショットを記録したら、試しに Message.tsx
を下記のように変更し、再び test-snapshots を実行してみてください。
import * as React from "react";
interface MessageProps {
/** メッセージテキスト。 */
message: string;
}
const Message = (props: MessageProps) => {
return (
<h1>Message: {props.message}</h1>
);
};
export default Message;
表示されるテキストが増えたため差分が発生したことが検出されます。
差分を記録した画像ファイルへのリンクが一緒に出力されますので、Ctrl を押しながらクリックして画像を開き、差分を確認します。
差分が無い部分は白色、差分がある部分は赤色で表示されます。
スナップショットの更新
全ての差分画像について、意図した通りのものであることを確認できたら、accept-snapshots を実行しスナップショットを更新します。
おわりに
如何でしたでしょうか。
一度環境構築してしまえば、後は UI コンポーネント作成と同時に MDX ファイルを用意していくだけで、カタログ化・スナップショットテストが行えます。
どちらも恩恵は非常に大きい上に、本記事のようにコードスニペットを使えば MDX ファイルの作成も非常に簡単に行えます。
プロジェクトの規模が大きくなるにつれ Storybook の重要性は大きくなりますので、是非 Storybook 導入してみてください。
変更履歴
- 2020/07/23:
<BrowserRouter>
ではカタログ内でHistory
が共有されてしまうため<MemoryRouter>
に変更。また、<MemoryRouter>
が常に配置されるようコードスニペットを変更。その他細かな修正。 - 2020/08/20:
package.json
内のtest
系スクリプト定義でNODE_ENV
にdevelopment
ではなくより適切なtest
を設定するよう変更。 - 2020/09/12:
main.ts
を、webpack.config
の設定を参照する形に変更。