React Testing Library を使って TDD でコンポーネントを作る

こんにちは。相変わらず 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>
  )
}

まだテストは通っています。もう少しやりましょうか。
taskstring 型になっているのが気になります。あとでタスクの完了状態も持つことになるのでオブジェクトとして扱えるほうが良さそうです。
今のうちに 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 を捻じ曲げようと思います。)

blog.engineer.adways.net

テストの追加と 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 だけ修正すればいいので影響範囲を最小限にできます。

ここまでの作業

さいごに

お疲れさまでした。ここまで読んでいただいてありがとうございます。
他にもリファクタリングしたい部分はまだまだあるのですが、流石に切りがないのでこれで終わりにします。

今回はページに相当するコンポーネントに対して、機能のテストを書いていきました。これによって次の事を伝えることができたのではないでしょうか。

  1. リファクタリングしても機能が壊れていないことを確認できる安心感
  2. コンポーネントや状態管理の方法を好きに変えていける柔軟性

UI 作りにおいてはこのくらいの粒度でテストを書くとコスパが良さそうだなと感じていて、1 つ 1 つのコンポーネントや Custom Hooks, Reducer などへの細かいテストは必要に応じて追加していくと良いんじゃないでしょうか。

今回が TDD 初挑戦でしたが、結構楽しかったです!ぜひ皆さんもやってみてください。
それでは、また。

参考リンク