💡

フロントエンドのテストの書き方

2024/02/24に公開

はじめに

皆さんフロントのテスト書いてますか?

私の観測範囲では以下のような理由からテストを書かれない方が多いです。

  • 工数がもらえない
  • テストの書き方がわからない
  • 網羅的にテストしようと思うと天文学的なテスト数になりそうで工数が読めない

甘い!!

そんな理由でテストを書かないなんてデグレが怖くないんですか!!!???

とはいえ、気持ちはわかるため、フロントエンドのテストの書き方についてまとめます。

この記事を読んで皆さんも良きテストライフを送ってください。

記事中のコードは次のリポジトリで管理してます。

リポジトリ

この記事の構成

この記事は前半を「テストの書き方がわからない」方向けに、「網羅的なテストの書き方」以降を「工数がもらえない」「網羅的にテストしようと思うと天文学的なテスト数になりそうで工数が読めない」方向けに書いています。

「テストの書き方はわかるけど、網羅的にテストをしようと思うと工数が読めない」という方は「網羅的なテストの書き方」以降から読み始めてください。

ここからしばらくは「テストの書き方がわからない」方向けです。

テストの基本

テストの基本とは「安心を求めることにある

テストの概念を簡単に言うと「特定の条件下でコードが意図通り動くことを保証する」ものです。

逆に言えばその条件下以外の挙動は何も保証されません。

ですが、ここで大事なのは「安心を求めることにある」ということ。

仕様書通りに動けばプログラマの仕事としてはOKなので特定の条件下でコードが意図通り動くだけで十分です。

仕様書以上を求めようとするときりがないし、テストを行う上での基準値として「安心」というのはとても大事な材料だということを心に留めておいてください。

テストとは?

テストの書き方を説明する前に、一旦テストについての解釈の仕方を説明しておきます。

解釈の仕方を把握しておくことで以降の内容についての理解の助けになりますし、この記事に書かれていない状況に遭遇しても応用が利くようになるからです。

というわけでテストとは!!

入力を与えて出力を確かめるものである

一例をあげると足し算をする関数のテストの場合以下のようになります。

import {add} from "./calc";

it("入力をすべて加算した結果を返す",()=>{
  expect(add(1,2)).toBe(3);
})

見ればわかるように add 関数に入力を与えて、出力を確かめています。

別の例としてボタンのテストを挙げます。

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./Button";

it("ボタンを押すとイベントリスナーが発火する", async () => {
	const handleClick = jest.fn();
	render(<Button onClick={handleClick}>ボタンだよ</Button>);

	await userEvent.click(screen.getByRole("button"));

	expect(handleClick).toHaveBeenCalled();
});

上記の例は「クリック」という入力を与えて、「イベントリスナーの呼び出し」という出力を確かめていると見ることができます。

以上の例を見ればわかるように、実はどんなテストコードも「入力」「出力」に分けて考えることができます。

また「入力」と「出力」はy=xの関数のようにそれぞれが対応をしています。

ようは「出力=f(入力) 」ということです。

このように考えることで何に対してexpectをすればいいかも自然とわかるようになります。

入力によって変化するものを出力と呼び、それに対してexpectをすればいいのです。

以降では具体的なテストの書き方をまとめますが、そこでも「入力」「出力」に分けて説明してるので、「入力」「出力」の意識を強く持って読むようにしてください。

テストの書き方

前述のようにテストとは「入力を与えて出力を確かめるもの」です。

なので一旦フロントでどんな入出力があるのか列挙します。

入力 props
各種イベント
apiの呼び出し結果
context
出力 dom
イベントリスナー
apiの呼び出し
context

もしかしたら漏れがあるかもしれませんが、私個人の見解ではフロントのテストとはすべて上記の組み合わせです。

なのでテストを書く時の手順として次に従えば無心でテストが書けます。

手順

  1. 入力を考える
  2. 出力を考える
  3. 出力が入力の関数となるような入出力の関数を考える
  4. テストを書く

※1,2は順不同です。考えやすいほうから考えて構いません。私自身出力から考えることがあります。

