こんにちは。相変わらず Testing Library にハマっている梅津です。
今回はタイトルの通り Testing Library を使ってテスト駆動開発(以下 TDD)をやってみる記事です。
Testing Library も TDD も始めたてなため微妙な点が多いかもしれませんが、「こんな感じでやれるんだなー」くらいのノリで見ていってください。
プロジェクトの用意
手軽に済ませたいので Create React App でプロジェクトを作ります。
Redux への移行作業も記事に入れたいので cra-template-redux-typescript を利用します。
このテンプレートには React Testing Library も入っていました。ちょうどいいですね。
必要のないコンポーネントなどの削除、prettier の導入を行いプロジェクトの準備を完了します。
何をやるか決める
コードを書く準備ができたので、次はどういった機能を実装するかメモします。
今回は次の機能ができれば良しとしましょう。
- タスクの一覧表示
- タスクの作成
- 完了状態の切り替え
機能に加えて、ここでもう 1 つ考えておくことがあります。
それは一覧表示機能とタスク作成機能を同じテストケースとして記述するべきか、それとも分けるべきかということです。
分けていたほうがそれぞれで何をやるのかが見やすくなって良さそうな気がします。
しかし、これらの処理は密接に結び付いているように感じるので、同じユースケースとしてまとめてしまうのも悪くなさそうです。
迷うところですが今回はひとつにまとめてしまいましょう。
方針も決まったのでテストを書いていきます。
yarn test
を実行して jest を起動しておくことを忘れずに。
フォームのマークアップ
まずは App コンポーネントの描画とフォームのマークアップから始めます。
App.test.tsx
を作成しましょう。
// App.test.tsx import { render } from "@testing-library/react" import React from "react" import { App } from "./App" test("タスクの追加と一覧表示", () => { const { getByText, getByLabelText } = render(<App />) getByLabelText(/todo/i) getByText(/add/i) })
最初のテストコードはこのような形になりました。テストが通るように App コンポーネントにフォームを追加していきましょう。
// App.tsx import React from "react" export function App() { return ( <div> <form> <label htmlFor={"task-inpuy"}>Todo:</label> <input id={"task-input"} /> <button type={"submit"}>Add</button> </form> </div> ) }
フォームを追加しました。これでテストを実行してみると…
FAIL src/App.test.tsx ✕ タスクの追加と一覧表示 (39ms) ● タスクの追加と一覧表示 Found a label with the text of: /todo/i, however no form control was found associated to that label. Make sure you're using the "for" attribute or "aria-labelledby" attribute correctly. ...略 6 | const { getByText, getByLabelText } = render(<App />) 7 | > 8 | getByLabelText(/todo/i) | ^ 9 | getByText(/add/i) 10 | })
おや、失敗してしまいました。要素を取得する部分は通るかと思ったのですが。
エラーの内容を見ると、ラベルは見つかったがそれに関連するフォームが見つからないと言われています。コードを見直したところ htmlFor
に渡す値を typo していました!これを修正しましょう。
<form> <label htmlFor={"task-input"}>Todo:</label> <input id={"task-input"} /> <button type={"submit"}>Add</button> </form>
Testing Library はフォームがまともに使える状態なのか、テスト時に確認できるのが良いですね。
タスクの作成
フォームができたので、次はタスクの入力と追加ボタンを押したときの処理を実装しましょう。
文字の入力やボタンのクリックは User Event Module を使って行います。fireEvent でも良いですが、僕は User Event Module を使うほうが好みです(理由は前回の記事のを参照)。
// App.test.tsx import { render } from "@testing-library/react" import user from "@testing-library/user-event" import React from "react" import { App } from "./App" test("タスクの追加と一覧表示", () => { const { getByText, getByLabelText } = render(<App />) user.type(getByLabelText(/todo/i), "task1") user.click(getByText(/add/i)) expect(getByText(/task1/i)).toBeInTheDocument() })
getByText(/task1/i)
でコケます。とりあえず task1
と表示してテストが通るようにしましょう。
export function App() { ... return ( <div> <form onSubmit={handleSubmit}> <label htmlFor={"task-input"}>Todo:</label> <input id={"task-input"} /> <button type={"submit"}>Add</button> </form> <div>task1</div> </div> ) }
テストは通りましたが、このままだと使い物にならないですしコンソールエラーも出ています。
イベントハンドラや State を追加して、ユーザーが入力した値を表示できるように変更しましょう。
// App.tsx import React, { ChangeEvent, FormEvent, useState } from "react" export function App() { const [task, setTask] = useState<string>("") const [inputValue, setInputValue] = useState<string>("") const handleChange = (e: ChangeEvent<HTMLInputElement>) => { setInputValue(e.target.value) } const handleSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault() setTask(inputValue) } return ( <div> <form onSubmit={handleSubmit}> <label htmlFor={"task-input"}>Todo:</label> <input id={"task-input"} value={inputValue} onChange={handleChange} /> <button type={"submit"}>Add</button> </form> {task && <div>{task}</div>} </div> ) }
まだテストは通っています。もう少しやりましょうか。
task
が string
型になっているのが気になります。あとでタスクの完了状態も持つことになるのでオブジェクトとして扱えるほうが良さそうです。
今のうちに Task
型を作っておきましょう。どこに置こうか悩みますが、とりあえず todo.ts
でも作ってそこに置きましょうか。
// todo.ts export type Task = { content: string }
App コンポーネントも修正します。
// App.tsx import React, { ChangeEvent, FormEvent, useState } from "react" import { Task } from "./todo" export function App() { const [task, setTask] = useState<Task | null>(null) const [inputValue, setInputValue] = useState<string>("") const handleChange = (e: ChangeEvent<HTMLInputElement>) => { setInputValue(e.target.value) } const handleSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault() const task: Task = { content: inputValue, } setTask(task) } return ( <div> <form onSubmit={handleSubmit}> <label htmlFor={"task-input"}>Todo:</label> <input id={"task-input"} value={inputValue} onChange={handleChange} /> <button type={"submit"}>Add</button> </form> {task && <div>{task.content}</div>} </div> ) }
テストは通ったまま。良いですね!
input 要素のクリア
このまま複数件の表示に移りたいところですが、一度テストコードを見直しましょう。
// App.test.tsx test("タスクの追加と一覧表示", () => { const { getByText, getByLabelText } = render(<App />) user.type(getByLabelText(/todo/i), "task1") user.click(getByText(/add/i)) expect(getByText(/task1/i)).toBeInTheDocument() })
タスクの追加をしたあと input 要素がどうなっていてほしいかが書かれていません。
追加したあとの input 要素は空になっていてほしいので、それを期待するテストを追加します。
// App.test.tsx test("タスクの追加と一覧表示", () => { const { getByText, getByLabelText } = render(<App />) const taskInput = getByLabelText(/todo/i) const addButton = getByText(/add/i) user.type(taskInput, "task1") user.click(addButton) expect(getByText(/task1/i)).toBeInTheDocument() expect(taskInput).toHaveValue("") })
テストが失敗しました。input 要素のクリアをしていなかったので当然ですね。
App コンポーネントを修正しましょう。
// App.tsx export function App() { ... const handleSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault() const task: Task = { content: inputValue, } setTask(task) setInputValue("") } return ... }
テストが通りました。タスク追加処理に関してはこれで良さそうです。
タスクリストの表示
それでは複数件のタスクを表示できるようにしてみましょう。テストコードを次のように変更します。
// App.test.tsx test("タスクの追加と一覧表示", () => { const { getByText, getAllByText, getByLabelText } = render(<App />) const taskInput = getByLabelText(/todo/i) const addButton = getByText(/add/i) // 1件表示 user.type(taskInput, "task1") user.click(addButton) expect(getByText(/task1/i)).toBeInTheDocument() expect(taskInput).toHaveValue("") // 複数件表示 user.type(taskInput, "task2") user.click(addButton) expect(getAllByText(/task./i)).toHaveLength(2) })
getAllByText
を使って複数件のタスクが表示されることを期待するコードを追加しました。
当然テストは通らないので App コンポーネントを修正します。
export function App() { const [tasks, setTasks] = useState<ReadonlyArray<Task>>([]) ... const handleSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault() const task: Task = { content: inputValue, } setTasks((tasks) => [task, ...tasks]) setInputValue("") } return ( <div> <form onSubmit={handleSubmit}>...略</form> {tasks.map((task, index) => ( <div key={index}>{task.content}</div> ))} </div> ) }
テストが通りました。これでリスト表示もできるようになりましたね。
タスクリストが空のときの処理
リストが空のときにどうするか決めていませんでした。ひとまず Empty とでも表示しておきましょうか。
(ここで「空のときは何も表示しない」とする場合ByTestIdを使うことになると思います。しかし、ByTestId については前回の記事で触れていないので、都合のいいように UI を捻じ曲げようと思います。)
テストの追加と App コンポーネントの修正を行いましょう。
// App.test.tsx test("タスクの追加と一覧表示", () => { const { getByText, getAllByText, getByLabelText, queryByText } = render( <App /> ) expect(getByText(/empty/i)).toBeInTheDocument() // Emptyが表示されていること ... // 1件表示 user.type(taskInput, "task1") user.click(addButton) expect(getByText(/task1/i)).toBeInTheDocument() expect(taskInput).toHaveValue("") expect(queryByText(/empty/i)).not.toBeInTheDocument() // Emptyが非表示になっていること // 複数件表示 ... })
// App.tsx export function App() { ... return ( <div> <form onSubmit={handleSubmit}> <label htmlFor={"task-input"}>Todo:</label> <input id={"task-input"} value={inputValue} onChange={handleChange} /> <button type={"submit"}>Add</button> </form> {tasks.length === 0 ? ( <div>Empty</div> ) : ( tasks.map((task, index) => <div key={index}>{task.content}</div>) )} </div> ) }
これでタスクリストが空のときの対応もできました。
空文字入力時の対応
ほかに見落としていることはないでしょうか?
そういえば、タスク追加時に空文字を入力されたときの挙動を決めていませんでしたね。
空文字やスペースしか入力されていないときはボタンを押してもタスクは追加できないようにしましょう。
// App.test.tsx test("タスクの追加と一覧表示", () => { ... const taskInput = getByLabelText(/todo/i) const addButton = getByText(/add/i) // 空文字ならタスクは追加されない user.type(taskInput, "") user.click(addButton) expect(getByText(/empty/i)).toBeInTheDocument() // スペースでも同様に追加されない user.type(taskInput, " ") user.click(addButton) expect(getByText(/empty/i)).toBeInTheDocument() ... })
// App.tsx export function App() { ... const handleSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault() if (!inputValue.trim()) { return } const task: Task = { content: inputValue, } setTasks((tasks) => [task, ...tasks]) setInputValue("") } return ... }
入力された値を trim した結果が空文字ならタスクを追加しないようにしました。
タスク追加と一覧表示については大分できあがってきましたね。
並び順のテストも書くべきか
あとはタスクの並び順についてのテストがありませんね。
並び順のテストも書くべきか悩むところですが、これは実装の詳細にあたるかなと思ったので書かないことにします。
今回は並び順に大きな意味があるわけではないのでこのままでもいいでしょう。
非同期処理への対応
そろそろリファクタリングしていきましょう。まずはテストコードからです。
今回は扱いませんが、本来のアプリではタスクの永続化などのために非同期処理を行うはずです。
現在のテストコードでは getBy*
関数を使っているため、このまま非同期処理を入れるとテストが通らなくなってしまいます。
また、アニメーションなどを入れた場合もエラーになる可能性があります。
ここは findBy*
関数を使って壊れにくいテストへ変更しましょう。
// App.test.tsx import { render } from "@testing-library/react" import user from "@testing-library/user-event" import React from "react" import { App } from "./App" test("タスクの追加と一覧表示", async () => { const { findByText, findAllByText, findByLabelText, queryByText } = render( <App /> ) expect(await findByText(/empty/i)).toBeInTheDocument() const taskInput = await findByLabelText(/todo/i) const addButton = await findByText(/add/i) // 空文字ならタスクは追加されない await user.type(taskInput, "") user.click(addButton) expect(await findByText(/empty/i)).toBeInTheDocument() // スペースでも同様に追加されない await user.type(taskInput, " ") user.click(addButton) expect(await findByText(/empty/i)).toBeInTheDocument() // 1件表示 await user.type(taskInput, "task1") user.click(addButton) expect(await findByText(/task1/i)).toBeInTheDocument() expect(taskInput).toHaveValue("") expect(queryByText(/empty/i)).not.toBeInTheDocument() // 複数件表示 await user.type(taskInput, "task2") user.click(addButton) expect(await findAllByText(/task./i)).toHaveLength(2) })
getBy*
を findBy*
に変更しました。テストケースには async
を追加してあります。
これで問題なさそうですが、念の為 App コンポーネントを変更してテストが壊れないことを確認しましょう。
次のようなヘルパーを追加して、タスク追加処理の直前で呼び出してみます。
// App.tsx function wait(waitTimeMillis: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, waitTimeMillis)) }
// App.tsx export function App() { ... const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { e.preventDefault() if (!inputValue.trim()) { return } await wait(1000) const task: Task = { content: inputValue, } setTasks((tasks) => [...tasks, task]) setInputValue("") } return ... }
これでテストを実行してみると…
FAIL src/App.test.tsx ✕ タスクの追加と一覧表示 (1105ms) ● タスクの追加と一覧表示 expect(received).toHaveLength(expected) Expected length: 2 Received length: 1 Received array: [<div>task1</div>] 33 | await user.type(taskInput, "task2") 34 | user.click(addButton) > 35 | expect(await findAllByText(/task./i)).toHaveLength(2) | ^ 36 | }) 37 | at Object.<anonymous>.test (src/App.test.tsx:35:41)
おっと、、壊れてしまいました。ここのエラーは予想していませんでした。
どうやら await findAllByText(/task./i)
の部分で task1
のテキストが見つかった時点で要素を返してしまい、 task2
が反映される前にアサーションしているようです。
これに関しては Testing Library に対する練度の低さが出てしまいましたね。仕方ないので findByText
を使って task2
の反映を待ち、その後 findAllByText
で複数個のタスクが表示されているか確認することにします。
test("タスクの追加と一覧表示", async () => { ... // 1件目追加 ... // 2件目追加 await user.type(taskInput, "task2") user.click(addButton) expect(await findByText(/task2/i)).toBeInTheDocument() // 複数件表示 expect(await findAllByText(/task./i)).toHaveLength(2) })
これでテストが通りました。期待したとおりにテストが動くか確認しておいて良かったですね。
App コンポーネントに追加した wait
関数とその呼び出しはもう必要ないので消しておきましょう。
コンポーネントのリファクタリング
続いてコンポーネントを分けていきます。まずは簡単そうな一覧表示の部分を別のコンポーネントに分けましょう。
// TaskItem.tsx import React from "react" import { Task } from "./todo" type Props = { task: Task } export function TaskItem({ task }: Props) { return <div>{task.content}</div> }
// TaskList.tsx import React from "react" import { TaskItem } from "./TaskItem" import { Task } from "./todo" type Props = { tasks: ReadonlyArray<Task> } export function TaskList({ tasks }: Props) { return ( <div> {tasks.length === 0 ? ( <div>Empty</div> ) : ( tasks.map((task, index) => <TaskItem key={index} task={task} />) )} </div> ) }
// App.tsx ... import { TaskList } from "./TaskList" export function App() { ... return ( <div> <form onSubmit={handleSubmit}> <label htmlFor={"task-input"}>Todo:</label> <input id={"task-input"} value={inputValue} onChange={handleChange} /> <button type={"submit"}>Add</button> </form> <TaskList tasks={tasks} /> </div> ) }
テストは通っていますね。この調子でフォーム部分もコンポーネントに分けていきましょう。
// AddTask.tsx import React, { ChangeEvent, FormEvent, useState } from "react" type Props = { onAddTask: (taskContent: string) => void } export function AddTask({ onAddTask }: Props) { const [inputValue, setInputValue] = useState<string>("") const handleChange = (e: ChangeEvent<HTMLInputElement>) => { setInputValue(e.target.value) } const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { e.preventDefault() if (!inputValue.trim()) { return } onAddTask(inputValue) setInputValue("") } return ( <form onSubmit={handleSubmit}> <label htmlFor={"task-input"}>Todo:</label> <input id={"task-input"} value={inputValue} onChange={handleChange} /> <button type={"submit"}>Add</button> </form> ) }
// App.tsx import React, { useState } from "react" import { AddTask } from "./AddTask" import { TaskList } from "./TaskList" import { Task } from "./todo" export function App() { const [tasks, setTasks] = useState<ReadonlyArray<Task>>([]) const handleAddTask = (taskContent: string) => { const task: Task = { content: taskContent, } setTasks((tasks) => [task, ...tasks]) } return ( <div> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} /> </div> ) }
変わらずテストは通っています。いいですね!
リファクタリングしてもテストが壊れないという Testing Library の良いところをお見せできたのではないでしょうか。
完了状態の切り替え
次の機能に行きましょう。次はタスクの完了状態の切り替えです。
チェックボックスをクリックすることで完了状態がトグルするという仕様にします。
これは今まで実装してきたものとは別のユースケースとなるので、テストを新たに追加しましょう。
// App.test.tsx import { render } from "@testing-library/react" import user from "@testing-library/user-event" import React from "react" import { App } from "./App" import { Task } from "./todo" test("タスクの追加と一覧表示", async () => { ... }) test("タスクの完了状態の切り替え", async () => { const tasks: ReadonlyArray<Task> = [ { id: "id1", content: "task1", completed: false }, { id: "id2", content: "task2", completed: false }, ] const { findAllByLabelText } = render(<App tasks={tasks} />) const [checkbox1, checkbox2] = await findAllByLabelText(/task./i) user.click(checkbox1) expect(checkbox1).toBeChecked() expect(checkbox2).not.toBeChecked() // 別のタスクには影響がないこと user.click(checkbox1) expect(checkbox1).not.toBeChecked() expect(checkbox2).not.toBeChecked() })
テストはこのように書いてみました。記事の中身が長くなってきたのでステップを踏まずに一気に行きます。
まず特定のタスクの状態だけを切り替えたいので Task に id
を追加します(採番するのも面倒なので string にしました)。加えて完了状態を表す completed
も追加します。
いちいちタスクの追加処理を通してタスクを増やしたくないので、App にタスクリストを渡せるようにします。
あとはチェックボックスをクリックして完了状態が切り替わっているか確認します。
それではコンパイルエラーから順番になおしていきましょう。まずは Task 型の修正です。
// todo.ts export type Task = { id: string content: string completed: boolean }
次に App コンポーネントに Props を追加します。Props として受け取ったタスクリストは useState の initialState として渡します。
プロダクトコード(index.tsx)に影響が出ないよう undefined を許容しつつ、App コンポーネント内では undefined として扱わないようにデフォルト引数を使って型変換をしておきます。
// App.tsx import React, { useState } from "react" import { AddTask } from "./AddTask" import { TaskList } from "./TaskList" import { Task } from "./todo" type Props = { tasks?: ReadonlyArray<Task> } export function App({ tasks: initialTasks = [] }: Props) { const [tasks, setTasks] = useState<ReadonlyArray<Task>>(initialTasks) ... }
あとは handleAddTask
でコンパイルエラーが出ているのでこれも修正します。
id はここに書かれている方法で適当な文字列を入れます。今回はこの方法で十分です。
// App.tsx export function App({ tasks: initialTasks = [] }: Props) { ... const handleAddTask = (taskContent: string) => { const task: Task = { id: randomString(), content: taskContent, completed: false, } setTasks((tasks) => [...tasks, task]) } ... } function randomString(): string { return Math.random().toString(36).substring(7) }
これでコンパイルエラーはなくなりましたがテストはコケたままです。マークアップに移りましょう。
TaskItem コンポーネントにチェックボックスを追加します。さらにチェックボックスクリック時のイベントも Props に追加します。
// TaskItem.tsx import React from "react" import { Task } from "./todo" type Props = { task: Task onItemClick: (task: Task) => void } export function TaskItem({ task, onItemClick }: Props) { const handleClick = () => { onItemClick(task) } return ( <div> <label style={{ textDecoration: task.completed ? "line-through" : "none", }} > <input type={"checkbox"} readOnly checked={task.completed} onClick={handleClick} /> {task.content} </label> </div> ) }
TaskList コンポーネントにもイベントを追加し、そのまま App コンポーネントまで伝播させます。
// TaskList.tsx import React from "react" import { TaskItem } from "./TaskItem" import { Task } from "./todo" type Props = { tasks: ReadonlyArray<Task> onItemClick: (task: Task) => void } export function TaskList({ tasks, onItemClick }: Props) { return ( <div> {tasks.length === 0 ? ( <div>Empty</div> ) : ( tasks.map((task, index) => ( <TaskItem key={index} task={task} onItemClick={onItemClick} /> )) )} </div> ) }
App コンポーネントにイベントハンドラを用意し、完了状態の切り替え処理を実装します。
// App.tsx export function App({ tasks: initialTasks = [] }: Props) { ... const handleItemClick = (selectedTask: Task) => { setTasks((tasks) => tasks.map((t) => ({ ...t, completed: t.id === selectedTask.id ? !t.completed : t.completed, })) ) } return ( <div> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onItemClick={handleItemClick} /> </div> ) }
これでようやくテストが通りました!
一気にやったせいでグリーンになるまでが長くてヒヤヒヤしましたね。
Redux への移行
最後に何らかの状態管理ライブラリを使いたくなった場合を想定し、Redux への移行をしてみたいと思います。
Redux を選択した理由は Redux Toolkit を触ってみたかっただけなので、深い意味はありません。Context + useReducer, MobX,Recoile など、お好きなものに読み替えてもらっても問題ありません。
それではコードを書いていきましょう。一応リファクタリングのつもりなのでプロダクトコードから変更します。これも一気に進めます。
まずは store.ts
を作成して、ルートの Reducer, Store, State などを作成します。
// store.ts import { configureStore } from "@reduxjs/toolkit" import { todoReducer } from "./todo" export const reducer = { todo: todoReducer, } export const store = configureStore({ reducer, }) export type RootState = ReturnType<typeof store.getState>
つづいて todo.ts
内に slice を追加します。
// todo.ts import { createSlice, PayloadAction } from "@reduxjs/toolkit" import { RootState } from "./store" export type Task = { id: string content: string completed: boolean } type TodoState = { tasks: ReadonlyArray<Task> } const initialState: TodoState = { tasks: [], } const todoSlice = createSlice({ name: "todo", initialState, reducers: { addTask: (state, action: PayloadAction<{ content: string }>) => { const task: Task = { id: randomString(), content: action.payload.content, completed: false, } state.tasks.unshift(task) }, toggleCompleted: (state, action: PayloadAction<{ id: string }>) => { state.tasks = state.tasks.map((t) => ({ ...t, completed: t.id === action.payload.id ? !t.completed : t.completed, })) }, }, }) // Action export const { addTask, toggleCompleted } = todoSlice.actions // Selector export function selectTasks(state: RootState): ReadonlyArray<Task> { return state.todo.tasks } // Reducer export const todoReducer = todoSlice.reducer // Helper function randomString(): string { return Math.random().toString(36).substring(7) }
これらをコネクトするように App コンポーネントを修正します。Props はいらなくなるので削除します。
// App.tsx import React from "react" import { useDispatch, useSelector } from "react-redux" import { AddTask } from "./AddTask" import { TaskList } from "./TaskList" import { addTask, selectTasks, Task, toggleCompleted } from "./todo" export function App() { const dispatch = useDispatch() const tasks = useSelector(selectTasks) const handleAddTask = (taskContent: string) => { dispatch(addTask({ content: taskContent })) } const handleItemClick = (selectedTask: Task) => { dispatch(toggleCompleted({ id: selectedTask.id })) } return ( <div> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onItemClick={handleItemClick} /> </div> ) }
最後に index.tsx を修正して Provider を追加しておきましょう。
// index.tsx import React from "react" import ReactDOM from "react-dom" import { Provider } from "react-redux" import { App } from "./App" import { store } from "./store" ReactDOM.render( <React.StrictMode> <Provider store={store}> <App /> </Provider> </React.StrictMode>, document.getElementById("root") )
さて、テストはどうなるかというと…。
Error: Uncaught [Error: could not find react-redux context value; please ensure the component is wrapped in a <Provider>]
Provider
で囲めと怒られました。render するときに Provider で囲むようにテストを修正します。また、テスト用の initialState を渡したいので configureStore
を使って Store を別に作成します。
// App.test.tsx import { render } from "@testing-library/react" import user from "@testing-library/user-event" import React from "react" import { configureStore } from "@reduxjs/toolkit" import { Provider } from "react-redux" import { App } from "./App" import { reducer } from "./store" import { Task } from "./todo" test("タスクの追加と一覧表示", async () => { const { findByText, findAllByText, findByLabelText, queryByText } = render( <Provider store={configureStore({ reducer })}> <App /> </Provider> ) ... }) test("タスクの完了状態の切り替え", async () => { const tasks: ReadonlyArray<Task> = [ ...略 ] const { findAllByLabelText } = render( <Provider store={configureStore({ reducer, preloadedState: { todo: { tasks: tasks, }, }, })} > <App /> </Provider> ) ... })
テストが通りました!render 部分の修正が必要でしたが、それ以外のテストコードは一切変更していません!またしても Testing Library の強みを見せられたのではないでしょうか。
Custom Render の作成
大分できあがってきましたが、もう少しだけリファクタリングをします。
テストコードを見ると Provider で囲んで render する部分が重複していますね。Coustom Render を作ってこの重複をなくしましょう。
// App.test.tsx import { configureStore, Store } from "@reduxjs/toolkit" import { render as rtlRender, // rtlはReact Testing Libraryの略 RenderOptions as RtlRenderOptions, } from "@testing-library/react" import user from "@testing-library/user-event" import React, { ComponentType, ReactElement } from "react" import { Provider } from "react-redux" import { DeepPartial } from "redux" import { App } from "./App" import { reducer, RootState } from "./store" import { Task } from "./todo" type RenderOptions = { initialState?: DeepPartial<RootState> store?: Store } & RtlRenderOptions function render( component: ReactElement, { initialState, store: s, ...renderOptions }: RenderOptions = {} ) { // この関数内で扱うstoreがundefinedにならないようにする const store = s ?? configureStore({ reducer, preloadedState: initialState }) // rerenderするときに再度Providerで囲まなくて良いようにWrapper Optionを使う function Wrapper({ children }: { children: ReactElement }) { return <Provider store={store}>{children}</Provider> } return { ...rtlRender(component, { wrapper: Wrapper as ComponentType, ...renderOptions, }), store, // テスト時にstoreを触りたくなることもあるだろうから混ぜて返却 } } test("タスクの追加と一覧表示", async () => { const { findByText, findAllByText, findByLabelText, queryByText } = render( <App /> ) ... }) test("タスクの完了状態の切り替え", async () => { ... const { findAllByLabelText } = render(<App />, { initialState: { todo: { tasks: tasks, }, }, }) ... })
テストは通ったままです。あとはこの Custom Render を別ファイルに分離しておきましょう。
相対パスで import するのが嫌な場合はこちらの設定を参考にしてみてください。今回は相対 import のままにします。
// test/utils.tsx import { configureStore, Store } from "@reduxjs/toolkit" import { render as rtlRender, RenderOptions as RtlRenderOptions, } from "@testing-library/react" import React, { ComponentType, ReactElement } from "react" import { Provider } from "react-redux" import { DeepPartial } from "redux" import { reducer, RootState } from "../store" type RenderOptions = { initialState?: DeepPartial<RootState> store?: Store } & RtlRenderOptions export function render( component: ReactElement, { initialState, store: s, ...renderOptions }: RenderOptions = {} ) { const store = s ?? configureStore({ reducer, preloadedState: initialState }) function Wrapper({ children }: { children: ReactElement }) { return <Provider store={store}>{children}</Provider> } return { ...rtlRender(component, { wrapper: Wrapper as ComponentType, ...renderOptions, }), store, } }
// App.test.tsx import user from "@testing-library/user-event" import React from "react" import { App } from "./App" import { Task } from "./todo" // React Testing Library から Custom Render へ変更 import { render } from "./test/utils" test("タスクの追加と一覧表示", async () => { ... }) test("タスクの完了状態の切り替え", async () => { ... })
テストコードがかなりスッキリしましたね。Context API を使ったコンポーネントをテストする場合は、こうやって Custom Render を作ることがキモになってきます。
こうしておくと他の Context API(e.g. ThemeProvider)を使ったコンポーネントのテスト時も Custom Render だけ修正すればいいので影響範囲を最小限にできます。
さいごに
お疲れさまでした。ここまで読んでいただいてありがとうございます。
他にもリファクタリングしたい部分はまだまだあるのですが、流石に切りがないのでこれで終わりにします。
今回はページに相当するコンポーネントに対して、機能のテストを書いていきました。これによって次の事を伝えることができたのではないでしょうか。
- リファクタリングしても機能が壊れていないことを確認できる安心感
- コンポーネントや状態管理の方法を好きに変えていける柔軟性
UI 作りにおいてはこのくらいの粒度でテストを書くとコスパが良さそうだなと感じていて、1 つ 1 つのコンポーネントや Custom Hooks, Reducer などへの細かいテストは必要に応じて追加していくと良いんじゃないでしょうか。
今回が TDD 初挑戦でしたが、結構楽しかったです!ぜひ皆さんもやってみてください。
それでは、また。