これから始めるReact Testing - ③Recoilのテスト

エンジニアの osumi です。最近は手挽きのコーヒーにハマっております。

前回から随分と時間が空いてしまったのですが、今回はReact Testing Library入門シリーズの第3弾として「Recoilを使ったテスト」についてご紹介できればと思います。

  1. React Componentのテスト
  2. カスタムフックのテスト
  3. Recoilのテスト(今回の記事)

npmパッケージの各種バージョンは初回の記事に記載していますが、それらに加えて以下のパッケージを利用します。

Recoilとは

Recoilはグローバルなステートを管理するためのライブラリです。Reactにはコンポーネント間でステートを共有する手段として、Context APIやReduxなどが存在しますが、Recoilは以下のような特徴を持っています。

  • Reactらしさを保つシンプルなインターフェイス
  • ContextAPIは単一の値しか格納できないが、Recoilは複数の値を集約できる
  • 効率的なレンダリング、非同期クエリ、タイムトラベルデバッグなども備えている

詳細はRecoilの公式サイトをご参照ください。

Recoilのテスト

今回はRecoilを用いて、以下のようなTODOリストをもとにReact Testing Libraryを使ってテストを実装していきたいと思います。

TODOリストのテストを実装するにあたって、以下の3つのステップで、それぞれUIの実装とテストの実装を行います。

  1. TODOリストの表示
  2. TODOリストの検索
  3. TODOリストの追加

記事内では一部のコードを抜粋して説明していますが、もし不明点があれば GitHubのソースコード をご参考ください。

github.com

また、TODOリストの実装には React + TailwindCSSを利用しています。

環境構築のステップは割愛しますが、ご自身で環境構築を行う場合は「Install Tailwind CSS with Create React App」をご参照ください。なお、TailwindCSSはスタイリングに利用するだけなので、無くても構いません。

Step 1:TODOリストの表示

最初に土台となるTODOリストの表示を実装します。TODOリストのデータはAPIから取得した後、Recoilのステートで管理し、UI上に表示します。

UIの実装

まずはTODOリストを表示するUIコンポーネントを定義します。補足としてコード内にコメントを記載しています。

// src/components/TodoList/index.tsx
import { FC } from "react";
import { TodoItem } from "./components";
import { useRecoilValue } from "recoil";
import { todoListState } from "./store";

export const TodoList: FC = () => {
  // Recoilで管理されているTODOリストのデータを取得する
  const todoList = useRecoilValue(todoListState);

  return (
    <section className="max-w-2xl mx-auto">
      <header className="py-4 text-left">
        <h1 className="text-3xl font-bold">Todo List</h1>
      </header>
      <ul className="divide-y">
        {todoList.map((todoItem) => (
          <TodoItem key={todoItem.id} todoItem={todoItem} />
        ))}
      </ul>
    </section>
  );
};

上記の useRecoilValue でTODOリストのデータを読み込んでいます。TODOリストのデータは以下のように atom を使って定義しています。

// src/components/TodoList/store.ts
import { atom } from "recoil";
import { Todo } from "./types";
import { fetchTodoList } from "./api";

export const todoListState = atom<Todo[]>({
  key: "TodoList",
  default: fetchTodoList(),
});

なお、TODOリストのデータ取得には JSONPlaceholder を利用しています。

// src/components/TodoList/api.ts
import { Todo, PlaceholderTodo } from "./types";
import { dataToEntity } from "./converter";

export const fetchTodoList = async (): Promise<Todo[]> => {
  const response = await fetch("https://jsonplaceholder.typicode.com/todos/");
  const json = (await response.json()) as PlaceholderTodo[];
  const todoList = json.map((data) => dataToEntity(data));
  return todoList;
};

テストの実装

TODOリストが正しく表示されていることを確認するテストを書いてみます。

// src/components/TodoList/index.test.tsx
import { render, screen } from "@testing-library/react";
import { RecoilRoot } from "recoil";
import { TodoList } from "./index";
import { Todo } from "./types";

// テストで利用するTODOリストのデータ
const mockTodoList: Todo[] = [
  { id: 0, label: "Todo1", isDone: true },
  { id: 1, label: "Todo2", isDone: false },
];

// TODOリスト取得用APIのレスポンスをモックする
jest.mock("./api", () => ({
  fetchTodoList: () =>
    new Promise((resolve) => {
      setTimeout(
        () =>
          resolve(mockTodoList),
        100
      );
    }),
}));

describe("TodoList", () => {
  it("show fetched todo list", async () => {
    render(
      // Recoilを利用する場合は RecoilRoot でラップする
      <RecoilRoot>
        <TodoList />
      </RecoilRoot>
    );

    // TODOリストが表示されていることを確認する
    mockTodoList.forEach(async (todo) => {
      expect(await screen.findByText(todo.label)).toBeVisible();
    });
  });
});

