[React] Storybook 導入, MUI対応, Testにimport
以下、備忘録。Storybookは勉強中。
Install Storybook
準備
公式の通りに実行する。
ReactのrootでStorybookをinstall。
$ npx sb init
確認
以下を実行すると、Storybookが立ち上がる。
$ npm run storybook
いくつかファイルが自動生成される。以下、その一部。
Button.tsx
import React from 'react';
import './button.css';
interface ButtonProps {
/**
* Is this the principal call to action on the page?
*/
primary?: boolean;
/**
* What background color to use
*/
backgroundColor?: string;
/**
* How large should the button be?
*/
size?: 'small' | 'medium' | 'large';
/**
* Button contents
*/
label: string;
/**
* Optional click handler
*/
onClick?: () => void;
}
/**
* Primary UI component for user interaction
*/
export const Button = ({
primary = false,
size = 'medium',
backgroundColor,
label,
...props
}: ButtonProps) => {
const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
return (
<button
type="button"
className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
style={{ backgroundColor }}
{...props}
>
{label}
</button>
);
};
button.css
.storybook-button {
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-weight: 700;
border: 0;
border-radius: 3em;
cursor: pointer;
display: inline-block;
line-height: 1;
}
.storybook-button--primary {
color: white;
background-color: #1ea7fd;
}
.storybook-button--secondary {
color: #333;
background-color: transparent;
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
}
.storybook-button--small {
font-size: 12px;
padding: 10px 16px;
}
.storybook-button--medium {
font-size: 14px;
padding: 11px 20px;
}
.storybook-button--large {
font-size: 16px;
padding: 12px 24px;
}
Button.stories.tsx
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Button } from './Button';
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
export default {
title: 'Example/Button',
component: Button,
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof Button>;
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;
export const Primary = Template.bind({});
// More on args: https://storybook.js.org/docs/react/writing-stories/args
Primary.args = {
primary: true,
label: 'Button',
};
export const Secondary = Template.bind({});
Secondary.args = {
label: 'Button',
};
export const Large = Template.bind({});
Large.args = {
size: 'large',
label: 'Button',
};
export const Small = Template.bind({});
Small.args = {
size: 'small',
label: 'Button',
};
MUI対応
設定
.storybook
フォルダな内のファイルを修正する。
module.exports = {
stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/preset-create-react-app',
],
+ webpackFinal(config) {
+ delete config.resolve.alias['emotion-theming'];
+ delete config.resolve.alias['@emotion/styled'];
+ delete config.resolve.alias['@emotion/core'];
+ return config;
+ },
framework: '@storybook/react',
};
+ import { ThemeProvider } from '@mui/material/styles';
+ import CssBaseline from '@mui/material/CssBaseline';
+ import theme from '../src/views/theme';
+ export const decorators = [
+ (Story) => {
+ return (
+ <ThemeProvider theme={theme}>
+ <CssBaseline />
+ <Story />
+ </ThemeProvider>
+ );
+ },
+ ];
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
確認すること
MUI導入時に設定していたtheme.ts
は以下。
palette
で設定しているprimary
とsecondary
の色がStorybook上で反映されることを確認する。
今回、Buttonを表示させて、青色とオレンジになれば良い。
import { createTheme } from '@mui/material/styles';
import { blueGrey, blue, deepOrange } from '@mui/material/colors';
export const FLEXIBLE_MIN_WIDTH = 1025;
export const FLEXIBLE_MAX_WIDTH = 1366;
const theme = createTheme({
palette: {
primary: blue,
secondary: deepOrange,
},
...
確認用のButton。colorを受け取れるようにしておいた。
import React from 'react';
import Button, { ButtonProps } from '@mui/material/Button';
export type BaseButtonProps = Pick<ButtonProps, 'onClick' | 'color'> & {
label: string;
};
const BaseButton: React.VFC<BaseButtonProps> = (props) => {
return (
<Button variant="contained" color={props.color} onClick={props.onClick}>
{props.label}
</Button>
);
};
export default BaseButton;
実装
BaseButtonのstoriesはCSF3.0で記述する。
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import BaseButton from './BaseButton';
export default {
// titleを設定するとStorybook上での階層を指定できる
// 設定しない場合は定義してあるフォルダ構造がそのまま使われる
// title: 'Button/BaseTextButton',
component: BaseButton,
} as ComponentMeta<typeof BaseButton>;
export const Primary: ComponentStoryObj<typeof BaseButton> = {
args: {
label: 'Primary',
// defaultがprimaryなので指定はなくても良い
// color: 'primary',
onClick: () => {
console.log('test');
},
},
};
export const Secondary: ComponentStoryObj<typeof BaseButton> = {
args: {
label: 'Secondary',
// MUI Buttonのsecondary colorを設定
color: 'secondary',
onClick: () => {
console.log('test');
},
},
};
確認
npm run storybook
で確認する。
theme.ts
のpalette
で指定した色が反映されている。
Play function
公式を参考に触ってみる。
準備
installと.storybook/main.js
を修正する。
interactionsDebugger: true
を設定するとStorybook上での操作が便利になります。
$ npm install @storybook/addon-interactions @storybook/testing-library --save-dev
module.exports = {
stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/preset-create-react-app',
+ '@storybook/addon-interactions',
],
+ features: {
+ interactionsDebugger: true,
+ },
webpackFinal(config) {
delete config.resolve.alias['emotion-theming'];
delete config.resolve.alias['@emotion/styled'];
delete config.resolve.alias['@emotion/core'];
return config;
},
framework: '@storybook/react',
};
お試し用Componentを作成。内容は、
-
TextField
とButton
がある -
Button
をクリックすると「送信しました。」のメッセージを表示する。 - (バリデーションしていないので
TextField
自体に意味はない)
TmpSendEmailCard.tsx
import { Box, Button, Card, CardContent, TextField } from '@mui/material';
import React, { useState } from 'react';
const TmpSendEmailCard: React.VFC = () => {
const [val, setVal] = useState('');
const [isSend, setIsSend] = useState(false);
return (
<Card>
<CardContent>
<Box>メールアドレス</Box>
<Box mt={1}>
<TextField
value={val}
onChange={(e) => {
setVal(e.target.value);
}}
/>
</Box>
<Box mt={2}>
<Button
variant="contained"
onClick={(): void => {
setIsSend(true);
}}
>
Send
</Button>
</Box>
{isSend && <Box mt={1}>送信しました。</Box>}
</CardContent>
</Card>
);
};
export default TmpSendEmailCard;
実装
作成したstoriesは以下。
FirstStory
とFirstStoryByWithin
は同じことをしている。違いとしてはwithin
を使った方が効率が良いっぽい。
(Working with the Canvas部に記述あり)
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import { userEvent, screen, within } from '@storybook/testing-library';
import TmpSendEmailCard from './TmpSendEmailCard';
export default {
component: TmpSendEmailCard,
} as ComponentMeta<typeof TmpSendEmailCard>;
export const FirstStory: ComponentStoryObj<typeof TmpSendEmailCard> = {
// args: {},
play: async () => {
await userEvent.type(screen.getByRole('textbox'), 'hoge@example.com', {
delay: 100,
});
await userEvent.click(screen.getByRole('button'));
},
};
export const FirstStoryByWithin: ComponentStoryObj<typeof TmpSendEmailCard> = {
args: {},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByRole('textbox'), 'hoge@example.com', {
delay: 100,
});
await userEvent.click(canvas.getByRole('button'));
},
};
確認
Import stories in tests
準備
上記を参考にinstallする。
$ npm install --save-dev @storybook/testing-react
実装
play
によってClick処理まで実行され、「送信しました。」の文字表示のテスト。
stories
ファイル内でJest
を用いてテストも可能らしい。test
かstories
、どちらかに寄せて効率よいテストを考えていけたらいなぁ…
import { render, screen } from '@testing-library/react';
import { composeStories } from '@storybook/testing-react';
import * as stories from './TmpSendEmailCard.stories';
const { FirstStoryByWithin } = composeStories(stories);
describe('お試しテスト', () => {
test('sample', async () => {
const { container } = render(<FirstStoryByWithin />);
await FirstStoryByWithin.play({ canvasElement: container });
expect(screen.getByText('送信しました。')).toBeInTheDocument;
});
});
確認
(※添付画像の「skipped」は関係ないテスト。上記のお試しテストが「passed」にカウントされている。)
Discussion