Storybook7

Installation

ChakraUI integration

Storybook7 addon
features: {
emotionAlias: false,
},
タイプエラーは起きるが一応動くには動く

Mock Api

msw
各ストーリ内部でparametersにmswプロパティを追加してそこにhandlersを定義できる。baseUrlはmsw側の設定で。
export const Default: Story = {
args: {
menuId: 1,
},
parameters: {
msw: {
handlers: [
rest.get(
"http://localhost:4114/api/appointment-menus/*/available-slots",
(req, res, ctx) => {
const now = DateTime.now();
const slots = [...Array(10)].map((_, i) => ({
day: now.plus({ days: i }).toISO(),
times: [...Array(10)].map((__, j) => ({
start: now.plus({ days: i, hours: j }).toISO(),
end: now.plus({ days: i, hours: j + 1 }).toISO(),
})),
}));
return res(ctx.json(slots));
}
),
],
},
},
};


MSW v1 -> v2

import type { StorybookConfig } from '@storybook/nextjs';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
'@chakra-ui/storybook-addon',
],
framework: {
name: '@storybook/nextjs',
options: {},
},
docs: {
autodocs: 'tag',
},
staticDirs: ['../public'], // mockServiceWorkerが存在するディレクトリ
};
export default config;

Style
global.cssとかを適用させたい場合はpreview.tsで読み込み
import "../src/styles/globals.css";

Hooks
StoryObjのrender関数でラッパーを追加したりuseFormを使ったりすれば動くが正しい設定なのか微妙、Eslintのルールをオフにせずコメントアウトしておいて正しい設定がわかったタイミングでアップデートする
const meta: Meta<typeof AppointmentForm> = {
title: "AppointmentForm",
component: AppointmentForm,
};
export default meta;
type Story = StoryObj<typeof AppointmentForm>;
export const Default: Story = {
args: {},
render: () => {
const {
register,
formState: { errors },
// eslint-disable-next-line react-hooks/rules-of-hooks
} = useForm<AppointmentForm>({
resolver: zodResolver(appointmentSchema),
defaultValues: {
changeTireForm: {},
},
});
return (
<AppointmentForm register={register} errors={errors} />
);
},
};

MUI
Installation
main.ts
import type { StorybookConfig } from "@storybook/nextjs";
const path = require("path");
const config: StorybookConfig = {
webpackFinal: async (config) => {
config.resolve.alias = {
...config.resolve.alias,
"@": path.resolve(__dirname, "../src"),
};
return config;
},
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-onboarding",
"@storybook/addon-interactions",
],
framework: {
name: "@storybook/nextjs",
options: {},
},
docs: {
autodocs: "tag",
},
};
export default config;
preview.ts
import type { Preview } from "@storybook/react";
import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

テーマの反映
npm i @storybook/addon-themes
import type { StorybookConfig } from "@storybook/nextjs";
const path = require("path");
const config: StorybookConfig = {
webpackFinal: async (config) => {
config.resolve.alias = {
...config.resolve.alias,
"@": path.resolve(__dirname, "../src"),
};
return config;
},
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-onboarding",
"@storybook/addon-interactions",
"@storybook/addon-themes", // add
],
framework: {
name: "@storybook/nextjs",
options: {},
},
docs: {
autodocs: "tag",
},
};
export default config;
import type { Preview } from "@storybook/react";
import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import "@fontsource/material-icons";
import { withThemeFromJSXProvider } from "@storybook/addon-themes";
import { brandingDarkTheme, brandingLightTheme } from "@/modules/MUI/muiTheme";
import { CssBaseline, ThemeProvider } from "@mui/material";
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;
// add for addons
export const decorators = [
withThemeFromJSXProvider({
themes: {
light: brandingLightTheme,
dark: brandingDarkTheme,
},
defaultTheme: "light",
Provider: ThemeProvider,
GlobalStyles: CssBaseline,
}),
];

controls
基本的に何もしなければコンポーネントのプロパティから推測されるものをコントロールにしてくれる。(renderを使う場合は例外)
*typeofとかを結構使っているような型や、複雑なものはダメっぽい(どこがどのように限界なのかはまだ不明)

global argType
*この場合条件付きにしないと全てのコンポーネントで指定のプロパティがあるかどうかに関わらず表示されてしまう
上記にあるように、argsがある時にのみ適用するようにする必要がある。(結局argsを書くのかという感じ)

MUI props
4コマ漫画か?

ドキュメントのようには動かないのでいじるプロパティは個別に設定していくことにする

Testing(Old)

