Presentational / Container Componentのテスト
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に渡します。
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件以上存在する時で表示を分岐しています。
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が更新されたことを確認する為です。
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件以上の時で表示が切り替わること、合わせてスナップショットの確認をします。
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