💡gntk.dev

Storybookによるビジュアルリグレッションテストの追加

GatsbyとTypeScriptで作ったブログにStoryBookを導入しました。chromaticにStoryBookをホスティングし、GitHubを連携させることで、PRを作るたびにstoryのビジュアルリグレッションテストができるようになりました。

post-cover

フロントエンドのビジュアルリグレッションテストとして、storybookを利用するための方法を調査し、本ブログに実装したのでまとめました。 storybookのチュートリアルがメンテンスされてないのか、ところどころ修正しながらでないと動かなかったりしてつらいことが多かったですが、なんとかできました。

導入

yarn add

TypeScriptで書いたGatsby製のプロジェクトに、StoryBookを導入します。公式に従い、npxを使って初期化します。

npx -p @storybook/cli sb init

完了したら、動作確認します。

yarn storybook

正常に動いていたら、サンプルのコンポーネントのスタイルガイドが見れるはずです。自分はここでStoryBookが動かなくて、yarnして依存関係をインストールしなおしたら動くようになりました。

storybookのwebpackの設定

TypeScriptで書いたGatsbyのソースファイルをトランスパイルできるようにwebpackの設定をします。.storybook/main.jsに記述します。

// .storybook/main.js
module.exports = {
  "stories": [
    "../src/**/*.stories.mdx",
    "../src/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-actions",
    "@storybook/addon-links",
    "@storybook/addon-essentials"
  ],
  webpackFinal: async config => {
    // Transpile Gatsby module because Gatsby includes un-transpiled ES6 code.
    config.module.rules[0].exclude = [/node_modules\/(?!(gatsby)\/)/]
    // use installed babel-loader which is v8.0-beta (which is meant to work with @babel/core@7)
    config.module.rules[0].use[0].loader = require.resolve("babel-loader")
    // use @babel/preset-react for JSX and env (instead of staged presets)
    config.module.rules[0].use[0].options.presets = [
      require.resolve("@babel/preset-react"),
      require.resolve("@babel/preset-env"),
    ]
    config.module.rules[0].use[0].options.plugins = [
      // use @babel/plugin-proposal-class-properties for class arrow functions
      require.resolve("@babel/plugin-proposal-class-properties"),
      // use babel-plugin-remove-graphql-queries to remove static queries from components when rendering in storybook
      require.resolve("babel-plugin-remove-graphql-queries"),
    ]
    // Prefer Gatsby ES6 entrypoint (module) over commonjs (main) entrypoint
    config.resolve.mainFields = ["browser", "module", "main"]
    config.module.rules.push({
      test: /\.(ts|tsx)$/,
      loader: require.resolve("babel-loader"),
      options: {
        presets: [["react-app", { flow: false, typescript: true }]],
        plugins: [
          require.resolve("@babel/plugin-proposal-class-properties"),
          // use babel-plugin-remove-graphql-queries to remove static queries from components when rendering in storybook
          require.resolve("babel-plugin-remove-graphql-queries"),
        ],
      },
    })
    config.resolve.extensions.push(".ts", ".tsx")
    return config
  },
}

Gatsby公式に従って設定しましたが、内容はあんまりわかっていません。(わかるようになったほうがいいよな〜とか思いつつ、webpackまわりの勉強はずっと先延ばしにしている。)

eslintの設定

StoryBookを初期化したときにサンプルみたいなのが勝手に作られるのですが、eslintでeslint-config-airbnbを利用している場合、StoryBookからStoryとかMetaとかimportしていることからno-extraneous-dependenciesに引っかかってしまう(devDependenciesをimportすなと怒られる)ため、無効化します。.eslintrc.jsに下記を追加。

        "import/no-extraneous-dependencies": [
            "error", {
                devDependencies: ["**/*.stories.tsx"],
                peerDependencies: false
            }
        ],

storyの作成

初期化と同時に作成されたサンプルとか公式チュートリアルとかを参考にして、storyを作っていきます。本ブログのコンポーネントを例に、作ったstoryをいくつか紹介します。

何もPropsを受け取らないコンポーネント

まず、なにもPropsを受け取らないコンポーネント(静的な要素)のstoryを作ります。トップページのheroエリアのコンポーネントは静的な要素です。

// hero.tsx
import React, { FC } from 'react';
import styled from 'styled-components';

export type Props = {
  className?: string;
};

const Component: FC<Props> = ({ className }) => {
  return (
    <div className={className}>
      <span role="img" aria-label="icon">
        💡
      </span>
      <h1>gntk.dev</h1>
      <p>技術的な学びや日常の気付きのメモなどを書くブログです</p>
    </div>
  );
};

const Hero = styled(Component)`
  margin: 50px 0;
  text-align: center;

  & > span {
    font-size: 4rem;
  }

  & > h1 {
    margin-top: 0;
    font-size: calc(24px + 1.5vw);
    line-height: 1.2;
  }

  & > p {
    color: #434343;
  }
`;

export default Hero;

