技術の変遷は秋の空、ワイの興味は飽きの空。時々OSSとテスト。

2023-08-13

AstroとReactでブログを作るときにテストでちょっと詰まった点について

#Astro#Storybook#Test
test_trouble_with_storybook_astro

ダイの大冒険という漫画が大好きなbun913です。

みなさんフロントエンドのテストをちゃんと書かれていますか?フロントエンドのテストは「何を目的にどういうテストをすればよいの?」と迷うことが沢山ありますよね。

でも「リファクタリング」を行うにしろ、テストなくして心理的安全性を担保できませんし、何より「外部から見た振る舞いを変えることなく内部を良くする」ことを担保することなんて不可能ですよね。

このサイトでもフロント分からんマンとはいえ、極力一生懸命調べながらテストを書くようにしています。

このブログサイトを Astro + Reactで構築するにあたって、ほぼ状態を持たないサイトとはいえ若干詰まった点があったので、困った点と解決策について紹介します。

今回は以下の点について記載しています。

  • AstroのContent Collectionsの関数に依存する部分のテストについて(モック化)
  • ReactのStorybookで画面サイズによって見た目が変わるコンポーネントのテストについて(ビューポートの設定)

この記事を読むことで以下のような点で嬉しいかもしれません。

  • (Astroに限らず)動的な外部モジュールをjestでモック化するための方法の一つがわかる
  • Storybookのテストランナーでビューポートを設定する方法がわかる
    • ビューポートに依存するコンポーネントのテストがしやすくなるかも

AstroのgetCollectionなどのモック化について

つまったところ

AstroではContent Collectionsという仕組みを利用して、例えばブログ用の記事などを一連のコレクションとして取り扱えます。

このブログを構築する際に、以下の記事を参考にしましたが、以下のような形でコレクションを定義することで、例えば src/content/blog 配下に保存した記事を「ブログ用のコレクション」としてAstroの仕組みで参照・取得できます。

// 以下の定義は上記参考記事をコピーさせていただいています
import { z, defineCollection } from "astro:content"

// zodで各項目のバリデーションを設定
const blogCollection = defineCollection({
  schema: z.object({
    title: z.string().max(100).min(1), // titleは1文字以上100文字以下
    tags: z.array(z.string()).max(5).min(1), // タグは1つ以上5個まで
    date: z.string().regex(/^\d{4}-(0?[1-9]|1[0-2])-(0?[1-9]|[12]\d|3[01])$/), // yyyy-mm-dd形式
  }),
})

// `blog` という名前で上記のコレクション定義を登録
export const collections = {
  blog: blogCollection,
}

このような定義をした上で、npx astro synk を実行することで .astro ディレクトリにコレクションを扱うための型などのメタデータが生成されます。

例えば、astro:contentモジュールは.astro/types.d.tsにて定義されています。

declare module "astro:content" {
  interface Render {
    ".md": Promise<{
      Content: import("astro").MarkdownInstance<{}>["Content"]
      headings: import("astro").MarkdownHeading[]
      remarkPluginFrontmatter: Record<string, any>
    }>
  }
}

その上で、astro:contentgetCollection などの関数を利用してブログ記事の一覧などを取得可能です。

import { getCollection } from "astro:content"

export async function getAllBlogs(): Promise<Blog[]> {
  const blogs = await getCollection("blog")
  const extracted = blogs.map((blog) => {
    const { slug, body } = blog
    const title = blog.data.title
    const tags = blog.data.tags
    const date = blog.data.date
    return { title, tags, date, slug, body, image: blog.data.image }
  })
  const sorted = extracted.sort(
    (a, z) => new Date(z.date).getTime() - new Date(a.date).getTime(),
  )
  return sorted
}

今回、このgetAllBlogsの単体テストを書くにあたってgetCollectionの関数に依存しているため、モック化しようと考えました。

通常jestを活用してモックを利用したい際には、以下の記事のようにmockspyOnといった関数を利用します。

Astroはアプリケーション実行時に動的に関数やJSファイルなどを生成するようで、Jestのようにアプリケーションの実行タイミングとは関係なく実行されるものとの取り回しが悪そうです。

実際mockspyOnで頑張ろうとしたのですが、難しかったため以下の方法を利用しました。

採用した方法

JestのmoduleNameMapperという仕組みを利用しました。

まずastro:contentというモジュールについて、jest実行時にはあらかじめ準備したモック関数を見に行くように設定します。

jest.config.js
export default {
  "roots": [
    "<rootDir>/src"
  ],
  preset: "ts-jest",
  testEnvironment: "jest-environment-jsdom",
  testMatch: [
    "**/__tests__/**/*.+(ts|tsx|js)",
    "**/?(*.)+(spec|test).+(ts|tsx|js)"
  ],
  transform: {
    "^.+\\.tsx?$": "ts-jest"
  },
  // ここでモック用関数(モジュール)の場所を指定
  moduleNameMapper: {
    "^astro:content$": "<rootDir>/src/test/astroMock/content",
  },
};

次に指定したsrc/test/astroMock/content.tsというファイルにモック用の関数を定義しました。

