Closed11

フロントエンド開発のためのテスト入門を読んだ

ぱんだぱんだ

2章 テスト手法とテスト戦略

  • サーバーではテストピラミッドが例に出されることがほとんどで1番下の単体テストをいっぱい書こうねとされている

  • 本書でも紹介されているがフロントエンドではテスティングトロフィーが例に出されることが多い

  • テスティングトロフィーでは結合テストが最も多く書かれるべきとされていて、これは現代のコンポーネント指向のフロントエンド開発において単体のコンポーネントだけでは成立する機能はほとんどないためだそうだ

  • サーバーにおける単体テストは主にドメイン層における純粋なビジネスロジックをテストすることを指していることが多く、最もテストしやすく重要な箇所なので単体テストを書くことが重要とされているのは理解できる

  • しかし、フロントエンドではコンポーネントが正しく実装されていることはもちろん重要だがそれらのコンポーネントを組み合わせて画面を構築できているか、機能が正しく動作しているかなどが重要となってくるのだ

  • そもそも、ほとんどの画面はAPI通信を伴うことがほとんどだと思うので、結合テストを書きたくなるのは当然だろう

  • 単体テスト

    • 純粋なロジック
    • コンポーネントに対するテスト(これはStroyBookが使える)
  • 結合テスト

    • Jestやvitestを使って書く
    • API通信はJestのmock関数やmswを使ってモックにして書くことがほとんど
    • StoryBookを作っていればコンポーネントのレンダリングのための事前準備のロジックをそのまま流用できる
    • 結合テストを実現するためには実際のユーザー操作をテストで表現する必要があり、これにはヘッドレスブラウザを使う方法もあるが、Testing LibraryとJest(jest-dom)を使用することで実行速度の速いユーザー操作を再現した結合テストを書くことができる
  • ビジュアルリグレッションテスト

    • スナップショットとの差分を見ることで画面のデグレを検知することができる。
    • これにもStoryBookとreg-cliを使うことで実現できる
    • ビジュアルリグレッションテストはe2eテストと同じ感覚でいたのでコストが高いのかなと思っていたがStoryBookがすでに存在する場合、そこまで大変ではなさそう
    • さらに、CI環境も構築することでPR作成時にリグレッションを確認した上でレビューできる
  • E2Eテスト

    • 最もユーザー操作に近い環境でテストするためヘッドレスブラウザを使う
    • 本書ではplaywrightが紹介されている
    • APIやDBもモックを使わないでテストするため、dockerなどで実際にフロント以外も起動したうえでテストする
ぱんだぱんだ

第3章 はじめの単体テスト

とりあえず、簡単なユニットテストを書いてみる。

calc.ts
export type Operator = "+" | "-" | "*" | "/";

export function calc(
	x: number,
	y: number,
	operator: Operator,
):
	| {
			tag: "success";
			result: number;
	  }
	| {
			tag: "error";
			msg: string;
	  } {
	let result: number;
	switch (operator) {
		case "+":
			result = x + y;
			break;
		case "-":
			result = x - y;
			break;
		case "*":
			result = x * y;
			break;
		case "/":
			if (y === 0) {
				return {
					tag: "error",
					msg: "zero divided",
				};
			}
			result = x / y;
			break;
	}
	return {
		tag: "success",
		result,
	};
}

これのテストを書いてみる。spockのデータ駆動テスト、Goのテーブル駆動テストのような最初にテストしたいパターンを列挙する書き方が好きなのでvitestでもそのような書き方をしたい。it.each()を使うとできそう。

calc.test.ts
import { describe, expect, it } from "vitest";
import { type Operator, calc } from "./calc";

describe("calc test", () => {
	type TestCase = {
		x: number;
		y: number;
		operator: Operator;
		expected: number;
	};

	const tests: TestCase[] = [
		{ x: 1, y: 1, operator: "+", expected: 2 },
		{ x: 1, y: 1, operator: "-", expected: 0 },
		{ x: 1, y: 5, operator: "-", expected: -4 },
		{ x: 1, y: 5, operator: "*", expected: 5 },
		{ x: 10, y: 5, operator: "/", expected: 2 },
	];

	it.each(tests)(
		"$x $operator $y = $expected",
		({ x, y, operator, expected }) => {
			const act = calc(x, y, operator);
			expect(act).toEqual({ tag: "success", result: expected });
		},
	);

	it("zero divide", () => {
		const act = calc(10, 0, "/");
		expect(act).toEqual({ tag: "error", msg: "zero divided" });
	});
});

ぱんだぱんだ

第4章 モック

  • ちょっと最初に用語の整理
  • スタブとモックの違いは単体テストの考え方や実践テスト駆動開発で学んだ
  • スタブは内側にダミーの値を返すもので、モックは外側に作用するものをダミーに置き換える
  • vitestやjestではmock()とspyOn()という風にスパイという用語が出てくる
  • 多くの場合、これらmock()やspyOn()はスタブとして使われることが多い、と思う
  • spyは実際に存在するものをそのまま使い、ダミーの動作に上書きしたり動作を監視するもののため実装が存在している必要がある
  • mockは完全にダミーオブジェクトを使うので存在しないものをモック化することも可能
  • 以下はimportしたモジュールをモックに置き換える例。Jestではjest.mock("<モック対象のファイルパス>")で書いていた処理。

モックモジュール

  • モジュール自体をモックにする
  • テストファイルの最初にモジュールをモック化する
  • 使用用途としては外部ライブラリの置き換えが考えられる
say.ts
export function sayHello() {
	return "Hello";
}

export function sayBye() {
	return "Bye";
}