Step 2:TODOリストの検索

TODOリストの表示ができたので、任意のキーワードで検索できるようにしてみましょう。

UIの実装

まずは TODOリストを検索するための UIコンポーネントを定義します。

// src/components/TodoList/components/TodoFilter/index.tsx
import { FC } from "react";
import { useRecoilState } from "recoil";
import { todoQueryState } from "../../store";

export const TodoFilter: FC = () => {
  // 検索ワードを取得、保存するためのステート
  const [query, setQuery] = useRecoilState(todoQueryState);

  return (
    <label className="flex justify-end gap-4 mt-8 mb-2">
      <input
        className="h-8 border rounded-md px-4"
        type="text"
        defaultValue={query.label}
        aria-label="Search Todo"
        placeholder="Search Todo"
        onChange={(e) => setQuery({ ...query, label: e.target.value })}
      />
    </label>
  );
};

ユーザーが入力した検索ワードは Recoil のステートで管理します。

// src/components/TodoList/store.ts
import { atom } from "recoil";
import { TodoListQuery } from "./types";

export const todoQueryState = atom<TodoListQuery>({
  key: "TodoSearch",
  default: {
    label: undefined,
  },
});

また、検索ワードをもとにTODOリストをフィルタリングして表示するために Recoil の selector を利用し、TODOリストの検索結果を返す関数 filteredTodoListState を定義します。

// src/components/TodoList/store.ts
import { atom, selector } from "recoil";
import { Todo, TodoListQuery } from "./types";
import { fetchTodoList } from "./api";

export const todoListState = atom<Todo[]>({
  // 省略
});

export const todoQueryState = atom<TodoListQuery>({
  // 省略
});

export const filteredTodoListState = selector<Todo[]>({
  key: "FilteredTodoList",
  get: ({ get }) => {
    const query = get(todoQueryState);
    const todoList = get(todoListState);
    let filteredTodoList = todoList;

    if (query.label) {
      filteredTodoList = todoList.filter(
        (todoItem) => todoItem.label.indexOf(query.label!) > -1
      );
    }

    return filteredTodoList;
  },
});

上記のUIコンポーネントと selector を使って、TODOリストを検索できるようにします。

import { FC } from "react";
import { TodoItem, TodoFilter } from "./components";
import { useRecoilValue } from "recoil";
import { filteredTodoListState } from "./store";

export const TodoList: FC = () => {
  // 検索ワードでフィルタリングしたTODOリストを取得する
  const todoList = useRecoilValue(filteredTodoListState);

  return (
    <section className="max-w-2xl mx-auto">
      <header className="py-4 text-left">
        <h1 className="text-3xl font-bold">Todo List</h1>
      </header>
      { /* 検索用のUIコンポーネントを表示する */ }
      <TodoFilter />
      <ul className="divide-y">
        {todoList.map((todoItem) => (
          <TodoItem key={todoItem.id} todoItem={todoItem} />
        ))}
      </ul>
    </section>
  );
};

テストの実装

検索ワードを入力するとTODOリストがフィルタリングされて表示されることを確認するテストを書いてみましょう。

describe("TodoList", () => {
  // 省略

  describe("when todo was filtered", () => {
    beforeEach(async () => {
      render(
        <RecoilRoot>
          <TodoList />
        </RecoilRoot>
      );

      await screen.findByText("Todo1");

      // 検索ワードを入力する
      userEvent.type(screen.getByLabelText("Search Todo"), "1");
    });

    it("show filtered todos only", () => {
      // Todo1は表示されるが、Todo2は存在しないことを確認する
      expect(screen.getByText("Todo1")).toBeVisible();
      expect(screen.queryByText("Todo2")).not.toBeInTheDocument();
    });
  });
});

Step 3:TODOリストの追加

最後のステップとして、ユーザーがTODOリストを追加できるようにしてみましょう。

UIの実装

まずはユーザーがTODOのテキストを入力し、追加するためのUIコンポーネントを定義します。

// src/components/TodoList/components/TodoItemCreator/index.tsx
import { FC, useState, FormEventHandler } from "react";
import { useRecoilValue } from "recoil";
import { Todo } from "components/TodoList/types";
import { todoListState } from "components/TodoList/store";

type Props = {
  onSubmit: (todoItem: Todo) => void;
};