上記手順に従い何例か実際にテストを書いてみます。

ボタンを押すとContextが変更される

次のCounterコンポーネントのテストを書きます。

import {
	FC,
	PropsWithChildren,
	createContext,
	useContext,
	useState,
} from "react";

export const Context = createContext<[number, (v: number) => void]>([
	0,
	(v) => {
		throw new Error("not implemented");
	},
]);
export const Provider: FC<PropsWithChildren> = ({ children }) => {
	const [counter, setCounter] = useState(0);
	return (
		<Context.Provider value={[counter, setCounter]}>
			{children}
		</Context.Provider>
	);
};

export const Counter: FC<PropsWithChildren> = ({ children }) => {
	const [counter, setCounter] = useContext(Context);
	return (
		<button onClick={() => setCounter(counter + 1)} type="button">
			{children}
		</button>
	);
};

このように単一のコンポーネントだけに依存されたContextは普通は作らないと思いますが、一旦例として受け入れてください。

それでは手順に沿って進めます。

手順 結果 解説
1. 入力を考える クリックイベントを押した回数 contextは入力にならないことに注意してください。
なぜcontextが入力にならないかというと、contextの値を外部から注入できない構成だからです。

もし、contextの値を外部から渡せるようにするのであれば、contextも入力になります。
2. 出力を考える context 入力によって変化するものが出力なので、contextが出力です。

テスト時にはcontextをexpectできるようにするためにdomへの出力に変換します。
3. 出力が入力の関数となるような入出力の関数を考える context = f(クリックイベントを押した回数) 今回はシンプルですが、複数の入力がある場合は出力との関係性が複雑になるため、一旦関数に直して関係を整理します。
4. テストを書く 後述
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter,Provider,Context } from "./Sample1";
import { FC, useContext } from "react";

const Test:FC =()=>{
    const [counter] = useContext(Context)
    return <>
        <Counter>ボタンだよ</Counter>
        <div>
            result: {counter}
        </div>
    </>
}

it("ボタンを押すとカウントが進む", async () => {
	render(<Test/>,{
		wrapper: Provider
	});

	expect(screen.getByText("result: 0")).toBeInTheDocument();

	await userEvent.click(screen.getByRole("button"));

	expect(screen.getByText("result: 1")).toBeInTheDocument();

	await userEvent.click(screen.getByRole("button"));

	expect(screen.getByText("result: 2")).toBeInTheDocument();
});

ここで、テストの基本を思い返してください。

テストの基本は「安心を求めることにある」でした。

なので、「入力」が「クリックイベントを押した回数」ですが、無数にクリックイベントを呼び出すのではなく2回にとどめています。

なぜならそれで充分安心できるからです。

とはいえ「無数の数クリックされたらどうするんだ!」と思う方は、無数の数のテストを書けばいいと思います。後述するproperty based testingを使えば簡単に書けます。

「安心できるテストの基準」は仕様書や、チームと相談の上決めてください。

レンダリング時にAPIを呼び出し、検索フォームに入力すると再度APIを呼び出すコンポーネント

次のSampleコンポーネントのテストを書きます。

import { debounce } from "lodash-es";
import { FC, useEffect, useMemo, useState } from "react";
import { callAPI } from "../../lib/api";
export const Sample: FC = () => {
	const [search, setSearch] = useState("");
	const [todos, setTodos] = useState<{ title: string }[]>([]);
	const searchTodo = useMemo(() => {
		return debounce(async (s) => {
			try {
				const res = await callAPI(s);
				setTodos(await res.json());
			} catch (e) {
				setTodos([]);
			}
		}, 500);
	}, []);

	useEffect(() => {
		searchTodo(search);
	}, [search, searchTodo]);
	return (
		<div>
			<input value={search} onChange={(e) => setSearch(e.target.value)} />
			<ul>
				{todos.map((todo, i) => {
					return <li key={todo.title}>{todo.title}</li>;
				})}
			</ul>
		</div>
	);
};