いやPrpps受け取ってますやんと思われるかもしれませんが、これはstyled-componentsを利用するにあたって、コンポーネントに割り当てるハッシュ値を設定するためのものなので、他のコンポーネントから渡されるものではないです。このコンポーネントのstoryは下記のようになります。

// hero.stories.tsx
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import Hero, { Props } from '../components/hero';

export default {
  title: 'Hero',
  component: Hero,
} as Meta;

const Template: Story<Props> = () => <Hero />;

export const Default = Template.bind({});

StoryBookのプレビュー画面(?)で表示するためのtitlecomponentStory型にしたコンポーネントをexportします。なんでDefaultという名前にしてexportしているかは、次の節で説明します。 これを書くと、下記のようにStoryBook上にコンポーネントのスタイルガイドが表示されます。 heroコンポーネントのstorybook上の表示

Propsを受け取るコンポーネント

次に、Propsを受け取って状態が変わるコンポーネントのStoryを作ります。例として、記事のタイトル・ディスクリプション・日付・画像URL・スラッグを受け取って記事へのリンクを表示するコンポーネントを紹介します。

// post-link.tsx
import React, { FC } from 'react';
import { Link } from 'gatsby';
import styled from 'styled-components';

export type Props = {
  className?: string;
  post: Pick<GatsbyTypes.ContentfulPost, 'title' | 'slug' | 'updatedAt'> & {
    readonly image: GatsbyTypes.Maybe<
      Pick<GatsbyTypes.ContentfulAsset, 'title'> & {
        readonly file: GatsbyTypes.Maybe<Pick<GatsbyTypes.ContentfulAssetFile, 'url'>>;
      }
    >;
    readonly description: GatsbyTypes.Maybe<
      Pick<GatsbyTypes.contentfulPostDescriptionTextNode, 'description'>
    >;
  };
};

const Component: FC<Props> = ({ className, post }) => {
  const { title, updatedAt, image } = post;
  const description = post.description?.description;
  const pageLink = `/post/${post.slug}`;
  return (
    <article className={className}>
      <Link to={pageLink}>
        <div>
          <img src={image?.file?.url} alt="post-cover" />
        </div>
      </Link>
      <section>
        <Link to={pageLink}>
          <h2>{title}</h2>
        </Link>
        <p>{description}</p>
        <time>{updatedAt}</time>
      </section>
    </article>
  );
};

const PostLink = styled(Component)`
// 省略
`;

export default PostLink;

今回はStoryBookの記事なのでこのコンポーネント自体の解説はそんなにしません。本ブログは記事コンテンツをcontentfulで管理、GraphQLでデータを取得しており、GraphQLのレスポンスの型はgatsby-plugin-typegenを使って生成しているため、このコンポーネントはPropsがやかましいかんじになっています。スタイルも多くて長ったらしくなるので省略しました。

上記の、Propsを受け取るコンポーネントのStoryは下記のようになります。

// post-link.stories.tsx
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import PostLink, { Props } from '../components/post-link';

export default {
  title: 'PostLink',
  component: PostLink,
} as Meta;

const Template: Story<Props> = ({ post }) => <PostLink post={post} />;

export const Default = Template.bind({});
Default.args = {
  post: {
    title: 'Netlifyでステージング環境を用意する',
    updatedAt: '2020年10月25日',
    image: {
      title: 'image title',
      file: {
        url:
          'https://tekitouimageurl.com/image.png',
      },
    },
    description: {
      description:
        '実機での表示確認など、ローカル環境では確認できない検証があるときはステージング環境が必要になります。NetlifyとGithubを連携させれば、PRを作るごとに自動でステージング環境が用意できます。',
    },
    slug: '20201025-netlify-staging-environment',
  },
};

コンポーネントと一緒にPropsの型もimportします。exportしているDefaultのargsに、StoryBookで表示したいPropsを設定してあげることができます。このようにStoryBookにスタイルガイドが表示されます。 PostLinkコンポーネントのstorybook上での表示

ここではDefaultだけexportしてますが、たとえばクソ長いdescriptionのとき表示が崩れないか?を確認したいときは

export const LongDescription = Template.bind({});
LongDescription.args = {
  post: {
    ...Default.args.post,
    description: {
      description: 'クソ長いテキスト(省略)',
    },
  },
};

といったふうにexportするStory型コンポーネントを設定してあげればよいです。

他のコンポーネントを子に持つコンポーネント

最後は他のコンポーネントを子に持つコンポーネントです。例として、トップページを表示するコンポーネントを紹介します。

// home.tsx
import React, { FC } from 'react';
import Layout from '../components/layout';
import Hero from '../components/hero';
import PostLink from '../components/post-link';
import SEO from '../components/seo';

