⛰️

NuxtプロジェクトにStorybookとmswを導入する

2022/07/14に公開

前提

Nuxt v2.15.8を利用しています。

Storybookの導入

NuxtプロジェクトへのStorybook導入は @nuxtjs/storybook が便利です。
公式ドキュメント:https://storybook.nuxtjs.org/

以下のようなメリットがあります。

Zero configuration
Nuxt webpack configuration
Nuxt plugins support
Story discovery from nuxt modules
Nuxt components support
Storybook Generate
Hot reload support

インストール・設定

https://storybook.nuxtjs.org/getting-started/installation
公式ドキュメントのinstallationに従い進めます。

yarn add --dev @nuxtjs/storybook

.gitignore へ下記を追加

.nuxt-storybook
storybook-static

Storybookでは .storybook ディレクトリ配下の main.js preview.js などで各種設定を行えますが、 @nuxtjs/storybook では nuxt.config.jsstorybook セクションで同様の設定を行えます。

export default {
  storybook: {
    // Options
  }
}

起動

https://storybook.nuxtjs.org/getting-started/commands#development

yarn nuxt storybook

余談:yarn buildに失敗する

筆者の環境では、 @nuxtjs/storybook の導入後に yarn build ( nuxt-ts build )に失敗するようになっていました。

ValidationError: Invalid options object. PostCSS Loader has been initialized using an options object that does not match the API schema.
 - options has an unknown property 'order'. These properties are valid:
   object { postcssOptions?, execute?, sourceMap?, implementation? }

これは postcss-loader のバージョンに依存する問題で
@nuxtjs/storybook では postcss-loader の4系が入るが、Nuxt自体は3系を利用していることが原因でした。
4系ではオプションの指定方法が変わっています。 (参考)

今回は明示的に postcss-loader の3系を入れることで対応しました。

"postcss-loader": "3.0.0",

mswの導入

https://mswjs.io/docs/
msw(Mock Service Worker)はService Worker APIを利用して、APIリクエストをモックできるライブラリです。

内部で外部APIを叩いているコンポーネントをStorybookに追加する際、mswを利用することで、各Storyで任意のレスポンスをモックすることが可能になります。
筆者の環境ではREST, GraphQLでのリクエストをおこなっていますが、mswではどちらもモック可能です。

https://storybook.js.org/docs/vue/writing-stories/build-pages-with-storybook#mocking-connected-components
https://github.com/mswjs/msw-storybook-addon
Storybookとmswのインテグレーションに関しては msw-storybook-addon というStorybookプラグインが用意されています。

インストール・設定

https://github.com/mswjs/msw-storybook-addon#installing-and-setup
公式ドキュメントの手順に従い進めます。

yarn add msw msw-storybook-addon -D

public にservice workerを生成

npx msw init public/

次にaddonの設定を行いますが、 ./storybook/preview.js を用意する必要があります。
しかし、今回は @nuxtjs/storybook を利用しているので .storybook ディレクトリがありません。
せっかくのzero-configですが、今回はejectをしてManual Setupを行います。
(筆者の環境では nuxt.config.js を利用した設定はうまくいきませんでしたが、ejectせずに設定できる方法もあるかもしれません。)

https://storybook.nuxtjs.org/advanced/manual-setup

yarn nuxt storybook eject

生成された ./storybook/preview.js に以下を追記します。

import { initialize, mswDecorator } from 'msw-storybook-addon';

// Initialize MSW
initialize();

// Provide the MSW addon decorator globally
export const decorators = [mswDecorator];

storiesファイルでのAPIモック

https://github.com/mswjs/msw-storybook-addon#usage

サンプルコードは公式ドキュメントのものを利用しています。
以下のようにコンポーネント内でfetchしているファイルがあるとします。

<!-- YourPage.vue -->

<template>
  <div v-if="!loading && data && data.subdocuments.length">
    <PageLayout :user="data.user">
      <DocumentHeader :document="data.document" />
      <DocumentList :documents="data.subdocuments" />
    </PageLayout>
  </div>
  <p v-if="loading">
    Loading...
  </p>
  <p v-if="error">
    There was an error fetching the data!
  </p>
</template>
<script>
  import { ref } from 'vue';

  import PageLayout from './PageLayout';
  import DocumentHeader from './DocumentHeader';
  import DocumentList from './DocumentList';

  export default {
    name: 'DocumentScreen',
    setup() {
      const data = ref(null);
      const loading = ref(true);
      const error = ref(null);
      fetch('https://your-restful-endpoint')
        .then((res) => {
          if (!res.ok) {
            error.value = res.statusText;
          }
          return res;
        })
        .then((res) => res.json())
        .then((requestData) => {
          data.value = requestData;
          loading.value = false;
        })
        .catch(() => {
          error.value = 'error';
        });
      return {
        error,
        loading,
        data,
      };
    },
  };
</script>

この場合、storiesファイルでは以下のようにしてAPIをモックできます。

// YourPage.stories.js

import { rest } from 'msw';

import DocumentScreen from './YourPage.vue';