intaraction test
import { FC } from "react";
import SearchOutlinedIcon from "@mui/icons-material/SearchOutlined";
import { TextField, TextFieldProps } from "@mui/material";
import { getLanguage } from "@/locales/languages/getLanguage";
import { AvailableLocaleType } from "@/locales/AvailableLocalesConst";
type SearchTextFieldProps = {
lang?: AvailableLocaleType;
TextFieldProps?: TextFieldProps;
};
const SearchTextField: FC<SearchTextFieldProps> = ({
lang,
TextFieldProps = {},
}: SearchTextFieldProps) => {
const t = getLanguage(lang);
return (
<TextField
size={"small"}
InputProps={{
startAdornment: <SearchOutlinedIcon />,
}}
placeholder={t.search}
{...TextFieldProps}
data-testid={"search-text-field"}
/>
);
};
export default SearchTextField;
import { Meta, StoryObj } from "@storybook/react";
import SearchTextField from "@/components/common/SearchTextField";
import { userEvent, within } from "@storybook/testing-library";
import { expect } from "@storybook/jest";
const meta: Meta<typeof SearchTextField> = {
tags: ["autodocs"],
component: SearchTextField,
parameters: {
layout: "centered",
},
};
export default meta;
type Story = StoryObj<typeof SearchTextField>;
export const Default: Story = {
args: {
lang: "en",
},
};
export const Filled: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(
canvas.getByTestId("search-text-field").getElementsByTagName("input")[0],
"Fish",
);
await expect(
canvas.getByTestId("search-text-field").getElementsByTagName("input")[0],
).toHaveValue("Fish");
},
};
cypressとかと違ってユーザー操作とか、をDOM見に行かないといけないのが割と面倒だけどそれ以外は凝集性が上がるので良さそう

Test Runner (for CI)
テスト実行時にplaywrightがない旨のエラーが出た
Error: Executable doesn't exist at /Users/USER_NAME/Library/Caches/ms-playwright/chromium-1084/chrome-mac/Chromium.app/Contents/MacOS/Chromium
npxでインストールしろとのことなので
npx playwright install
zero configでstories拡張子のファイルを走破するみたい
playの有無とかに関わらず全てのストーリを走らせる?

Test coverage
npm run test-storybook -- --coverage
zero configだけどadd onをmain.tsに追記するので開発サーバーの再起動が必要(体感、設定ファイル周りは基本再起動させる必要があるっぽい)
特にエラーは出ないけどコンソールの標準出力でレポートが見れないバグがあるみたい。nycをdevDependencyに追加しても解決せず。 上記のワークアラウンドでは普通に見れるので内部コマンドのどっかが漏れてるかなんか起きてそう

Accessibility Test
これもzero config + 再起動でUI上は確認可能な状態になる。
Automation
テストランナーを実行すると違反がある場合にはテストが落ちるようになり、何が違反しているかはそれぞれログで出力される。

React select

Interaction Test

Page component
'use client';
import { FC, useEffect, useState } from 'react';
import { Box, Button, Heading, useToast } from '@chakra-ui/react';
import Axios from 'axios';
import Repository from '@repositories/Repository';
const AppOauthCallbackPage: FC<{ searchParams: { code: string } }> = ({
searchParams: { code },
}) => {
const [status, setStatus] = useState('installing');
const toast = useToast();
const handleClick = () => {
window.location.href = 'https://example.com';
};
useEffect(() => {
if (code) {
Repository.installApp(code)
.then(() => {
setStatus('installed');
})
.catch((e) => {
if (Axios.isAxiosError(e)) {
toast({
title: e.response?.data.message,
status: 'error',
duration: 5000,
isClosable: true,
});
}
setStatus('error');
});
}
}, [code]);
return (
<Box w='100%' px={5}>
<Heading variant='h5' mb={5} data-testid={'title'}>
{status === 'installing' && 'アプリのインストール中'}
{status === 'installed' && 'アプリのインストールが完了しました'}
{status === 'error' && 'アプリのインストールに失敗しました'}
</Heading>
<Button
colorScheme='line'
isDisabled={status !== 'installed'}
data-testid={'moveToButton'}
onClick={handleClick}>
移動
</Button>
</Box>
);
};
export default AppOauthCallbackPage;
import { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { http, HttpResponse } from 'msw';
import AppOauthCallbackPage from 'app/page';
const meta: Meta<typeof AppOauthCallbackPage> = {
tags: ['autodocs'],
component: AppOauthCallbackPage,
parameters: {
controls: { expanded: true },
},
};
export default meta;
type Story = StoryObj<typeof AppOauthCallbackPage>;
export const Installed: Story = {
args: {
searchParams: {
code: 'code',
},
},
parameters: {
msw: {
handlers: [
http.post('http://localhost:4114/api/app/install', () => {
return HttpResponse.json({}, { status: 201 });
}),
],
},
},
};
export const Installing: Story = {
args: {
searchParams: {
code: '',
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByTestId('moveToButton'));
//assert page is not changed
await expect(canvas.getByTestId('title')).toBeInTheDocument();
},
};
export const Error: Story = {
args: {
searchParams: {
code: 'code',
},
},
parameters: {
msw: {
handlers: [
http.post('http://localhost:4114/api/app/install', () => {
return HttpResponse.json({ message: 'error' }, { status: 400 });
}),
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByTestId('moveToButton'));
//assert page is not changed
await expect(canvas.getByTestId('title')).toBeInTheDocument();
},
};

Play with react-select
CSSセレクターとかがないので、表示しているプレースホルダーや選択肢のラベルでやりくりすればできないことはない。
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText('選択してください'));
await userEvent.keyboard('{enter}');
await expect(canvas.getByText('フル6分割(2500x1686)')).toBeInTheDocument();
},

Assert dialog