export type Props = {
  data: {
    readonly allContentfulPost: {
      readonly edges: ReadonlyArray<{
        readonly node: Pick<GatsbyTypes.ContentfulPost, 'title' | 'slug' | 'updatedAt'> & {
          readonly image: GatsbyTypes.Maybe<
            Pick<GatsbyTypes.ContentfulAsset, 'title'> & {
              readonly file: GatsbyTypes.Maybe<Pick<GatsbyTypes.ContentfulAssetFile, 'url'>>;
            }
          >;
          readonly description: GatsbyTypes.Maybe<
            Pick<GatsbyTypes.contentfulPostDescriptionTextNode, 'description'>
          >;
        };
      }>;
    };
  };
};

const Home: FC<Props> = ({ data }) => {
  return (
    <Layout>
      <SEO title="gntk.dev" description="技術的な学びや日常の気付きのメモなどを書くブログです" />
      <Hero />
      {data.allContentfulPost.edges.map((edge) => (
        <PostLink key={edge.node.slug} post={edge.node} />
      ))}
    </Layout>
  );
};

export default Home;

Layoutコンポーネントの中にSEO, Hero, PostLinkといったコンポーネントがあります。Storyはこうです。

// home.stories.tsx
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import Home, { Props } from '../templates/home';
import * as PostLinkStories from './post-link.stories';

export default {
  title: 'Home',
  component: Home,
} as Meta;

const Template: Story<Props> = ({ data }) => <Home data={data} />;

export const Default = Template.bind({});
const post = PostLinkStories?.Default?.args?.post;
Default.args = {
  data: {
    allContentfulPost: {
      edges: [
        {
          node: {
            title: post?.title,
            updatedAt: post?.updatedAt,
            image: post?.image,
            description: post?.description,
            slug: post?.slug,
          },
        },
      ],
    },
  },
};

子コンポーネントの読み込みはコンポーネント自身が行うので、Storyでは特に設定してあげることもなく、必要なPropsだけ設定してあげます。Props、本当は

Default.args = {
  data: {
    allContentfulPost: {
      edges: [
        {
          node: {
            ...post,
          },
        },
      ],
    },
  },
};

みたいにスッキリした書き方にしたかったのですが、なぜか型が一致しないため、このような醜い書き方になってます、、TypeScriptつよくなりたい

自動テストの設定

ここまでで用意したスタイルガイドをもとに、自動でビジュアルリグレッションテストを実行する環境を作ります。

storybookをchromaticにホスティングする

chromaticは、storybookのメンテナーが作成した無料のホスティングサービスです。chromaticとgithubを連携させれば、変更をgithubにpushするたびにスタイルガイドをデプロイするたびにビジュアルリグレッションテストを実行してくれます。 chromaticのパッケージを追加します。

yarn add -D chromatic

yarn addが完了したらここからchromaticにgithubアカウントでログインします。ログインしたら、storybookがあるリポジトリを選択し、表示されるproject-tokenを控えます。

控えたproject-tokenを使って、下記コマンドでstorybookをchromaticにデプロイできます。

yarn chromatic --project-token=<project-token>

実行が完了すると、デプロイされたstorybookのURLが表示されます

chromaticへのデプロイ自動化

開発チームでstorybookを共有するのに、いちいち上記のようにyarn chromaticして更新するのは面倒なので、自動化します。

まず、リポジトリのsecretsにproject-tokenを設定します。secretsはリポジトリに設定できる暗号化された環境変数で、github actionsとかで使えます。リポジトリのSetting > Secrets > [New repository secret]の手順で、CHROMATIC_PROJECT_TOKENみたいな名前のsecretを作成し、project-tokenを設定してください githubのsecretsの設定画面

次に、プロジェクトのルートにディレクトリ.github/workflows/chromatic.ymlを作成します。

# .github/workflows/chromatic.yml
# name of our action
name: 'Chromatic Deployment'
# the event that will trigger the action
on: push

# what the action will do
jobs:
  test:
    # the operating system it will run on
    runs-on: ubuntu-latest
    # the list of steps that the action will go through
    steps:
      - uses: actions/checkout@v1
      - run: yarn
      - uses: chromaui/action@v1
        # options required to the GitHub chromatic action
        with:
          # our project token, to see how to obtain it
          # refer to https://www.learnstorybook.com/intro-to-storybook/react/en/deploy/
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          token: ${{ secrets.GITHUB_TOKEN }}

最後の2行のところで設定したsecretsを利用しています。GITHUB_TOKENは自動で作成されるものなので、特に設定する必要はないです。これで、chromatic上のstorybookの更新が自動化されました。githubリポジトリに変更をpushするたびにstorybookが更新されます。

ビジュアルリグレッションテスト

なんと実はchromaticへデプロイするだけで実はビジュアルリグレッションテストができています。試しにheroコンポーネントを修正してPRを作ってみます。すると、このようにPRに「ビジュアルテストしたんで、変更をaccept/denyしてください」みたいなかんじの表示が出るようになります。 chromaticへのリンク chromaticでのコンポーネントの差分表示

今後の予定

最初の記事でも書いたように、このブログにはタグつけやらページネーションやら追加したい機能がいくつかあるので、機能追加しやすいように、こういうテストとかの環境作りを優先して勧めたいです。

© 2021 gntk.dev