say.test.ts
// モックにするだけ
vi.mock("./say")

it("mock method is undefined", () => {
 	expect(sayHello()).toBeUndefined();
 });

// 関数の処理を上書きする
vi.mock("./say", () => {
	return {
		sayHello: () => "Hello!!",
	};
});

it("stub", () => {
	expect(sayHello()).toBe("Hello!!");
});

// 元の実装の処理を引き継ぐ
vi.mock("./say", async (importOriginal) => {
	return {
		...(await importOriginal<typeof import("./say")>()),
		sayHello: () => "Hello!!",
	};
});

it("stub", () => {
	expect(sayHello()).toBe("Hello!!");
	expect(sayBye()).toBe("Bye");
});

APIのモック

  • spyOnを使うことで指定のオブジェクトの関数をモックにすることができる
  • スタブとして期待する挙動に上書きすることもできる
api.test.ts
test("データ取得成功時: ユーザー名があるとき", async () => {
	vi.spyOn(Fetchers, "getMyProfile").mockResolvedValueOnce({
		id: "xxxxxxx-123456",
		email: "test@test.com",
		name: "yamanaka",
	});
	await expect(getGreet()).resolves.toBe("Hello, yamanaka!");
});

test("データ取得失敗時", async () => {
	vi.spyOn(Fetchers, "getMyProfile").mockRejectedValueOnce(httpError);
	await expect(getGreet()).rejects.toMatchObject({
		err: { message: "internal server error" },
	});
});

モック生成関数を使ったテスト

  • スタブが返す値を固定ではなく動的に変えたい
  • それには入力の値によってスタブが返す値を変えるようなユーティリティー関数を用意してあげると実現しやすい
function mockGetMyArticles(status = 200) {
	if (status > 299) {
		return vi.spyOn(Fetchers, "getMyArticles").mockRejectedValueOnce(httpError);
	}
	return vi
		.spyOn(Fetchers, "getMyArticles")
		.mockResolvedValueOnce(getMyArticlesData);
}
  • これを使ったテストは以下のようになる
test("記事一覧取得: success", async () => {
	mockGetMyArticles();
	const act = await getMyArticleLinksByCategory("nextjs");
	expect(act?.length).toBe(1);
});

test("記事一覧取得: failed", async () => {
	mockGetMyArticles(500);
	await getMyArticleLinksByCategory("testing").catch((err) => {
		expect(err).toMatchObject({
			err: { message: "internal server error" },
		});
	});
});

モック関数を使ったspy

  • vi.fn()を使ってモック関数を生成することができる
  • モック関数は呼ばれた回数、引数などを記録しているため検証できる
  • モック関数は高階関数を引数にとる関数のテストに利用できる機会が多い
test("モック関数は実行された", () => {
	const mockfn = vi.fn();
	mockfn();
	expect(mockfn).toBeCalled();
});

test("モック関数は実行されていない", () => {
	const mockfn = vi.fn();
	expect(mockfn).not.toBeCalled();
});

test("モック関数を使ったアサーション", () => {
	const mockfn = vi.fn();
	mockfn("hello");
	expect(mockfn).toHaveBeenCalledTimes(1);
	expect(mockfn).toHaveBeenCalledWith("hello");
});

タイマー

  // mockタイマーの使用を支持する
  vi.useFakeTimers()
  // mockタイマーの使用を解除する
  vi.useRealTimers()
  // mockタイマーに現在時刻として使用される時刻をセットする
  vi.setSystemTime(new Date())
ぱんだぱんだ

第5章 UIコンポーネントテスト

  • UIコンポーネントとのテストをする場合、DOM操作が伴うためブラウザ環境でのテスト実行が必要
  • vitestにはjsdomもしくはhappy-domoが利用可能
  • happy-domの方が高速らしいがAPIはjsdomの方が豊富らしい
  • vitestでjsdomなどの環境指定するにはテストファイルごとに指定することもできるし、vitest.conft.tsなどに記載することでグローバル設定にすることもできる
  • グローバルに設定する場合はコンポーネントのテストのみに環境を適用するようにしたほうがいいかもしれない
vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environmentMatchGlobs: [
      ['src/components/**/*.test.ts', 'happy-dom']
    ]
  }
})
  • UIコンポーネントのテストには他にもコンポーネントをレンダリングして、ユーザー操作やイベントを発火させ、アサーションするのにTesting Libraryを使う
  • 以下はVueプロジェクトにTesting Libraryを導入する例
pnpm add -D @testing-library/vue
// カスタムマッチャーを使うために
pnpm add -D @testing-library/jest-dom
// インタラクションなユーザー操作を再現するために
pnpm add -D @testing-library/user-event
  • happy-domを使う場合、依存関係に追加しないといけなかった
pnpm add -D happy-dom
  • 以下のエラーが出る
Error: Failed to parse source for import analysis because the content contains invalid JS syntax. Install @vitejs/plugin-vue to handle .vue files.
  • エラーメッセージにあるようにpluginをインストールしてみる
pnpm add -D @vitejs/plugin-vue
  • vitest.config.tsにpluginを追加する
vitest.config.ts
+import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vitest/config";

export default defineConfig({
+	plugins: [vue()],
	test: {
		environmentMatchGlobs: [["src/components/**/*.test.ts", "happy-dom"]],
	},
});

  • これでUIコンポーネントのテストをする準備はok
  • 以下のようなFormコンポーネントをテストしてみる
src/components/Form.vue
<script setup lang="ts">
defineProps<{ name: string; onSubmit?: (e: Event) => void }>();
</script>

