Open26

Storybook7

nicopinnicopin
nicopinnicopin

msw

各ストーリ内部でparametersにmswプロパティを追加してそこにhandlersを定義できる。baseUrlはmsw側の設定で。

slots.stories.tsx
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));
          }
        ),
      ],
    },
  },
};
nicopinnicopin
.storybook/main.ts
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;

nicopinnicopin

Style

global.cssとかを適用させたい場合はpreview.tsで読み込み

.storybook/preview.ts
import "../src/styles/globals.css";
nicopinnicopin

Hooks

StoryObjのrender関数でラッパーを追加したりuseFormを使ったりすれば動くが正しい設定なのか微妙、Eslintのルールをオフにせずコメントアウトしておいて正しい設定がわかったタイミングでアップデートする

form.stories.tsx
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} />
    );
  },
};

nicopinnicopin

MUI

Installation

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

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;
nicopinnicopin

テーマの反映

https://storybook.js.org/recipes/@mui/material

npm i @storybook/addon-themes
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",
    "@storybook/addon-themes", // add
  ],
  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";
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,
  }),
];

nicopinnicopin

controls

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

nicopinnicopin

global argType

https://storybook.js.org/docs/react/api/arg-types#manually-specifying-argtypes
基本的にinferで済めばそれに越したことはないが、自動的に生成できない場合でかつ複数のコンポーネントで利用されるプロパティがあるならグローバルに設定できる
*この場合条件付きにしないと全てのコンポーネントで指定のプロパティがあるかどうかに関わらず表示されてしまう
https://github.com/storybookjs/storybook/issues/11697#issuecomment-1218062842
上記にあるように、argsがある時にのみ適用するようにする必要がある。(結局argsを書くのかという感じ)

nicopinnicopin

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

nicopinnicopin
nicopinnicopin

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見に行かないといけないのが割と面倒だけどそれ以外は凝集性が上がるので良さそう

nicopinnicopin

Test Runner (for CI)

https://storybook.js.org/docs/react/writing-tests/test-runner

テスト実行時に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の有無とかに関わらず全てのストーリを走らせる?

nicopinnicopin

Test coverage

https://storybook.js.org/docs/react/writing-tests/test-coverage

npm run test-storybook -- --coverage

zero configだけどadd onをmain.tsに追記するので開発サーバーの再起動が必要(体感、設定ファイル周りは基本再起動させる必要があるっぽい)

https://github.com/storybookjs/addon-coverage/issues/13
特にエラーは出ないけどコンソールの標準出力でレポートが見れないバグがあるみたい。nycをdevDependencyに追加しても解決せず。
https://github.com/storybookjs/addon-coverage/issues/13#issuecomment-1808839982
上記のワークアラウンドでは普通に見れるので内部コマンドのどっかが漏れてるかなんか起きてそう

nicopinnicopin
nicopinnicopin

Page component

page.tsx
'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;
page.stories.tsx
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();
  },
};

nicopinnicopin

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();
  },