export default {
  /* 👇 The title prop is optional.
  * See https://storybook.js.org/docs/vue/configure/overview#configure-story-loading
  * to learn how to generate automatic titles
  */
  title: 'DocumentScreen',
  component: DocumentScreen,
};

//👇The mocked data that will be used in the story
const TestData = {
  user: {
    userID: 1,
    name: 'Someone',
  },
  document: {
    id: 1,
    userID: 1,
    title: 'Something',
    brief: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
    status: 'approved',
  },
  subdocuments: [
    {
      id: 1,
      userID: 1,
      title: 'Something',
      content:
        'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
      status: 'approved',
    },
    {
      id: 2,
      userID: 1,
      title: 'Something else',
      content:
        'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
      status: 'awaiting review',
    },
    {
      id: 3,
      userID: 2,
      title: 'Another document',
      content:
        'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
      status: 'approved',
    },
    {
      id: 4,
      userID: 2,
      title: 'Something',
      content:
        'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
      status: 'approved',
    },
  ],
};

const PageTemplate = () => ({
  components: { DocumentScreen },
  template: '<DocumentScreen />',
});

export const MockedSuccess = PageTemplate.bind({});
MockedSuccess.parameters = {
  msw: [
    rest.get('https://your-restful-endpoint/', (_req, res, ctx) => {
      return res(ctx.json(TestData));
    }),
  ],
};

export const MockedError = PageTemplate.bind({});
MockedError.parameters = {
  msw: [
    rest.get('https://your-restful-endpoint/', (_req, res, ctx) => {
      return res(ctx.delay(800), ctx.status(403));
    }),
  ],
};
GraphQLの場合も同様です。
<!-- YourPage.vue -->

<template>
  <div v-if="loading">Loading...</div>

  <div v-else-if="error">There was an error fetching the data!</div>

  <div v-if="!loading && data && result.subdocuments.length">
    <PageLayout :user="data.user">
      <DocumentHeader :document="result.document" />
      <DocumentList :documents="result.subdocuments" />
    </PageLayout>
  </div>
</template>

<script>
  import PageLayout from './PageLayout';
  import DocumentHeader from './DocumentHeader';
  import DocumentList from './DocumentList';

  import gql from 'graphql-tag';
  import { useQuery } from '@vue/apollo-composable';

  export default {
    name: 'DocumentScreen',
    setup() {
      const { result, loading, error } = useQuery(gql`
        query AllInfoQuery {
          user {
            userID
            name
          }
          document {
            id
            userID
            title
            brief
            status
          }
          subdocuments {
            id
            userID
            title
            content
            status
          }
        }
      `);
      return {
        result,
        loading,
        error,
      };
    },
  };
</script>
// YourPage.stories.js

import DocumentScreen from './YourPage.vue';

import WrapperComponent from './ApolloWrapperClient.vue';

import { graphql } from 'msw';

export default {
  /* 👇 The title prop is optional.
  * See https://storybook.js.org/docs/vue/configure/overview#configure-story-loading
  * to learn how to generate automatic titles
  */
  title: 'DocumentScreen',
  component: DocumentScreen,
};

//👇The mocked data that will be used in the story
const TestData = {
  user: {
    userID: 1,
    name: 'Someone',
  },
  document: {
    id: 1,
    userID: 1,
    title: 'Something',
    brief: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
    status: 'approved',
  },
  subdocuments: [
    {
      id: 1,
      userID: 1,
      title: 'Something',
      content:
        'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
      status: 'approved',
    },
    {
      id: 2,
      userID: 1,
      title: 'Something else',
      content:
        'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
      status: 'awaiting review',
    },
    {
      id: 3,
      userID: 2,
      title: 'Another document',
      content:
        'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
      status: 'approved',
    },
    {
      id: 4,
      userID: 2,
      title: 'Something',
      content:
        'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
      status: 'approved',
    },
  ],
};

const PageTemplate = () => ({
  components: { DocumentScreen, WrapperComponent },
  template: `
    <WrapperComponent>
      <SampleGraphqlComponent />
    </WrapperComponent>
  `,
});

export const MockedSuccess = PageTemplate.bind({});
MockedSuccess.parameters = {
  msw: [
    graphql.query('AllInfoQuery', (req, res, ctx) => {
      return res(ctx.data(TestData));
    }),
  ],
};

export const MockedError = PageTemplate.bind({});
MockedError.parameters = {
  msw: [
    graphql.query('AllInfoQuery', (req, res, ctx) => {
      return res(
        ctx.delay(800),
        ctx.errors([
          {
            message: 'Access denied',
          },
        ])
      );
    }),
  ],
};

起動

/public にservice workerを用意しているので、 -s public オプションが必要です。

npm run start-storybook -s public

参考資料など

https://storybook.nuxtjs.org/
https://mswjs.io/docs/
https://nananomae.xyz/article/2021-01-15:error-when-upgrading-postcss-loader
https://storybook.js.org/docs/vue/writing-stories/build-pages-with-storybook#mocking-connected-components
https://github.com/mswjs/msw-storybook-addon

Discussion