src/test/astroMock/content.ts
export const getCollection = () => {
  // 6件のブログを返す
  return Promise.resolve([
    {
      id: "blog_with_cloudflare",
      slug: "blog_with_cloudflare",
      body: "みなさん始めまして。\nダイの大冒険は好きですか",
      collection: "blog",
      data: {
        title: "Cloudflare Pagesでブログを作ってみた",
        tags: ["Cloudflare", "Atro"],
        date: "2021-08-07",
        image: {
          url: "/blog/cloudflare.png",
          alt: "Cloudflare Pages",
        },
      },
// 省略

これによりjest実行時に上記のモック用関数が利用され、単体テストを書くことができました。

// astro:contentは動的に生成されるファイルなので,src/astroMock/contentをモックとして参照させている
// jest.config.jsを参照
describe("getAllBlogs", () => {
  it("should return all blogs", async () => {
    const allBlogCollections = await getAllBlogs()
    expect(allBlogCollections).toHaveLength(6)
  })
  it("should return all blogs sorted by date", async () => {
    const allBlogCollections = await getAllBlogs()
    const sorted = allBlogCollections.sort(
      (a, z) => new Date(z.date).getTime() - new Date(a.date).getTime(),
    )
    expect(allBlogCollections).toEqual(sorted)
  })
})

StroybookのTest RunnerでViewPortを設定する

つまったところ

今回とあるReactのコンポーネントの挙動で「特定の画面サイズ以下で表示される」といった挙動をするコンポーネントを作成していました。

※ 本来コンポーネント側で表示される・非表示という状態を持つべきではないかもしれませんが、一旦そこは論点から外しています。

このブログではStorybookを活用しており、Storybookのプレビューでは画面サイズ(ビューポート)のデフォルトサイズを指定していて、ビジュアル的に確認できるようにしています。

mobleViewPortImage

tabletViewPortImage

この設定は、Viewportにも書かれているaddonを利用していて.storybook/preview.jsに以下のように記載しています。

.storybook/preview.js
import 'tailwindcss/tailwind.css';
// 事前にaddonをnpmやyarnなどでインストールが必要
import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport';

const preview = {
  parameters: {
    actions: { argTypesRegex: "^on[A-Z].*" },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
    // ここでaddonを利用できるようにする
    viewport: {
      viewports: INITIAL_VIEWPORTS,
    },
  },
};

export default preview;

これにより、Storybookで以下のようにビューポートを指定することで、上記画面のようにデフォルトのビューポートを設定可能です。

export const MobileView: Story = {
  args: {
    handleClick: action("Button Clicked!"),
    isOpen: false,
  },
  parameters: {
    // デフォルトのビューポートを指定
    viewport: {
      defaultViewport: "iphone6",
    },
  },
  // ここはStorybookのテストランナーによるテストを記載
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement)
    const button = await canvas.findByRole("button")
    await step("Button is Visible", () => {
      expect(button).toBeVisible()
    })
  },
}

上記のようにplay関数の中で特定の画面サイズでは表示されているといった内容のテストを記載しており、@storybook/test-runnerの仕組みを利用しています。

ところが、こちらをnpm run test-storybookを実行しても意図した挙動とはなりません。

なぜなら、上記のデフォルトのビューポートの設定はpreviewの際の設定であり、CLIによるテスト実行時には反映されていないためです。

そこで、以下のような方法を活用してnpm run test-storybook実行時のデフォルトのビューポートを設定できるようにしました。

test-runner.jsの作成

test-runnerでのビューポートの設定について、ドキュメントを検索していると以下のIssueに辿り着きました。

どうやらこの方も「test-runner」実行時にビューポートが全てデフォルトの設定になって困っているようです。

上記Issueにかかれているように .storybook/preview.jsでプレビュー時の挙動を制御できたように、.storybook/test-runner.jsでテスト実行時の挙動や設定を制御できるようです。

そこで、以下のようにtest-runner.jsでaddonv-viewportの仕組みを利用できるようにコードを記載しました。

const { getStoryContext } = require("@storybook/test-runner")
const { INITIAL_VIEWPORTS } = require("@storybook/addon-viewport")

const DEFAULT_VP_SIZE = { width: 1280, height: 720 }

module.exports = {
  async preRender(page, story) {
    const context = await getStoryContext(page, story)
    const vpName = context.parameters?.viewport?.defaultViewport
    const vpParams = INITIAL_VIEWPORTS[vpName]

    if (vpParams) {
      const vpSize = Object.entries(vpParams.styles).reduce(
        (acc, [screen, size]) => ({
          ...acc,
          [screen]: parseInt(size),
        }),
        {},
      )

      page.setViewportSize(vpSize)
    } else {
      page.setViewportSize(DEFAULT_VP_SIZE)
    }
  },
}

これにより、以下のようにstories.tsxでviewPortを指定することで、特定のビューポートの際に画面上に表示される・されないといったビジュアル面でのテストができました。

export const MobileView: Story = {
  args: {
    handleClick: action("Button Clicked!"),
    isOpen: false,
  },
  parameters: {
    viewport: {
      defaultViewport: "iphone6",
    },
  },
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement)
    const button = await canvas.findByRole("button")
    await step("Button is Visible", () => {
      // モバイルサイズの時には表示上見える
      expect(button).toBeVisible()
    })
  },
}

export const TabletView: Story = {
  args: {
    handleClick: action("Button Clicked!"),
    isOpen: false,
  },
  parameters: {
    viewport: {
      defaultViewport: "ipad",
    },
  },
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement)
    const button = canvas.queryByRole("button")
    await step("Button can not find", () => {
      // タブレットサイズになると見えない
      expect(button).toBeNull()
    })
  },
}

まとめ

概ね以下のような感じです。

  • Astroで動的に生成される関数や型はjestのmoduleNameMapperの仕組みでモック化した
  • Storybookで画面サイズによる見た目や挙動の変化を確認するには.stroybook/test-runner.jsを変更する必要があった

フロントエンドのテストは多岐にわたっていて、キャッチアップが大変ですね。

私も「フロント分からん」「CSS分からん」なりにキャッチアップを続けたいと思います。

以上、最後まで読んでいただきありがとうございました。