feature image

2023年8月26日 | ブログ記事

フロントエンドテストに入門してみた話

この記事は
夏のブログリレー
6日目

の記事です。
こんにちは、21B SysAd班のmehm8128です。

4月にフロントエンド開発のためのテスト入門 今からでも知っておきたい自動テスト戦略の必須知識 | 吉井 健文 |本 | 通販 | Amazon が発売され、ゴールデンウィークに一通り読んでフロントエンドテストに入門したのですが、アウトプットができていなかったので、アウトプットした話を書きます。

traQuest

少し前にtraQuestという、サークル内SNS traQの使い方を新入生に慣れてもらうために、ゲームでよくあるようなクエスト形式で体験してもらうというアプリを作ったのですが、Next.jsのapp routerを使っていたり、ユーザーがクエストを追加できるフォームがあったりと、今回テストを書く練習をするのにちょうどよさそうだったので、こちらのアプリにテストを追加していきました。
部員専用で認証を設定しているので他の人は見れないようになっているので、アプリの雰囲気はスクショだけ貼っておきます(CSSが崩壊しているのはいつか直す予定です)。

chrome_nyd3n0d6if
chrome_pmac96zpps

また、リポジトリはこちらです。
フロントエンド: https://github.com/mehm8128/traQuest-UI
バックエンド: https://github.com/mehm8128/traQuest-server

テストの目的

まずテストを書く目的について、本でも紹介されていますが、いくつか挙げます。
1つはもちろんコードが正しく動くことを保証するためです。この入力のときにはこの出力になる、ということがテストによって保証されていることで、(テストが正しく動いている前提の下では)安心して変更を加えることができます。
次にコードがどう動くのかが分かりやすくなるということです。テストコードはなるべく簡潔な設定の下で動作が保証されるコードが書かれます。つまり、テストされる関数やコンポーネントが動く最小構成を、テストを読むことによって捉えることができます。これはレビューする人や、後からプロジェクトに参加した人などがコードを理解しやすくなり、チーム開発においてとても有用だと思います。
最後に、これは自分が特に良いと思ったものなのですが、コンポーネントテストにおいてはtesting-libraryを使うことで、コードが正しく動くことに加えてa11yもある程度保証できるということです(a11yとはアクセシビリティのことです。accessibilityでaとyの間に11文字あることから、こう略されます)。a11yが保証されているというのは、様々な状況におかれているユーザーが最低限そのサービスを利用できるということです。ここでいう様々な状況というのは身体の障害などに加えて、利用する端末、一時的な使いづらい状況なども含まれます。testing-libraryではテストでassertするときにa11y由来(この言葉ってこの本独自の言葉なんですかね)のマッチャーを使うことができるので、それを徹底することで、テストが通るということはある程度そこに関連するa11yも保証されている、という状態を作ることができます。これについては具体的にあとで説明します。
他にも色々テストを書くメリットはあると思いますが(storybookを使った場合にデザイナーに共有できるなど)、このくらいにしておきます。

テスト書いた

今回書いたテストは大きく分けてロジックだけのテストとコンポーネントのテストの2つがあります。
順に紹介していきます。
ちなみに、本ではjestを使っていたのですが、一回jestで書いたあとでvitestに書き直しました。違いについてはvitestの方が実行速度が速いというくらいしか知らないです。traQでもvitestを使ったテストがいくつか書かれています。

ロジックのテスト

ロジックのテストでは、ロジックをテストします。
これはフロントエンドとかあんまり関係ないので、バックエンドでも似たようなことやると思います多分。
今回はシンプルなアプリだったので何も意味がないテストを書いているのですが、一応雰囲気の紹介です。

以下のようなリクエストを送って返ってきたものをreturnするだけの関数があったときに、

export const postQuest = async (quest: QuestRequest) => {
	const res = await axios.post<Quest>(`${getApiOrigin()}/api/quests`, quest)
	return res.data
}

以下のようなテストを書きます。

test("questの作成成功時: questの詳細を返す", async () => {
		const requestBody: QuestRequest = {
			title: "title",
			level: 1,
			description: "description",
			tags: [],
		}
		const quest = {
			data: questFixture,
		}
		vi.spyOn(axios, "post").mockResolvedValue(quest)
		await expect(postQuest(requestBody)).resolves.toMatchObject(quest.data)
	})

