技術の変遷は秋の空、ワイの興味は飽きの空。時々OSSとテスト。
ダイの大冒険という漫画が大好きなbun913です。
みなさんフロントエンドのテストをちゃんと書かれていますか?フロントエンドのテストは「何を目的にどういうテストをすればよいの?」と迷うことが沢山ありますよね。
でも「リファクタリング」を行うにしろ、テストなくして心理的安全性を担保できませんし、何より「外部から見た振る舞いを変えることなく内部を良くする」ことを担保することなんて不可能ですよね。
このサイトでもフロント分からんマンとはいえ、極力一生懸命調べながらテストを書くようにしています。
このブログサイトを Astro + Reactで構築するにあたって、ほぼ状態を持たないサイトとはいえ若干詰まった点があったので、困った点と解決策について紹介します。
今回は以下の点について記載しています。
この記事を読むことで以下のような点で嬉しいかもしれません。
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:content
の getCollection
などの関数を利用してブログ記事の一覧などを取得可能です。
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を活用してモックを利用したい際には、以下の記事のようにmock
やspyOn
といった関数を利用します。
Astroはアプリケーション実行時に動的に関数やJSファイルなどを生成するようで、Jestのようにアプリケーションの実行タイミングとは関係なく実行されるものとの取り回しが悪そうです。
実際mock
やspyOn
で頑張ろうとしたのですが、難しかったため以下の方法を利用しました。
JestのmoduleNameMapper
という仕組みを利用しました。
まずastro:content
というモジュールについて、jest実行時にはあらかじめ準備したモック関数を見に行くように設定します。
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
というファイルにモック用の関数を定義しました。
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)
})
})
今回とあるReactのコンポーネントの挙動で「特定の画面サイズ以下で表示される」といった挙動をするコンポーネントを作成していました。
※ 本来コンポーネント側で表示される・非表示という状態を持つべきではないかもしれませんが、一旦そこは論点から外しています。
このブログではStorybookを活用しており、Storybookのプレビューでは画面サイズ(ビューポート)のデフォルトサイズを指定していて、ビジュアル的に確認できるようにしています。
この設定は、Viewportにも書かれているaddonを利用していて.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でのビューポートの設定について、ドキュメントを検索していると以下の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()
})
},
}
概ね以下のような感じです。
moduleNameMapper
の仕組みでモック化した.stroybook/test-runner.js
を変更する必要があったフロントエンドのテストは多岐にわたっていて、キャッチアップが大変ですね。
私も「フロント分からん」「CSS分からん」なりにキャッチアップを続けたいと思います。
以上、最後まで読んでいただきありがとうございました。