<template>
  <form>
    <h2>アカウント情報</h2>
    <p>{{ name }}</p>
    <div>
      <button>編集する</button>
    </div>
  </form>
</template>

  • テストは以下のような感じ
src/components/Form.test.ts
import { render } from "@testing-library/vue";
import Form from "./Form.vue";

test("名前の表示", () => {
	const { getByText } = render(Form, {
		props: {
			name: "yamanaka",
		},
	});
	expect(getByText("yamanaka")).toBeInTheDocument();
});
  • Testing LibraryではHTML要素をロールを指定して取得することもできる
  • 今回用意したFormコンポーネントでは明示的にRoleを指定していないが、暗黙的に指定されるRoleを使って要素を取得している
  • Testing Libraryを用いたテストは副次的な作用として、Roleを使って要素を取得できるようにしないとテストが書けないため、アクセシビリティの向上が見込める
Form.test.ts
import { render } from "@testing-library/vue";
import Form from "./Form.vue";

test("フォームコンポーネントが正常に表示されていること", () => {
	const { getByText, getByRole } = render(Form, {
		props: {
			name: "yamanaka",
		},
	});
	expect(getByText("yamanaka")).toBeInTheDocument();
	expect(getByRole("button")).toBeInTheDocument()
	expect(getByRole("heading")).toHaveTextContent("アカウント情報")
});

  • イベントハンドラーのテストにはmock関数が使える
Form.test.ts
test("ボタンをクリックするとイベントハンドラーが実行される", () => {
	const mockFn = vi.fn()
	const { getByRole } = render(Form, {
		props: {
			name: "yamanaka",
			onSubmit: mockFn
		}
	})
	fireEvent.click(getByRole("button"))
	expect(mockFn).toHaveBeenCalled()
})
  • 一覧表示のUIコンポーネントのテスト
ArticleList.test.ts
ArticleList.test.ts
import { render, within } from '@testing-library/vue';
import { ItemProps } from './ArticleItem.vue';
import ArticleList from './ArticleList.vue';

const items: ItemProps[] = [
  {
    id: 'howto-testing-with-typescript',
    title: 'TypeScriptを使ったテストの書き方',
    body: 'テストを書くとき、TypeScriptを使うことで、テストの保守性が向上します…',
  },
  {
    id: 'nextjs-link-component',
    title: 'Next.jsのLinkコンポーネント',
    body: 'Next.jsの画面遷移には、Linkコンポーネントを使用します…',
  },
  {
    id: 'react-component-testing-with-jest',
    title: 'JestではじめるReactのコンポーネントテスト',
    body: 'Jestは単体テストとして、UIコンポーネントのテストが可能です…',
  },
];

function _render(_items: ItemProps[]) {
  return render(ArticleList, {
    props: {
      items: _items,
    },
  });
}

test('itemsの数だけ一覧表示される', () => {
  const { getAllByRole } = _render(items);
  expect(getAllByRole('listitem')).toHaveLength(3);
});

test('一覧が表示されること', () => {
  const { getByRole } = _render(items);
  expect(getByRole('list')).toBeInTheDocument();
});

test('itemsの数だけ一覧表示される', () => {
  const { getByRole } = _render(items);
  const list = getByRole('list');
  expect(list).toBeInTheDocument();
  expect(within(list).getAllByRole('listitem')).toHaveLength(3);
});

test('一覧アイテムがからの時「投稿記事がありません」が表示される', () => {
  const { queryByRole, getByText } = _render([]);
  const list = queryByRole('list');
  expect(list).not.toBeInTheDocument();
  expect(getByText('投稿記事がありません')).toBeInTheDocument();
});

  • <ul>要素はrole(list)で取得できる

  • <li>要素はrole(listitem)で取得できる

  • 要素がないことを検証するときはgetByRole関数を使うとエラーになってしまうのでqueryByRole関数を使うと良い

  • 一覧要素のコンポーネントのテスト

ArticleItem.test.ts
import { render, RenderResult, within } from '@testing-library/vue';
import ArticleItem, { ItemProps } from './ArticleItem.vue';

const item: ItemProps = {
  id: 'test',
  title: 'test-title',
  body: 'testです',
};

function _render(_item: ItemProps): RenderResult {
  return render(ArticleItem, { props: _item });
}

test('一覧要素として表示される', () => {
  const { getByRole } = _render(item);
  expect(getByRole('listitem')).toBeInTheDocument();
});

test('記事タイトルが表示される', () => {
  const { getByRole } = _render(item);
  expect(getByRole('heading', { level: 3 })).toHaveTextContent('test-title');
});

test('記事の内容が表示される', () => {
  const { getByText } = _render(item);
  expect(getByText('testです')).toBeInTheDocument();
});

test('指定のidのリンクが表示される', () => {
  const { getByRole } = _render(item);
  expect(getByRole('link', { name: 'もっと見る' })).toHaveAttribute(
    'href',
    '/articles/test'
  );
});

  • 一個ずつ要素があるか確認するようなテストになってしまったが、どこまでやるのがいいんだろうか
  • 全部やるのもあれなので最低限propsなどの変数を参照して要素を表示するようなところだけ書くのがいいんだろうか

スナップショット

  • スナップショットを用いたリグレッションテストは後述するStoryBookを作成していれば実施できるがvitestのtoMatchSnapshot()を使うことでもできる
  • 以下のようにPropsやmock関数を与えた状態のコンポーネントのスナップショットを