単体テストを実行するときに実際に通信するわけにはいかないので(E2Eテストなどでは開発環境のサーバーに実際に通信することはあります。本の後半参照)、axiosのレスポンスはspyOnでモックしています(関係ないですけどvi.spyOnってめっちゃvimを感じますね。vitestのviです)。そして、postQuestした返り値がresolveされたときの中身の値が、quest.dataと一致しているかをassertしています。
ちなみにquestFixtureというのは

export const quest: Quest = {
	id: "1",
	number: 1,
	title: "aaaaa",
	level: 3,
	completed: true,
	completedCount: 10,
	description: "aaaaa",
	tags: [],
	createdAt: "2021-01-01T00:00:00",
	updatedAt: "2021-01-01T00:00:00",
}

みたいなモックデータです。

本来ならpostQuest関数でデータの加工とかを色々していればテストの意味が出てくるのですが、ここではなんの意味もなくなっています。
よく他のプロジェクトで使う、Date型の変数を日付のstringに直すような関数を使っていればいい感じのテストが書けたのですが、traQuestで使っていなかったので書きませんでした。

ソースコードはこちら: https://github.com/mehm8128/traQuest-UI/tree/main/src/clients/quests

コンポーネントのテスト

コンポーネントのテストでは、コンポーネントのテストをします。
ボタンコンポーネントとフォームコンポーネントの例を紹介します。
まずはボタンコンポーネントの例から。

test("uncompletedなときにcompleteできる", async () => {
	const mockFetchFn = vi.spyOn(axios, "post")
	const mockSetFn = vi.fn()
	render(
		<RecoilProvider>
			<CompleteButton
				isCompleted={false}
				questId="aaa"
				setQuestDetail={mockSetFn}
			/>
		</RecoilProvider>
	)
	const buttonEle = screen.getByRole("button", { name: "クエスト完了!" })
	expect(buttonEle).toBeInTheDocument()
	await user.click(buttonEle)
	expect(mockFetchFn).toHaveBeenCalled()
	expect(mockSetFn).toHaveBeenCalled()
})

クエストをクリアしたときに、手動でクリアボタンを押すようなアプリなのですが、既にクリアしているときには押せず、まだクリアしていないときには押せるような実装になっているので、それをテストしています(前者のテストは省略)。
renderでコンポーネントを準備します。ここで、getMeなどで取得した自分のidなどの情報をコンポーネント内で参照していて、今回はRecoilを使ってそれを状態管理しているので、コンポーネントをRecoilProviderでwrapしています(Recoilはそろそろjotaiに変えようと思っています)。
次のscreen.getByRoleがポイントです。roleというのはHTMLのタグがそれぞれ持っているもので、buttonタグのroleはデフォルトでbuttonです。他にもaタグはデフォルトでlinkliタグはlistItemなど色々あります。ここではroleがbuttonで、そのaccessible nameがクエスト完了!であるHTML要素を取得しています。roleやaccessible nameについては調べてみてください(後日a11yについての記事も出す予定です)。そして、その取得したHTML要素が存在していることや、そのボタン要素をクリックしたら最初にモックしたaxiosの関数とrender時に渡したsetQuestDetail用の関数が呼ばれることなどをassertしています。

ちなみに省略したもう1つのテストでは、既にクリアされているときはボタンがdisabledなことを

expect(buttonEle).toBeDisabled()

のようにしてassertしていたりします。

このようにgetByRoleでa11y由来のマッチャーを用いて要素を取得することで、HTMLが正しく記述されていることも保証することができます。例えば今回のボタンがチェックマークのようなアイコンだけが表示されているボタンだったとして、なんらかの方法でaccessible nameを正しくつけられていなかったらスクリーンリーダーではなんのボタンなのか分からず、クエストをクリアすることが難しくなってしまいます。しかし、このようにしてaccessible nameを用いてテストを書くことで、そもそもちゃんとaccessible nameをボタンにつけておかないとテストが通らない、というような状態にしておくことができます。

