🙆‍♂️

Presentational / Container Componentのテスト

2022/07/04に公開

React Componentの設計パターンの一つのPresentational / Containerパターン
パターン自体の考え方やサンプルコードは幾つかのブログの記事等で見かけましたが、各Componentのテスト方法については見かけなかった(気がする)ので、自分の考えの整理も兼ねて、テストに着目した記事を描いてみようと思いました。

ComponentをPresentationalとContainerに分離させる設計パターン

Dan Abramov氏によるComponent設計の考え方。

Componentの再利用性など幾つかのメリット(後述)を目的として、Componentを以下の2つに分ける。

  • Container Component
    →APIによるデータ取得やドメインロジック(計算や判定)などを行い、結果をpropsとしてPresentational Componentに渡す。
  • Presentational Component
    →propsで受け取ったデータを表示する。

メリット

Presentationalの表示部分と、Containerの業務ロジック部分が分離される為・・・

  • Componentの再利用性が向上する。
  • 可読性が向上する。
  • テスト対象が明確になる。

デメリット

  • コード量が増加する。

どのようにテストするか

ここから本題です。
今回はAPIからTODOのデータを取得し、表示するというコンポーネントを対象とします。
前述の各Componentの責務を再度確認します。

  • Container Component
    →APIによるデータ取得やドメインロジック(計算や判定)などを行い、結果をpropsとしてPresentational > Componentに渡す。
  • Presentational Component
    →propsで受け取ったデータを表示する。

上記から、主なテスト観点は以下になると考えます。

  • Container Component
    →Presentational Componentに期待したpropsを渡していること
  • Presentational Component
    →Container Componentから期待したpropsが渡されること

サンプルコード

Container側のコードです。
ダミーデータを返してくれるAPIサーバーへリクエストし、取得結果をPresenterに渡します。

ListContainer.js
import axios from "axios"
import { useEffect, useState } from "react";
import Presenter from "./Presenter";

export interface TodoType {
  userId: number
  id: number
  title: string
  completed: boolean
}

export default () => {
  const [todos, setTodos] = useState<TodoType[]>([]);
  const URL = "https://jsonplaceholder.typicode.com/todos/";

  useEffect(() => {
    (async () => {
      const resp = await axios.get(URL);
      setTodos(resp.data);
    })();
  }, []);
 
  return (
    <Presenter todos={todos}></Presenter>
  );
}

Presenter側のコードです。
Container側からpropsで渡されたデータを表示します。
データが0件の時、1件以上存在する時で表示を分岐しています。

Presenter.js
import { TodoType } from "./ListContainer"

export interface PresenterProps {
  todos: TodoType[]
}

export default (props: PresenterProps) => {
  return (
    <div className="wrapper">
      {
        props.todos.length === 0
        ?
          <span data-testid="no-todos">データなし</span>
        :
          <div data-testid="todos">
          {
            props.todos.map((todo: any, index: number) => {
              return (
                <div key={index}>
                  {todo.title}
                </div>
              )
            })
          }
          </div>
      }
    </div>
  )
}

Container Componentのテスト

Container側のテストコードです。
Presenter側のComponentをモックして todos が渡されていることを確認します。
2回の呼び出しを確認しているのは、stateの初期値(空配列)から、データ取得が完了し、stateが更新されたことを確認する為です。

ListContainer.test.tsx
import React from 'react';
import { cleanup, render, waitFor } from '@testing-library/react';
import ListContainer from "../ListContainer";
import * as Presenter from '../Presenter';
import { act } from 'react-dom/test-utils';

afterEach(() => {
  cleanup();
});

describe("ListContainerのテスト", () => {
  test("Containerから todos の引数が渡されていること。", async () => {
    const mock = jest.spyOn(Presenter, "default");
    await act(() => {
      render(<ListContainer />);
    });
    await waitFor(() => {
      expect(mock).toBeCalledTimes(2);
      expect(mock).toHaveBeenNthCalledWith(2, {
        todos: expect.any(Array)
      }, {});
    }, { timeout: 5000 });
  });
});

Presentational Componentのテスト

Presenter側のテストコードです。
propsで受け取るデータが0件の時、1件以上の時で表示が切り替わること、合わせてスナップショットの確認をします。

Presenter.test.tsx
import React from 'react';
import { cleanup, render, RenderResult, screen, waitFor } from '@testing-library/react';
import ListContainer from '../ListContainer';
import Presenter from '../Presenter';
import { act } from 'react-dom/test-utils';

afterEach(() => {
  cleanup();
});

describe("Presenterのテスト", () => {
  test("todos = 0件 の時、「データなし」が表示されること", async () => {
    await act(() => {
    render(<Presenter todos={[]} />);
    });
    expect(screen.getByTestId("no-todos")).toHaveTextContent("データなし");
  });
  test("todos > 0件 の時、todo表示要素「todos」が表示されること", async () => {
    await act(() => {
      render(<Presenter todos={[{id: 1, title: "test", userId: 1, completed: true}]} />);
    });
    // state更新後の再描画を待機
    await waitFor(() => {
      expect(screen.getByTestId("todos").children.length).toBe(1);
    });
  });
  test("スナップショットが一致していること", async () => {
    let element: RenderResult;
    await act(() => {
      element = render(<ListContainer />);
    });
    // 初期値は[]の為、0件の表示
    expect(screen.getByTestId("no-todos")).toHaveTextContent("データなし");
    // 200件取得されるまで待機
    await waitFor(() => {
      expect(screen.getByTestId("todos").children.length).toBe(200);
    });
    // asFragment > toMatchSnapshotでスナップショットテスト
    expect(element!.asFragment()).toMatchSnapshot();
  });
});

参考

  • Container / Presentational Componentの設計パターンについて
  • default exportしたComponentをspyOn()する方法
  • JestのユニットテストでComponentのpropsが渡されていることを確認する方法

Discussion