test('Snapshot', () => {
  const { container } = renderAddressForm({ deliveryAddresses });
  expect(container).toMatchSnapshot();
});

  • __snapshoto__配下に保存する。
  • 次回、以降はこのスナップショットと比較することでUIコンポーネントのリグレッションを検知することができる
  • UIコンポーネントのスナップショットを更新する場合はテスト実行コマンドに-updateSnapshotのオプションをつけて実行する
  • サーバー側のゴールデンテストと同じ感じだ

アクセシビリティについて

  • Testing Libraryの以下のログ関数を使うことでレンダリングしたUIコンポーネントの暗黙的なroleやアクセシビリティを確認することができる
test('アクセシビリティの確認', () => {
  const { container } = renderAddressForm({ deliveryAddresses });
  logRoles(container as HTMLElement);
});
  • 要素の取得をロールなどで行うようにしてテストを書くことで、アクセシビリティを意識したコードを書くことができる
ぱんだぱんだ

テストダブルの分類について

mockやstub, spyといった用語を正確に使い分けられていないので少し調べた。
t-wadaさんも引用していた気がするが以下の記事がx-unitの文脈に沿っていて1番信用できる分類基準っぽい。

https://goyoki.hatenablog.com/entry/20120301/1330608789

記事の引用

分類方法
 Test Doubleの分類方法は以下のような感じです。

テストの範囲内で本物と同じように動作するTest DoubleはFake Object。
内部のパラメータや状態がなんでもあってもテストに影響を及ぼさない代替オブジェクトなら、Dummy Object
上記以外で、テスト対象の間接出力を受け取り、かつ自身でその検証を行うTest DoubleはMock Object
上記以外で、テスト対象の間接出力を受け取りそれをあとから参照可能にするTest DoubleはTest Spy
上記以外で、テスト対象の間接入力を操作できるTest DoubleはTest Stub

間接入力と間接出力について正確に理解する必要があるが、テストダブルに与える引数が間接出力、テストダブルが返す値が間接入力。

APIなんかをモックにするという文脈では期待する値を返すようにテストダブルを設定するので、それはAPI呼び出しの間接入力を良い感じに設定するのでこれはスタブとしてテストダブルを使っている。

実践テスト駆動開発に書いてあった気がするがスタブは基本検証すべきでない。スタブはテストを実行するのに必要な挙動を与えるだけで検証したい内容ではないはずだから。

vitestでの以下のような使い方はまさにスタブとしての使い方。

test("データ取得成功時: ユーザー名があるとき", async () => {
	vi.spyOn(Fetchers, "getMyProfile").mockResolvedValueOnce({
		id: "xxxxxxx-123456",
		email: "test@test.com",
		name: "yamanaka",
	});
	await expect(getGreet()).resolves.toBe("Hello, yamanaka!");
});

モックとスパイは混同しやすいがライブラリを使用するうえではだいたいテスト対象の処理を実行したあとにテストダブルが記録した値を検証することが多いのでスパイとして使っていることになる。