ソースコードはこちら: https://github.com/mehm8128/traQuest-UI/blob/main/src/app/[questId]/_components/CompleteButton.test.tsx

次にフォームコンポーネントの例です。
フォームコンポーネントは最初のスクショの2枚目のものです。
画像関連の部分は未実装なので気にしないでください。

準備用の関数、1つ目の例、2つ目の例で3つコードを提示します。

const setup = async () => {
	const mockCreateQuestFn = vi.spyOn(questApis, "postQuest")
	const mockCreateTagFn = vi.spyOn(tagApis, "postTag").mockResolvedValue([
		{
			id: "tag4",
			name: "tag4",
			createdAt: "",
		},
	])
	render(<QuestRequestForm tags={tags} />)

	const inputTitle = async () => {
		const titleInput = screen.getByRole("textbox", { name: "クエスト名" })
		await user.type(titleInput, "title")
	}
	const inputDescription = async () => {
		const descriptionInput = screen.getByRole("textbox", {
			name: "クエストの説明",
		})
		await user.type(descriptionInput, "description")
	}
	const selectLevel = async () => {
		await selectEvent.select(screen.getByLabelText("難易度"), "3")
	}
	const selectTag = async () => {
		await selectEvent.select(screen.getByLabelText("タグ"), "tag2")
	}
	const createTag = async () => {
		await selectEvent.create(screen.getByLabelText("タグ"), "tag4")
	}
	const submit = async () => {
		await user.click(screen.getByRole("button", { name: "申請を送信" }))
	}

	return {
		mockCreateQuestFn,
		mockCreateTagFn,
		inputTitle,
		inputDescription,
		selectLevel,
		selectTag,
		createTag,
		submit,
	}
}

これは本にあった書き方なのですが、一通りの設定を1つの関数にまとめています。1つのフォームコンポーネントに6つテストを書いたのですが、それらで共通しているようなところはこうやって共通化することできれいに書けました。
また、ここでもa11y由来のマッチャーを使っていますね。

次に1つ目のテストケースです。

test("既存のタグを使って正常に送信できる", async () => {
		const {
			mockCreateQuestFn,
			inputTitle,
			inputDescription,
			selectLevel,
			selectTag,
			submit,
		} = await setup()

		await inputTitle()
		await inputDescription()
		await selectLevel()
		await selectTag()
		await submit()

		expect(mockCreateQuestFn).toHaveBeenCalledWith({
			title: "title",
			description: "description",
			level: 3,
			tags: ["tag2"],
			image: undefined,
			alt: undefined,
		})
	})

タグは新規作成もできれば既存のものを使うこともできるのですが、このテストでは新規作成はせずに既存のタグを使って送信しています。
先ほどsetup関数を作ったので、とてもきれいに書けています。テストを見たときにこのテストケースでは何をしているのかが分かりやすいです。

最後にバリデーションエラーが出る場合のテストケースです。

test("クエストの説明がないバリデーションエラー", async () => {
		const { inputTitle, selectLevel, selectTag, submit } = await setup()

		await inputTitle()
		await selectLevel()
		await selectTag()
		await submit()

		await waitFor(() =>
			expect(
				screen.getByRole("textbox", { name: "クエストの説明" })
			).toHaveAccessibleErrorMessage("Invalid length")
		)
	})

クエスト名かクエストの説明が入力されずに送信ボタンが押された場合、「Invalid length」というエラーが表示されるようになっています。よって、そのような条件でちゃんとエラーが表示されるというテストを書いています。これについてはもう少しコンポーネント自体のコードの説明を書きます。

説明文のフォームは以下のようになっています。

<FieldWrap
				labelText="クエストの説明"
				htmlFor={`${id}-description`}
				error={errors.description}
			>
				<textarea
					id={`${id}-description`}
					placeholder="クエストの説明"
					className="w-full min-h-[128px] border border-gray-400 rounded-md p-2"
					aria-invalid={!!errors.description}
					aria-errormessage={`${id}-description-error`}
					{...register("description")}
				/>
			</FieldWrap>

リンク: https://github.com/mehm8128/traQuest-UI/blob/main/src/app/request/_components/QuestRequestForm.tsx

FieldWrapはこちら。

