Open5

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

ぱんだぱんだ

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("アカウント情報")
});