export const TodoItemCreator: FC<Props> = ({ onSubmit }) => {
  const todoList = useRecoilValue(todoListState);
  const [value, setValue] = useState<string>("");

  const addItem: FormEventHandler = (e) => {
    const maxId = Math.max(...todoList.map((todo) => todo.id));
    onSubmit({
      id: maxId + 1,
      label: value,
      isDone: false,
    });
    setValue("");
    e.preventDefault();
  };

  return (
    <form className="flex gap-4 bg-gray-100 p-4 rounded-md" onSubmit={addItem}>
      <input
        className="w-full h-10 border rounded-md p-4"
        type="text"
        value={value}
        aria-label="Input name of todo"
        placeholder="Input name of todo"
        onChange={(e) => setValue(e.target.value)}
      />
      <button
        className="bg-blue-500 hover:bg-blue-400 active:bg-blue-300 text-white font-bold px-4 rounded-md"
        type="submit"
      >
        Add
      </button>
    </form>
  );
};

上記のUIコンポーネントを表示し、新しいTODOがサブミットされた場合は Recoil のステートを更新します。

import { FC } from "react";
import { TodoItem, TodoFilter, TodoItemCreator } from "./components";
import { useRecoilValue, useSetRecoilState } from "recoil";
import { todoListState, filteredTodoListState } from "./store";
import { Todo } from "./types";

export const TodoList: FC = () => {
  // Recoilのステートを更新するための関数を取得する
  const setTodoList = useSetRecoilState(todoListState);
  const todoList = useRecoilValue(filteredTodoListState);

  const addItem = (todoItem: Todo) => {
    // RecoilのステートにTODOを追加する
    setTodoList([todoItem, ...todoList]);
  };

  return (
    <section className="max-w-2xl mx-auto">
      <header className="py-4 text-left">
        <h1 className="text-3xl font-bold">Todo List</h1>
      </header>
      { /* TODOを追加するためのUIを表示する */ }
      <TodoItemCreator onSubmit={addItem} />
      <TodoFilter />
      <ul className="divide-y">
        {todoList.map((todoItem) => (
          <TodoItem key={todoItem.id} todoItem={todoItem} />
        ))}
      </ul>
    </section>
  );
};

テストの実装

TODOリストに新しいTODOが追加できることを確認するためのテストを書いてみましょう。

// src/components/TodoList/index.tsx
describe("TodoList", () => {
  // 省略

  describe("when todo item was added", () => {
    beforeEach(async () => {
      render(
        <RecoilRoot>
          <TodoList />
        </RecoilRoot>
      );

      await screen.findByText("Todo1");

      // 新しいTODOとして"Todo3"を追加する
      userEvent.type(screen.getByLabelText("Input name of todo"), "Todo3");
      userEvent.click(screen.getByRole("button"));
    });

    it("show new todo", () => {
      // "Todo3"が表示されていることを確認する
      expect(screen.getByText("Todo3")).toBeVisible();
    });
  });
});

なお、上記では新しく追加したTODO = ”Todo3”が表示されていることを確認していますが、Recoilのステートが正しく更新されているかどうかを確認するために RecoilObserver を使ってテストする方法もあります。RecoilObserver は公式で紹介されている atom の状態変更を検知する手法です。

// src/tests/index.ts
import { useEffect } from "react";
import { useRecoilValue, RecoilState } from "recoil";

export const RecoilObserver = <T>({ node, onChange }: Props<T>) => {
  const value = useRecoilValue(node);
  useEffect(() => onChange(value), [onChange, value]);
  return null;
};
// src/components/TodoList/index.test.tsx
describe("TodoList", () => {
  // 省略

  describe("when todo item was added", () => {
    const onChange = jest.fn();
    beforeEach(async () => {
      render(
        <RecoilRoot>
          { /* RecoilObserverを使ってステートの変更を検知する */ }
          <RecoilObserver node={todoListState} onChange={onChange} />
          <TodoList />
        </RecoilRoot>
      );

      await screen.findByText("Todo1");

      userEvent.type(screen.getByLabelText("Input name of todo"), "Todo3");
      userEvent.click(screen.getByRole("button"));
    });

    it("add new todo in recoil state", async () => {
      // TODOリストのステートに新しいTODOが追加されていることを確認する
      expect(onChange).toHaveBeenCalledTimes(2);
      expect(onChange).toHaveBeenNthCalledWith(1, mockTodoList);
      expect(onChange).toHaveBeenNthCalledWith(2, [
        {
          id: 2,
          label: "Todo3",
          isDone: false,
        },
        ...mockTodoList,
      ]);
    });
  });
});

おわりに

少々長くなってしまいましたが、Recoilを使ったTODOリストの実装とReact Testing Libraryを使ったテストについてご紹介しました。

公式ドキュメントの方には、Recoilのステートを含むカスタムフックのテストやSnapshotを使ったテストも紹介されていますので、併せてご覧ください。

recoiljs.org