では、手順に沿って進めます。

手順 結果 解説
1. 入力を考える inputへの入力
apiの呼び出し結果
2. 出力を考える dom
apiの呼び出し
3. 出力が入力の関数となるような入出力の関数を考える dom = f(apiの呼び出し結果, inputへの入力)

apiの呼び出し = g(inputへの入力)
入出力が増えたので関数が一気に複雑になりました。

左記の場合、入力の組み合わせ*出力先ごとにテストケースを作ります。

入力の組み合わせに対してだけでテストケースを書く方もいますが、出力先ごとにもテストケースを書いたほうがいいです。

というのも出力ごとにテストを書くとテストコードは増えますが、1つ1つのテストがシンプルになるからです。
4. テストを書く 後述

テストを書く際にはまずテストケースを考えます。

最初はテストの入力の組み合わせからです。

入力 パターン
apiの呼び出し結果 成功, 失敗
inputへの入力 なし, あり

これに出力のパターンを足し合わせます。

dom = f(apiの呼び出し結果, inputへの入力) = 4パターン

apiの呼び出し = g(inputへの入力) = 2パターン

dom + api呼び出し = 4パターン + 2パターン = 6パターン

というわけで計6パターンです

一旦todoとしてテストケースを列挙します。

わかりやすさのため、「inputへの入力あり・なし」を「初期描画時・検索時」に変更している点に注意してください。

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Sample } from "./Sample2";

describe("Sample", () => {
	describe("初期描画時", () => {
		it.todo("APIが呼び出される");
		describe("API呼び出し成功時", () => {
			it.todo("ToDoが表示される");
		});
		describe("API呼び出し失敗時", () => {
			it.todo("ToDoが表示されない");
		});
	});
	describe("検索時", () => {
		it.todo("APIが呼び出される");
		describe("API呼び出し成功時", () => {
			it.todo("ToDoが更新される");
		});
		describe("API呼び出し失敗時", () => {
			it.todo("ToDOがなくなる");
		});
	});
});

また、apiのモックにはmswを使います。

テストをすべて書ききると次のようになります。

import { server } from "@/test/msw";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { Sample } from "./Sample2";