vitestでいうと```vi.fn()``を使ったテストがスパイになりそう。

test("モック関数を使ったアサーション", () => {
	const mockfn = vi.fn();
	mockfn("hello");
	expect(mockfn).toHaveBeenCalledTimes(1);
	expect(mockfn).toHaveBeenCalledWith("hello");
});

あとはテスト実行にあたって興味はないが用意する必要がある外部依存なんかはダミーになり得そう。APIサーバーを模倣したモックサーバーなんかを用意するケースなんかはフェイクになり得そう。

vitestやjestの関数名がややこしいがこんな感じの理解でだいたい良さそう。

ぱんだぱんだ

第7章 Webアプリケーション結合テスト

ここでは主にmswを使ったAPIとやりとりをするビューの結合テストの書き方。
正直viewを用意するのがしんどい。

cursorを使い始めたのでcomposerで記事一覧表示のviewを作ってもらった。

ArticleList.vue
src/views/ArticleList.vue
<template>
  <div class="article-list">
    <h1>記事一覧</h1>

    <!-- ローディング表示 -->
    <div v-if="loading" class="loading">読み込み中...</div>

    <!-- エラー表示 -->
    <div v-else-if="error" class="error">
      {{ error }}
    </div>

    <!-- 記事一覧 -->
    <div v-else class="articles">
      <div v-for="article in articles" :key="article.id" class="article-item">
        <h2>{{ article.title }}</h2>
        <p>{{ article.summary }}</p>
        <div class="article-meta">
          <span>投稿日: {{ formatDate(article.createdAt) }}</span>
          <router-link :to="`/articles/${article.id}`" class="read-more"> 続きを読む </router-link>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

interface Article {
  id: number
  title: string
  summary: string
  createdAt: string
}

const articles = ref<Article[]>([])
const loading = ref(true)
const error = ref<string | null>(null)

// 記事一覧を取得する関数
const fetchArticles = async () => {
  try {
    // APIエンドポイントは適宜変更してください
    const response = await fetch('/api/articles')
    if (!response.ok) {
      throw new Error('記事の取得に失敗しました')
    }
    const data = await response.json()
    articles.value = data
  } catch (e) {
    error.value = e instanceof Error ? e.message : '予期せぬエラーが発生しました'
  } finally {
    loading.value = false
  }
}

// 日付のフォーマット関数
const formatDate = (dateString: string) => {
  return new Date(dateString).toLocaleDateString('ja-JP')
}

// コンポーネントのマウント時に記事を取得
onMounted(() => {
  fetchArticles()
})
</script>

<style scoped>
.article-list {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.loading,
.error {
  text-align: center;
  padding: 20px;
}

.error {
  color: red;
}

.article-item {
  border-bottom: 1px solid #eee;
  padding: 20px 0;
}

.article-item h2 {
  margin: 0 0 10px 0;
  color: #333;
}

.article-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 10px;
  color: #666;
}

.read-more {
  color: #0066cc;
  text-decoration: none;
}

.read-more:hover {
  text-decoration: underline;
}
</style>

  • 上記の記事一覧表示画面ではonMountedでfetchして記事一覧を取得する想定
  • このfetchをmswでインターセプトして任意の値を返すようにする
  • とりあえず、mswを追加する
pnpm add -D msw@latest
  • mswを追加したら、以下のようなsetup関数を作成する
src/test/setup.ts
import type { RequestHandler } from 'msw'
import { setupServer } from 'msw/node'
import { afterAll, afterEach, beforeAll } from 'vitest'

export function setupMockServer(...handlers: RequestHandler[]) {
  const server = setupServer(...handlers)
  beforeAll(() => server.listen())
  afterEach(() => server.resetHandlers())
  afterAll(() => server.close())
  return server
}

  • mswはブラウザでもnode環境でも動く
  • テストはとりあえずnode環境想定で良い
  • 上記のsetup関数に渡すハンドラーは以下のような感じ
src/views/__mock__/msw.ts
import { http, HttpResponse } from "msw"
import { articles } from "./fixture"

export function handleGetArticles() {
  return http.get('/api/articles', () => {
    return HttpResponse.json(articles)
  })
}

export const handlers = [handleGetArticles()]

  • これを使ったテストは以下のようになる
src/views/ArticleList.test.ts
import { expect, test } from 'vitest'
import { setupMockServer } from '../tests/setup'
import { handlers } from './__mock__/msw'
import { render } from '@testing-library/vue'
import ArticleList from './ArticleList.vue'

setupMockServer(...handlers)

test('記事一覧が表示される', async () => {
  const { findAllByRole } = render(ArticleList)
  expect(await findAllByRole('heading', { level: 2 })).toHaveLength(2)
})

  • 要素の取得はgetではなくfind系を使うことに注意
  • get系の関数は即座に取得しようとしてしまうためfetchの結果を待たず、期待した結果にならない
  • 非同期の結果を待って、再レンダリングされた後の要素を取得したいケースではfind系を使うと良い
  • vitestのvi.mockを使う場合、fetchの部分をモジュールに切り出して、 そのモジュールをモックにすることでmswを使ったテストと似たようなテストは書ける
  • mswは実際のhttp通信をインターセプトして値を書き換えているため、実際の挙動に近い状態のテストを実施できる
  • vitestのモックを使う場合、サーバーのテストと同様モックに差し替えられるようにモジュールの切り出しが必要なため、テストが書けるように設計されていることが重要
  • 一方、mswの場合、fetchの結果を都合よく差し替えるためfetchの処理をモジュールに切り出していなくても問題ないし、SWRのようなfetchをラップしたようなライブラリでもいいし、GraphQLとかも対応していそう。
  • 参照系はmswで適当なデータを返すようにしてテストを書くでいいが、更新系のfetchがちゃんと呼ばれたかテストしたい場合、これはモックを使って記録した値を検証する必要があるかもしれない。
  • mock関数をmswに仕込むという方法が紹介されていた

https://zenn.dev/takepepe/articles/jest-msw-mocking

  • 他にもmswはStroyBookやテスト以外の場所でも処理を使いまわせたりとメリットが多い
  • API通信が伴うのであればmswを積極的に使っていって良い気がする
ぱんだぱんだ

第8章 UI エクスプローラー

  • この章ではStory Bookについて紹介されていた
  • Story Bookはjsdomを使った結合テストとブラウザを使ったe2eテストの間に位置するようなテストも担うことができるようになっている
  • とりあえず、以下のコマンドで初期化できる
pnpx storybook init

初期化できたら以下のように起動できる

pnpm storybook

以下はサンプルとして用意されていたButtonコンポーネントのstoryファイルの一部。

import Button from './Button.vue';

// More on how to set up stories at: https://storybook.js.org/docs/writing-stories
const meta = {
  title: 'Example/Button',
  component: Button,
  // This component will have an automatically generated docsPage entry: https://storybook.js.org/docs/writing-docs/autodocs
  tags: ['autodocs'],
  argTypes: {
    size: { control: 'select', options: ['small', 'medium', 'large'] },
    backgroundColor: { control: 'color' },
  },
  args: {
    primary: false,
    // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
    onClick: fn(),
  },
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;
  • コンポーネントをimportしてMetaオブジェクト作る
  • 作ったオブジェクトをexport defaultする
  • StoryObjの型パラメーターにmetaオブジェクトの型を指定してStory型を定義する
  • Story型を使ってPropsのパターンを定義する
export const Primary: Story = {
  args: {
    primary: true,
    label: 'Button',
  },
};

export const Secondary: Story = {
  args: {
    primary: false,
    label: 'Button',
  },
};

export const Large: Story = {
  args: {
    label: 'Button',
    size: 'large',
  },
};

export const Small: Story = {
  args: {
    label: 'Button',
    size: 'small',
  },
};
  • StoryBookではpropsはargsで定義する
  • StroyBookでは3つの設定に分けられる
    • グローバル(.storybook/preview.js)
    • コンポーネント(export default)
    • ストーリー(export const)
  • SPレイアウトをstoryに登録したい場合は@storybook/addon-viewportを使ってパラメーターを設定してあげればいい
  • 本書では以下のような共通parameterを定義していた
import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport';

export const SPStory = {
  parameters: {
    viewport: {
      viewports: INITIAL_VIEWPORTS,
      defaultViewport: 'iphonex',
    },
  },
};
  • 使い方は以下のような感じ
export const SPLoggedIn: Story = {
  args: {
    user: {
      name: 'Jane Doe',
    },
  },
  parameters: { ...SPStory.parameters },
};
  • decoratorsというパラメータに関数を渡すことでコンポーネントをラップして表示するみたいなことができる。例えば全てのストーリーに余白をつけるなど
  • これを使うとReactのContextを使ったコンポーネントなんかのstoryも作成できる
  • APIに依存したようなコンポーネントはMSWが使える
pnpm add -D msw msw-storybook-addon
  • preview.tsにmswの初期化とdecoratorのexportを宣言しておく
.storybook/preview.ts
import { mswDecorator, initialize } from 'msw-storybook-addon';

export const decorators = [mswDecorator];

initialize();
  • MSWをプロジェクトで初めてインストールした場合、publicディレクトリのパスを宣言する必要があるらしい
pnpm msw init public
  • これを実行するとmockServiceWorker.jsが作成される
  • storybookにもpublicディレクトリの場所を明示しておく必要があるらしい
.storybook/main.ts
  staticDirs: ["../public"],
  • MSWをブラウザで使用するにはService Workerを使ってネットワークレベルで通信をインターセプトするらしいのだが、Service Workerをmsw initでpublicディレクトリ配下に作成し、storybookで初期化をしているらしい
  • StoryBookの使用だけであればたぶん大丈夫だがMSWの使用が本番アプリケーションに影響しないように初期化する必要がある。
  • 以下、適当にマウント時でデータフェッチするコンポーネントを作ってみる
src/components/Hello.vue
<script setup lang="ts">
import { onMounted, ref } from "vue";

type User = {
  name: string;
};

const data = ref<User>({ name: "" });
const loading = ref<boolean>(true);
const error = ref<Error | null>(null);

onMounted(async () => {
  try {
    const res = await fetch("/api/me");
    if (!res.ok) {
      throw new Error(`APIエラー: ${res.status}`);
    }
    data.value = await res.json();
  } catch (err) {
    error.value = err as Error;
  } finally {
    loading.value = false;
  }
});
</script>

<style scoped>
.container {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh; /* 画面の高さいっぱいに広げる */
}
.content {
  background-color: skyblue;
  padding: 20px;
  border-radius: 10px;
  width: 10rem;
  text-align: center;
}
</style>

<template>
  <div class="container">
    <div class="content">
      <p v-if="loading">Loading...</p>
      <p v-else-if="error">{{ error.message }}</p>
      <p v-else>{{ data.name }}</p>
    </div>
  </div>
</template>

  • このStoryを作るにはMSWを使ってデータ取得をモックする
  • MSWの処理はStoryBook以外にも使いまわせるのでどこか適切な場所に置いておくと良さそう
src/__mock__/me/msw.ts
import { http, HttpResponse } from "msw";
import { Mock } from "vitest";

export function handleMe(args?: {
  mock?: Mock;
  status?: number;
  delay?: number;
}) {
  return http.get("/api/me", async () => {
    args?.mock?.();
    if (args?.status) {
      return HttpResponse.json({}, { status: args.status });
    }
    if (args?.delay) {
      // 遅延
      await new Promise((resolve) => setTimeout(resolve, args.delay));
    }
    return HttpResponse.json({ name: "user" });
  });
}

export const handlers = [handleMe()];
  • Storyは以下のように作った
Hello.stories.ts
import { Meta, StoryObj } from "@storybook/vue3";
import Hello from "../components/Hello.vue";
import { handleMe } from "../__mock__/me/msw";

const meta = {
  title: 'Example/Hello',
  component: Hello,
  tags: ['autodocs'],
} satisfies Meta<typeof Hello>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
  parameters: {
    msw: {
      handlers: [handleMe()],
    },
  },
};

export const Loading: Story = {
  parameters: {
    msw: {
      handlers: [handleMe({ delay: 1000 })],
    },
  },
};

export const Error: Story = {
  parameters: {
    msw: {
      handlers: [handleMe({ status: 500 })],
    },
  },
};

  • ルーターに依存したストーリの作り方は飛ばした
  • アドオンを入れることでアクセシビリティのチェックもできるらしいが飛ばした
  • testing-libraryのStroyBookのadd-onを使うことでフォーム入力のようなインタラクションを伴ったコンポーネントライブラリを作ることもできる
  • らしいが、@storybook/testアドオンに含まれており、最初から依存関係は追加されているのに加え、サンプルとしても作成されていたのでとりあえず載せておく
src/stories/Page.stories.ts
import { expect, userEvent, within } from '@storybook/test';
import type { Meta, StoryObj } from '@storybook/vue3';

import MyPage from './Page.vue';

const meta = {
  title: 'Example/Page',
  component: MyPage,
  render: () => ({
    components: { MyPage },
    template: '<my-page />',
  }),
  parameters: {
    // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
    layout: 'fullscreen',
  },
  // This component will have an automatically generated docsPage entry: https://storybook.js.org/docs/writing-docs/autodocs
  tags: ['autodocs'],
} satisfies Meta<typeof MyPage>;

export default meta;
type Story = StoryObj<typeof meta>;

// More on interaction testing: https://storybook.js.org/docs/writing-tests/interaction-testing
export const LoggedIn: Story = {
  play: async ({ canvasElement }: any) => {
    const canvas = within(canvasElement);
    const loginButton = canvas.getByRole('button', { name: /Log in/i });
    await expect(loginButton).toBeInTheDocument();
    await userEvent.click(loginButton);
    await expect(loginButton).not.toBeInTheDocument();

    const logoutButton = canvas.getByRole('button', { name: /Log out/i });
    await expect(logoutButton).toBeInTheDocument();
  },
};

export const LoggedOut: Story = {};


  • コンポーネントテスト時と同様でgetByRoleなどで要素を取得したり、userEventを使ってインタラクションを実行したりする
  • こう書くことでStoryBookでコンポーネントを確認するときに特定のユーザー操作後のコンポーネントの状態を確認することができるのに加えて、そのユニットテストも兼ねることができる
  • 上記のようなStoryBookにユーザー操作を伴うテストが書かれている時、ブラウザで開いたStoryBookのダッシュボードのInteractionsのタグからテスト結果を確認したり実行することができる。

  • このテストをCLI上からテストランナーで実行するには@storybook/test-runnerを使えば良いらしい
pnpm add -D @storybook/test-runner

テストランナーを実行

pnpm test-storybook
  • 実行したところ以下のエラーがでた
║ Looks like Playwright Test or Playwright was just installed or updated. ║
║ Please run the following command to download new browsers:              ║
║                                                                         ║
║     pnpm exec playwright install                                        ║
║                                                                         ║
║ <3 Playwright Team
  • とりあえず言う通りに実行
pnpx playwright install
  • 通ったっぽい
pnpm test-storybook               

> storybook-demo@0.0.0 test-storybook /Users/yamanakajunichi/work/myapp/study/frontend-test/storybook-demo
> test-storybook

 PASS   browser: chromium  src/stories/Hello.stories.ts
 PASS   browser: chromium  src/stories/Header.stories.ts
 PASS   browser: chromium  src/stories/Page.stories.ts
 PASS   browser: chromium  src/stories/Button.stories.ts

Test Suites: 4 passed, 4 total
Tests:       12 passed, 12 total
Snapshots:   0 total
Time:        7.732 s
Ran all test suites.
  • 少しまとめておくとStoryBookのテストランナーはJestとplaywrightでローカルで起動しているStoryBookのダッシュボードに対して行うブラウザテスト
  • jsdomやhappy-domを使ったブラウザ環境をエミュレートしたような環境ではない
  • テストランナーを導入する目的としてはコンポーネントの変更(Propsの変更など)によりStoryBookが壊れてしまうことを防ぎ、StoryBookをアプリケーションコードの変更に追従させるという目的などがある
  • StoryBookでコンポーネントをレンダリングする準備の処理はJestやvitestを使ったコンポーネントテストに使い回すことができる
  • 具体的には@storybook/testing-vue3などを依存関係に追加してcomposeStories関数を使えば良いらしい。StoryBook8系から依存関係の追加をしなくても@storybook/vue3などからimportして使えるようになっているっぽい
  • MSWを使ってAPIをモックしてレンダリングする系のコンポーネントの場合、StoryBookの方でMSWのhandlerを設定していてもvitestの方でも設定する必要がある
  • 以下の例はStoryBookのstoryをimportして、MSWを起動しつつコンポーネントをレンダリングしてテストする例
Hello.test.ts
import { composeStories } from "@storybook/vue3";
import * as stories from "../stories/Hello.stories";
import { screen, waitFor } from "@testing-library/vue";
import { handleMe, handlers } from "../__mock__/me/msw";
import { setupMockServer } from "../test/setup";

describe("Default", () => {
  setupMockServer(...handlers);
  test("Default", async () => {
    const { Default } = composeStories(stories);
    await Default.run();
    await waitFor(() => expect(screen.getByText("user")).toBeInTheDocument());
  });
});

describe("Error", () => {
  setupMockServer(handleMe({ status: 500 }));
  test("500", async () => {
    const { Error } = composeStories(stories);
    await Error.run();
    await waitFor(() => expect(screen.getByText("APIエラー: 500")).toBeInTheDocument());
  })
});

describe("Loading", () => {
  setupMockServer(handleMe({ delay: 1000 }));
  test("1000ms", async () => {
    const { Loading } = composeStories(stories);
    await Loading.run();
    await waitFor(() => expect(screen.getByText("Loading...")).toBeInTheDocument());
  });
});

  • 上記の例ではStoryをimportするメリットはあまりないが、コンポーネントをレンダリングするのに準備処理がそれなりに必要なケースなんかではStoryを使いまわせるメリットはでかいかもしれない
ぱんだぱんだ

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

  • 前述したvitestのスナップショットテストでもリグレッションテストにはなるがスナップショットテストはレンダリングしたhtmlの構造をテキストベースで比較するため細かいスタイルの変更を検出できないなどがある
  • classでスタイルしている場合にclassのスタイル内容が変更になったりグローバルなcssスタイリングの変更などを検知できない
  • Storybookを使ったビジュアルリグレッションテストはブラウザベースでピクセル単位で変更差分を検知できる
  • 本書ではreg-cliとstorycapを使ってVRTを書く方法が紹介されている
  • 以下の記事で主要なVRT手法をまとめてくれている

https://zenn.dev/loglass/articles/visual-regression-testing-comparison

  • 記事によるとreg-cliとstorycapを使うのが良さそう
  • chromaticというマネージドサービスもお金のことを気にしなければ良さそう
  • StoryBookを導入していることが前提のため、StoryBookを使っていなければplaywrightのスナップショットテストもあり
  • 今回はreg-cliとstorycapを使ってVRTを書いてみる
  • reg-cliは2つの画像ファイルを比較して差分を出力する
pnpx reg-cli actual expected diff -R index.html
  • 上記のように期待するスナップショットと実際のスナップショットを指定して差分があればdiffに出力される。出力されるのは差分画像ファイル。
  • Rオプションでレポートのhtmlファイルを出力する
  • レポートファイルをひらくことで視覚的に差分を確認することができる

  • reg-cliはスナップショットを比較するツールでStorybookのスナップショットを使ってVRTを書くためのツールがstorycap
  • reg-cliは差分比較だけなのでStoryBookがなくても使える
pnpm add -D storycap
  • storycapをインストールしたら以下を記述
// preview.ts
import { withScreenshot} from 'storycap'

export const decorator = [withScreenshot]

// .storybook/main.ts
  addons: [
    ...
    'storycap'
  ],
  • 記述できたらスクリプトを追加しておく
    "storycap": "pnpx storycap http://localhost:6006 --serverCmd 'pnpx http-server storybook-static -p 6006'"
  • 追加できたら、以下のコマンドを実行しstorybookのビルドとスナップショットの作成を実行
pnpm build-storybook
pnpm storycap
  • storybookをビルドするのは開発サーバーを起動するよりも事前にビルドしておいて静的ファイルとして開く方が早いから
  • storycapはStorybookに登録されたStoryをVRT対象としてスナップショットを__snapshot__配下に保存する
  • 実際の現場ではGitHub ActionsなどのCI環境でVRTを実行することが多いと思うが、これにはreg-suitが使える
  • reg-suitを使うと外部ストレージサービスに保存された現在のスナップショットとstorycapを使って取得したスナップショットを比較し、差分があればレポートとして出力してくれたり、スナップショットの更新とかもろもろ良い感じにやってくれる
  • なので、実際はreg-cli単体で使うよりもCI環境でreg-suitを使うことのほうが多そう
  • 以下のような導入記事も多く出ている

https://dev.classmethod.jp/articles/visual-regression-testing-using-storycap-and-reg-suit/

  • ここからはストレージバケットの作成とGitHub Actionsの話なので省略するが以下の部分だけ注意
# GitHub Actionsのcheckout時にこれを指定しないと親コミットが取れず失敗する
with:
    fetch-depth: 0
ぱんだぱんだ

第10章 E2Eテスト

  • playwright
  • playwrightにはTesting LibraryにインスパイアされたLocatorというのもがあり、Testing Libraryのようにroleなどで要素を取得できる
  • 詳しい内容は今回は省略
  • いつかE2Eを書く日がきたらplaywrightのドキュメントを読み込もう
ぱんだぱんだ

まとめ

  • かなり学びの多い書籍だった
  • フロントエンドのテスト手法をまとめてくれている技術書は今時点でこの書籍しかないのでほんとありがたい
  • vitestではなくJestで書かれていたり少し古くなってしまっているところもあるが基本的な手法、ライブラリに変化はないので特に問題ない
  • ちょっと今回学んだテスト手法を整理
    • vitestによるロジックの単体テスト(ユーティリティー関数のような純粋な関数をテストしたいときなどはvitestで単体テストとして書ける)
    • StoryBookのテストランナーでコンポーネントカタログを作りつつコンポーネントの単体、結合テスト
    • vitest + jsdom(happy-dom) + Testing library + MSWでコンポーネントの単体、結合テスト
    • StroyBook + reg-suit + storycapによるVRT
    • playwrightを使ったE2Eテスト
  • サーバーのテストと違い、種類と使うライブラリ、ブラウザなのかjsdomのようなブラウザをエミュレートした環境なのかなどの組み合わせでテストの種類が多く見える
  • 現場的な視点でいくとStoryBookを導入するかどうかとE2Eテストまで書くのかどうかでいろいろ決めていきたい気がする
  • 0からテストコードを書いていくならまずはvitestとjsdom(happy-dom)とTesting LibraryとMSWを入れてUIコンポーネントテストを書いていくところからスタートすると良さそう
  • mizchiさんも言ってたけどE2Eテストの導入、管理のコストはかなり高いと思っているので余裕ないのに最初からE2Eはまじでやめたほうがいいと思っている
  • 個人的にはできればStoryBookまで導入したいなという感想を持った
  • 単純にプロジェクトで使っているコンポーネントを視覚的に確認できるのが嬉しいのと、そうすることでコンポーネント設計に意識が向くのと、コンポーネントのテストまで書いて終えば質の高いコンポーネントを安心して使える
  • で、コンポーネントとAPIへのfetchで取得したデータを元に作るコンポーネントやビューはvitest + jsdom(happy-dom) + Testing Library + MSWで結合テストとして書くと良さそう
  • StoryBookの導入は大変みたいなのをよく聞くけどそんなに頑張らなければそこまで大変でもなさそうな感想を持った
  • と思ったけど途中から導入する場合、既存のコンポーネントのStoryファイルを全部書くことから始まるので普通に大変そうだ
  • あとはvitestのモック関数の使い方がけっこう自由度高くて使い方が難しいと思った
  • vitestのモックはimportしたモジュールをまるまるモックにするvi.mock()と関数をモックにするvi.fn()とスパイを仕込めるvi.spyOn()がある
  • 前述してるけどここら辺のテストダブルの用語の使い方が厳密ではない(vitestに限った話ではないし用語の使い方も厳密な定義はない)
  • 雑に言うと内側に作用するのがスタブで外側に作用するもので記録したものを後から検証するやつをspyでテストダブルのオブジェクトの中で検証までするのがモック
  • この定義でいうと大半はスタブとスパイになりそう
このスクラップは8日前にクローズされました