この記事は
夏のブログリレー
6日目
の記事です。
こんにちは、21B SysAd班のmehm8128です。
4月にフロントエンド開発のためのテスト入門 今からでも知っておきたい自動テスト戦略の必須知識 | 吉井 健文 |本 | 通販 | Amazon が発売され、ゴールデンウィークに一通り読んでフロントエンドテストに入門したのですが、アウトプットができていなかったので、アウトプットした話を書きます。
traQuest
少し前にtraQuestという、サークル内SNS traQの使い方を新入生に慣れてもらうために、ゲームでよくあるようなクエスト形式で体験してもらうというアプリを作ったのですが、Next.jsのapp routerを使っていたり、ユーザーがクエストを追加できるフォームがあったりと、今回テストを書く練習をするのにちょうどよさそうだったので、こちらのアプリにテストを追加していきました。
部員専用で認証を設定しているので他の人は見れないようになっているので、アプリの雰囲気はスクショだけ貼っておきます(CSSが崩壊しているのはいつか直す予定です)。
また、リポジトリはこちらです。
フロントエンド: 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
タグはデフォルトでlink
、li
タグは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対応をしていたりします。
id
とhtmlFor
でlabelとフォーム要素の紐づけを行っています(普通のHTMLのfor
がReactではhtmlFor
になります)。ここに映り込んでいないのですが、ReactのuseId
というフック(https://react.dev/reference/react/useId)を使ってidを生成し、それを利用しています。
そしてaria-invalid
でinvalidであることを表し、aria-errormessage
でエラーメッセージ(FieldWrap
のid={`${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つ記事を出そうと思っているので、そちらもよろしくお願いします
明日の担当は鵜崎くんです、お楽しみにー