describe("Sample", () => {
	describe("初期描画時", () => {
		it("APIが呼び出される", async () => {
			jest.useFakeTimers();
			const getRequest = jest.fn();
			server.use(
				http.get("/todos", ({ request }) => {
					getRequest(request.url);
					return HttpResponse.json([{ title: "hoge" }]);
				}),
			);
			render(<Sample />);
			jest.advanceTimersByTime(1000);
			await act(() => Promise.resolve());
			expect(getRequest).toHaveBeenCalledTimes(1);
			expect(getRequest).toHaveBeenNthCalledWith(1, "http://localhost/todos");
		});
		describe("API呼び出し成功時", () => {
			it("ToDoが表示される", async () => {
				jest.useFakeTimers();
				server.use(
					http.get("/todos", () => {
						return HttpResponse.json([{ title: "hoge" }, { title: "huga" }]);
					}),
				);
				render(<Sample />);
				jest.advanceTimersByTime(1000);
				await act(() => Promise.resolve());
				expect(screen.getByText("hoge")).toBeInTheDocument();
				expect(screen.getByText("huga")).toBeInTheDocument();
			});
		});
		describe("API呼び出し失敗時", () => {
			it("ToDoが表示されない", async () => {
				jest.useFakeTimers();
				server.use(
					http.get("/todos", () => {
						return HttpResponse.error();
					}),
				);
				render(<Sample />);
				jest.advanceTimersByTime(1000);
				await act(() => Promise.resolve());
				expect(screen.getByRole("list")).toBeEmptyDOMElement();
			});
		});
	});
	describe("検索時", () => {
		it("APIが呼び出される", async () => {
			jest.useFakeTimers({ advanceTimers: true });
			const getRequest = jest.fn();
			server.use(
				http.get("/todos", ({ request }) => {
					getRequest(request.url);
					return HttpResponse.json([{ title: "hoge" }]);
				}),
			);
			render(<Sample />);
			jest.advanceTimersByTime(1000);
			await inputIntoSearch("hoge");
			jest.advanceTimersByTime(1000);
			await act(() => Promise.resolve());
			expect(getRequest).toHaveBeenCalledTimes(2);
			expect(getRequest).toHaveBeenNthCalledWith(
				2,
				"http://localhost/todos?s=hoge",
			);
		});
		describe("API呼び出し成功時", () => {
			it("ToDoが更新される", async () => {
				jest.useFakeTimers({ advanceTimers: true });
				let callCount = 0;
				server.use(
					http.get("/todos", () => {
						if (callCount >= 1) {
							return HttpResponse.json([{ title: "foo" }, { title: "bar" }]);
						}
						callCount++;
						return HttpResponse.json([{ title: "hoge" }]);
					}),
				);
				render(<Sample />);
				jest.advanceTimersByTime(1000);
				await inputIntoSearch("hoge");
				jest.advanceTimersByTime(1000);
				await act(() => Promise.resolve());
				expect(screen.getByText("foo")).toBeInTheDocument();
				expect(screen.getByText("bar")).toBeInTheDocument();
			});
		});
		describe("API呼び出し失敗時", () => {
			it("ToDOがなくなる", async () => {
				jest.useFakeTimers({ advanceTimers: true });
				let callCount = 0;
				server.use(
					http.get("/todos", () => {
						if (callCount >= 1) {
							return HttpResponse.error();
						}
						callCount++;
						return HttpResponse.json([{ title: "hoge" }]);
					}),
				);
				render(<Sample />);
				jest.advanceTimersByTime(1000);
				await inputIntoSearch("hoge");
				jest.advanceTimersByTime(1000);
				await act(() => Promise.resolve());
				expect(screen.getByRole("list")).toBeEmptyDOMElement();
			});
		});
	});
});

async function inputIntoSearch(input: string) {
	await userEvent.type(screen.getByRole("textbox"), input);
}

ここで注目してもらいたいのは、「検索時」のテストでは「初期描画時」と同様のexpectも書けるはずですが、書いていないことです。

これはあくまで「入力に対する出力」を確かめているため、「検索時」のテストでは初期描画時の出力に対するテストはしないということです。

「テストの書き方」まとめ

手順に沿うことで何も迷うことなくテストを書いていけることが分かったと思います。

ですが、まだ前述のテストでは「安心」しきれていない人もいると思います。

次の章からは誰もが安心できるようなテストを少ない工数で書く方法を見ていきます。

網羅的なテストの書き方

この章を読むことで、通常のテストを書くのと同様の工数で網羅的なテストを書けるようになると思います。

「網羅的にテストをしようと思うと工数が読めない」という方はぜひ参考にしてください

Jestのit.eachを使ったParameterized test

これまではテストケースが少ないテストをしてきましたが、実際にテストをしようとなるととても自力では書きたくないほどの数のテストケースがある場合があります。

そういった場合に利用されるのがParameterized testです。

例えば、次のコンポーネントのテストをします。

import { FC, PropsWithChildren } from "react";

interface Props {
	value: number;
}
export const FizzBuzz: FC<Props> = ({ value }) => {
	if (value % 15 === 0) {
		return <div>FizzBuzz</div>;
	}
	if (value % 3 === 0) {
		return <div>Fizz</div>;
	}
	if (value % 5 === 0) {
		return <div>Buzz</div>;
	}
	return <div>{value}</div>;
};

これも手順に沿います。

手順 結果 解説
1. 入力を考える props
2. 出力を考える dom
3. 出力が入力の関数となるような入出力の関数を考える dom = f(props)
4. テストを書く 後述

ではテストケースを列挙するため入力のパターンを作ります。

props = 実数全体

出力のパターンは次です。

dom = f(props) = “FizzBuzz” | “Fizz” | “Buzz” | 3と5に素な実数すべて

このようにテストケースがとんでもない数になる場合にParameterized testは有効です。