export default function FieldWrap({
	labelText,
	htmlFor,
	children,
	error,
}: {
	labelText: string
	htmlFor: string
	children: React.ReactNode
	error?: FieldErrors[number]
}) {
	return (
		<div className="flex flex-col gap-1">
			<label htmlFor={htmlFor}>
				<span>{labelText}</span>
			</label>
			{children}
			<div className="h-3 text-red-400" id={`${htmlFor}-error`}>
				{error?.message?.toString()}
			</div>
		</div>
	)
}

リンク: https://github.com/mehm8128/traQuest-UI/blob/main/src/components/FieldWrap.tsx

バリデーションにはReact Hook Formを使っています。そして、resolverにValibotを使ってみました。Valibotはzodのようなスキーマライブラリで、zodよりパフォーマンスがめちゃくちゃいいらしいです。最近出てきたものなのでまだドキュメントなどがあまり整備されていなかったのですが、折角なので使ってみました。しかも使ってみるまで知らなかったのですが、使い始めた前日にReact Hook FormのresolverがValibotに対応したらしく、タイミングがちょうどよかったです。

コンポーネントの話に戻ると、FieldWrapではエラーの表示を担当しています。FieldErrors[number]でエラーを渡して(エラーの型が色々種類があってどうするのが最善か分からなかったので一旦これにしています、要検討)、undefinedでなかったら表示するようにしています。エラー文がなぜか英語だったのはここで変換をサボったからです。
そして実際のコンポーネントではFieldWrapに色々渡したり、a11y対応をしていたりします。
idhtmlForでlabelとフォーム要素の紐づけを行っています(普通のHTMLのforがReactではhtmlForになります)。ここに映り込んでいないのですが、ReactのuseIdというフック(https://react.dev/reference/react/useId)を使ってidを生成し、それを利用しています。
そしてaria-invalidでinvalidであることを表し、aria-errormessageでエラーメッセージ(FieldWrapid={`${htmlFor}-error`})と紐づけています(htmlForでpropsもらってるのよくないですね)。

今回は1回しかtextareaを使っていないのでそのまま書いていますが、本当ならもっと使い回せるようにコンポーネント化してすっきり書けると思います。

と、このようにしてa11y対応を行いつつバリデーションのテストを書くことができました。
書くのはそこそこ大変ですが、適切な共通化などをしていけばきれいに書くことができそうですね。

テストのソースコード: https://github.com/mehm8128/traQuest-UI/blob/main/src/app/request/_components/QuestRequestForm.test.tsx

まとめ

こんなに書くつもりじゃなかったのですが、長文になってしまいました。
traPのサービスはtraQ以外は比較的1つ1つのサービスが小規模ということもあり、フロントエンドテストはあまり書かれることがないのですが、フォームくらいは書くようにしてもいいのかなーと思ったりしています。せっかく3つのサービスのフロントエンドのリーダーをやらせてもらっているので、今のうちに導入したいなーと目論んでいます。
また、storybookやE2Eテストについては今回触れなかったのですが、storybookについてはかなり便利なのである程度使わせてもらっていますということだけ書いておきます。

テストの書き方は一通り学べたものの、まだどういうテストケースを作るとよりテストとして効果が大きいか、などは実務を通してしか分からないことがあったりすると思うので、色々試したりインターンで書かせてもらったりして勉強していけたらと思います。
今回の夏のブログリレーではあと2つ記事を出そうと思っているので、そちらもよろしくお願いします

明日の担当は鵜崎くんです、お楽しみにー

mehm8128 icon
この記事を書いた人
mehm8128

21BJC。SysAd班で色々やってます

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2021年8月12日
CPCTFを支えたWebshell
mazrean icon mazrean
2022年9月26日
競プロしかシラン人間が web アプリ QK Judge を作った話
tqk icon tqk
2022年9月16日
5日でゲームを作った #tararira
Komichi icon Komichi
2023年9月27日
夏のブログリレーは終わらない【駄文】
Komichi icon Komichi
2023年9月13日
ブログリレーを支えるリマインダー
H1rono_K icon H1rono_K
2023年8月21日
名取さなになりたくてOBSと連携する配信画面を作った
d_etteiu8383 icon d_etteiu8383
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記