💨

[React] Storybook 導入, MUI対応, Testにimport

2022/02/13に公開

以下、備忘録。Storybookは勉強中。

https://github.com/kosukekashiwa/cra-sample

Install Storybook

準備

公式の通りに実行する。

https://storybook.js.org/docs/react/get-started/install

https://github.com/kosukekashiwa/cra-sample

ReactのrootでStorybookをinstall。

$ npx sb init

確認

以下を実行すると、Storybookが立ち上がる。

$ npm run storybook

いくつかファイルが自動生成される。以下、その一部。

Button.tsx
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
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フォルダな内のファイルを修正する。

.storybook/main.js
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',
};
.storybook/preview.js
+ 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で設定しているprimarysecondaryの色がStorybook上で反映されることを確認する。
今回、Buttonを表示させて、青色とオレンジになれば良い。

theme.ts
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を受け取れるようにしておいた。

BaseButton.tsx
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で記述する。
https://storybook.js.org/blog/component-story-format-3-0/

BaseButton.stories.tsx
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.tspaletteで指定した色が反映されている。

Play function

公式を参考に触ってみる。

https://storybook.js.org/docs/react/writing-stories/play-function

準備

installと.storybook/main.jsを修正する。
interactionsDebugger: trueを設定するとStorybook上での操作が便利になります。

$ npm install @storybook/addon-interactions @storybook/testing-library --save-dev
.storybook/main.js
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を作成。内容は、

  • TextFieldButtonがある
  • Buttonをクリックすると「送信しました。」のメッセージを表示する。
  • (バリデーションしていないのでTextField自体に意味はない)
TmpSendEmailCard.tsx
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は以下。
FirstStoryFirstStoryByWithinは同じことをしている。違いとしてはwithinを使った方が効率が良いっぽい。
(Working with the Canvas部に記述あり)

TmpSendEmailCard.stories.tsx
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

準備

https://storybook.js.org/docs/react/writing-tests/importing-stories-in-tests

上記を参考にinstallする。

$ npm install --save-dev @storybook/testing-react

実装

playによってClick処理まで実行され、「送信しました。」の文字表示のテスト。

storiesファイル内でJestを用いてテストも可能らしい。teststories、どちらかに寄せて効率よいテストを考えていけたらいなぁ…

TmpSendEmailCard.test.tsx
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