Parameterized testを使ったテストは次のようになります。

import { render, screen } from "@testing-library/react";
import { FizzBuzz } from "./Sample3";

it.each([
	[3, "Fizz"],
	[6, "Fizz"],
	[5, "Buzz"],
	[10, "Buzz"],
	[15, "FizzBuzz"],
	[2, "2"],
	[8, "8"],
])("%iを渡すと%sを表示する", (value, expected) => {
	render(<FizzBuzz value={value} />);
	expect(screen.getByText(expected)).toBeInTheDocument();
});

入力と出力をeachの引数として渡してあげると、テストの関数でそれを引数として受け取りテストが走ります。

多くのパターンがあるテストもParameterized test を使うことである程度の安心を得ることができます。

fast-checkを使ったproperty based testing

Parameterized testでは手動で入出力を作成していたのに対して、入出力すらランダムに生成できるようにしたのがproperty based testingです。

これを聞いただけでも十分魅力が伝わると思いますが、実際に「Jestのit.eachを使ったParameterized test」のテストをproperty based testingで書き直すと以下のようになります。

import { fc, it } from "@fast-check/jest";
import { RenderOptions, cleanup, render } from "@testing-library/react";
import { ReactNode } from "react";
import { FizzBuzz } from "./Sample3";

it.prop([fc.float()])("3の倍数を渡すとFizzが表示される", (value) => {
	fc.pre(value % 3 === 0 && value % 5 !== 0);
	const result = renderFC(<FizzBuzz value={value} />);
	expect(result.getByText("Fizz")).toBeInTheDocument();
});

it.prop([fc.float()])("5の倍数を渡すとBuzzが表示される", (value) => {
	fc.pre(value % 3 !== 0 && value % 5 === 0);
	const result = renderFC(<FizzBuzz value={value} />);
	expect(result.getByText("Buzz")).toBeInTheDocument();
});

it.prop([fc.float()])("15の倍数を渡すとFizzBuzzが表示される", (value) => {
	fc.pre(value % 15 === 0);
	const result = renderFC(<FizzBuzz value={value} />);
	expect(result.getByText("FizzBuzz")).toBeInTheDocument();
});

it.prop([fc.float()])("3と5に素な数字を渡すとその数字が表示される", (value) => {
	fc.pre(value % 3 !== 0 && value % 5 !== 0);
	const result = renderFC(<FizzBuzz value={value} />);
	expect(result.getByText(value)).toBeInTheDocument();
});

const renderFC = (ui: ReactNode, options: RenderOptions = {}) => {
	return render(ui, {
		container: document.body.appendChild(document.createElement("div")),
		...options,
	});
};

property based testingによってランダムに生成された数字のテストが行えます。

めちゃくちゃ安心ですね。

各property based testが1つのテスト換算なため@testing-library/reactのrender関数のラップが必須なことに注意してください。
これをしないと1テスト内で複数のFizzBuzzコンポーネントが同時にレンダリングされてテストの挙動がおかしくなります。

ちなみにパスワードなど、特殊なルールを持つ文字列のテストについては次の記事を参考にしてください。

Property based testing を試してみよう - Qiita

fast-checkを使ったフォームのテスト

最後におそらくフロントエンドのコンポーネントテストでは最も重要になるであろうテストを紹介します。

それはfast-checkを使ったフォームのテストです。

次のような簡易的なユーザー登録のコンポーネントのテストをします。

import { zodResolver } from "@hookform/resolvers/zod";
import { FC } from "react";
import { useForm } from "react-hook-form";
import zod from "zod";
const schema = zod
	.object({
		name: zod.string().min(1),
		password: zod
			.string()
			.min(8, "パスワードは8文字以上で入力してください")
			.regex(
				/^(?=.*?[a-z])(?=.*?\d)[a-z\d]{8,100}$/i,
				"パスワードは半角英数字混合で入力してください",
			),
		passwordConfirm: zod.string().min(1),
	})
	.superRefine(({ password, passwordConfirm }, ctx) => {
		if (password !== passwordConfirm) {
			ctx.addIssue({
				path: ["passwordConfirm"],
				code: "custom",
				message: "パスワードが一致しません",
			});
		}
	});

interface Props {
	value: number;
}
export const FizzBuzz: FC<Props> = ({ value }) => {
	const {
		handleSubmit,
		register,
		formState: { errors },
	} = useForm({ resolver: zodResolver(schema) });
	return (
		<form onSubmit={handleSubmit(console.log)}>
			<input type="text" {...register("name")} />
			<input type="password" {...register("password")} />
			<input type="password" {...register("passwordConfirm")} />
			<button type="submit">送信</button>
			<div data-testid="error">{errors && "エラーがあります"}</div>
		</form>
	);
};

どうやるのかというと力ずくです。

fast-checkを使えば力づくも多少簡素化できます。

手順に沿って進めます。

手順 結果 解説
1. 入力を考える ユーザー入力
2. 出力を考える dom 本来ならapi呼び出しもありますが、今回はエラーがあるかないかだけテストします。
3. 出力が入力の関数となるような入出力の関数を考える dom = f(props)
4. テストを書く 後述

続いてテストケースを列挙するため入力のパターンを作ります。

ユーザー入力 = 名前の入力にエラーがある・なし, パスワード入力にエラーがある・なし、パスワード確認にエラーがある・なし

簡単のために各入力のエラーのあるなしだけにしましたが、本来ならこれがエラーのパターンの順不同の組み合わせの数になります。

出力のパターンは次です。

dom = エラーがある・なし

これもエラーの数と組み合わせだけ増えます。

ではテストを書く前に簡単な説明をします。

まず先にやろうとしていることを説明すると、bit演算を使った組み合わせの列挙です。

例えば100なら3番目を抽出、010なら2番目、001なら1番目を抽出のような形です。

これによって、どの入力にエラーがあるかの組み合わせを列挙できるようにします。

競プログラミングの知識でもあればもう少しいい感じのアルゴリズムがわかるのでしょうが、私にはないのでn進数を使った方法で組み合わせを探索します。

ではそのようにテストを書いていきます。

import { fc, it } from "@fast-check/jest";
import {
	RenderOptions,
	RenderResult,
	render,
	within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ReactNode } from "react";
import { Form } from "./Sample4";

it.prop([fc.gen(), fc.integer({ min: 1, max: 2 ** 3 - 1 })])(
	"1つでもエラーがあればエラーが表示される",
	async (gen, comb) => {
		let c = comb;
		const result = renderFC(<Form />);
		const password = gen(properString, 8, 20);
		await [() => inputName(result, gen), () => inputInvalidName()][c % 2]();
		c = c >> 1;
		await [
			() => inputPassword(result, gen, password),
			() => inputInvalidPassword(),
		][c % 2]();
		c = c >> 1;
		await [
			() => inputPasswordConfirm(result, gen, password),
			() => inputInvalidPasswordConfirm(),
		][c % 2]();
		await clickButtonA(result);
		expect(result.getByText("エラーがあります")).toBeInTheDocument();
	},
);

it.prop([fc.gen()])("すべてにエラーがないならエラーはない", async (gen) => {
	const result = renderFC(<Form />);
	const password = gen(properString, 8, 20);
	await inputName(result, gen);
	await inputPassword(result, gen, password);
	await inputPasswordConfirm(result, gen, password);
	await clickButtonA(result);
	expect(within(result.container).getByTestId("error")).toBeEmptyDOMElement();
});

const renderFC = (ui: ReactNode, options: RenderOptions = {}) => {
	return render(ui, {
		container: document.body.appendChild(document.createElement("div")),
		...options,
	});
};

const char = (charCodeFrom: number, charCodeTo: number) =>
	fc.integer({ min: charCodeFrom, max: charCodeTo }).map(String.fromCharCode);
const filteredStrings = (min: number, max: number, filter: RegExp) => {
	return fc
		.array(
			char(
				33, //'!'
				126, //'~'
			).filter((c) => filter.test(c)),
			{ minLength: min, maxLength: max },
		)
		.map((arr) => arr.join(""));
};
const properString = (min: number, max: number) => {
	return filteredStrings(min, max, /^[a-zA-Z\d]$/).filter((s) => {
		return (
			!new RegExp(`^[\\d]{${min},${max}}`).test(s) &&
			!new RegExp(`^[a-zA-Z]{${min},${max}}`).test(s)
		);
	});
};
async function inputName(result: RenderResult, gen: fc.GeneratorValue) {
	await userEvent.type(
		result.getByRole("textbox", { name: "Name" }),
		gen(filteredStrings, 1, 100, /^[a-zA-Z\d]/),
	);
}

async function inputPassword(
	result: RenderResult,
	gen: fc.GeneratorValue,
	password?: string,
) {
	await userEvent.type(
		result.getByLabelText("Password"),
		password ?? gen(properString, 8, 20),
	);
}

async function inputPasswordConfirm(
	result: RenderResult,
	gen: fc.GeneratorValue,
	password?: string,
) {
	await userEvent.type(
		result.getByLabelText("Password Confirmation"),
		password ?? gen(properString, 8, 20),
	);
}

async function inputInvalidName() {
	// 何も入力しない
}

async function inputInvalidPassword() {
	// 何も入力しない
}

async function inputInvalidPasswordConfirm() {
	// 何も入力しない
}

async function clickButtonA(result: RenderResult) {
	await userEvent.click(result.getByRole("button"));
}

実際のフォームだとエラーに合わせてexpectだったりを行うのですが、今回は例ということで簡略化しました。

完全に力づくですが、一応テストは書けました。

フォームのテストはどうあがいても大変です。

まとめ

というわけでフロントエンドのテストの書き方を解説してきました。

テスト書きましょう。

補足

テスティングトロフィーのとらえ方について

あくまで個人的見解であり、また、裏取りをする気もないので間違いがあるかもしれないことに注意してください。

私がこの章で何を主張したいのかというと、「@testing-libraryを使うだけで、インテグレーションテストの量がテスティングトロフィーに即した形になるよね」ってことです。

というのもテスティングトロフィーが提唱された当時はEnzymeというReactのテスティングユーティリティが人気でした。

詳しい説明は割愛しますがEnzymeを使うとReactのUnit Testが書けます。

その状況に待ったをかけようとしたのがテスティングトロフィーであり、@testing-library群です。

@testing-libraryは「ReactのテストにUnit Testはほぼ意味ない、Unit Testを書ける機能なんてつけない」という思想の元生まれたと記憶してます。(間違ってたらすいません)

なので、@testing-libraryを使っているなら実はすでにインテグレーションテストを書いてるし、テスティングトロフィーに倣えてるのです。

無駄なexpect

私もやりがちなのですが、無駄なexpectというのがあります。

無駄なexpectとしてよくあるのは、他のテストで十分保証されていることを再度書くことです。

無駄なexpectを突き詰めると極端な話、ライブラリのテストを再度書くことになりうるので、きちんと「「入力」に対して「出力」がある」のだと意識してテストを書いてあげましょう。

ユースケースのテストはe2eで書く

この記事ではコンポーネントのテストに焦点を当てて解説してきました。

おそらくこれでもまだ不安がぬぐえない人がいると思います。

そういった方にお勧めなのは、「ユースケースのテストをe2eで書きましょう」ということです。

e2eのツールはなんでもいいですが、おすすめは並列実行が無償でできるplaywrightです。

e2eを書く時も手順は同じです。

入力を考え、出力を考え、テストを書く。

違う点は入出力の種類です。

e2eでの入出力は以下に絞られます。

入力 ユーザーの操作
apiの呼び出し結果
出力 dom
apiの呼び出し

ユースケースはえてして複雑です。

コンポーネントのテストのように複雑な入出力の環境下で複雑なユースケースのテストをすると爆死します。

なので、入出力の絞られるe2eテストでユースケースのテストをしてあげましょう。

Discussion