6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

入れ子の目次で分かる Container / Presentational パターンのテスト容易性

Posted at

お疲れ様です。eヘルスケアの袋瀬です。

この記事では react で「Container / Presentational パターンはテストがしやすい」という事を
入れ子の目次を作成する過程で見ていきたいと思います。

入れ子の目次はデータの整形が大変だった

最近、業務で以下のような目次を作成する必要がありました。

この目次は、headlessCMS(microCMS)からデータ連携され、そのデータ構造は以下となります。(目次に必要なデータだけを抜粋)

headers: [
  {
    fieldId: "mainHeader",
    text: "大見出し1",
  },
  {
    fieldId: "subHeader",
    text: "小見出し1-1",
  },
  {
    fieldId: "mainHeader",
    text: "大見出し2",
  },
  // 以下続く
  ...
]

このデータをもとに、入れ子の目次を作っていきますが、この記事はテスト容易性が主のため、目次実装パートはサクサクといきます。

入れ子の目次を作成

目次のHTML構造は以下のイメージで、順序付きリストのアイテムは「大見出し」と「小見出しリスト」を持ちます。「小見出しリスト」はない場合もあります。

<div>
  <h3>目次</h3>
  <ol>
    <li>
      <!-- ↓↓↓↓↓↓ 「大見出し」と「小見出しリスト」を持っている -->
      <a>大見出し1</a>
      <ul>
        <li><a>小見出し1</a></li>
      </ul>
     <!-- ↑↑↑↑↑↑ -->
    </li>
    <li>
      <a>大見出し2</a>
      <ul>
        <li><a>小見出し2-1</a></li>
        <li><a>小見出し2-2</a></li>
      </ul>
    </li>
    <li>
      <!-- ↓↓↓↓↓↓ 「大見出し」だけ -->
      <a>大見出し3</a>
    <!-- ↑↑↑↑↑↑ -->
    </li>
  </ol>
</div>

つまり、microCMSからのデータを「大見出し」と「小見出しリスト」に分ければ実装できそうです。
そのように型を定義しておきましょう。

type MainHeader = {
  fieldId: "mainHeader";
  text: string;
};

type SubHeader = {
  fieldId: "subHeader";
  text: string;
};

// 大見出しと小見出しリストを保持するオブジェクト
type tableOfContent = {
  mainHeader: MainHeader;
  subHeaders: SubHeader[];
};

それでは、目次コンポーネントを実装していきます。microCMS からのデータを理想の形に変えて、目次HTMLを構築しています。
(ここでは、何かごちゃごちゃして、データ整形しているな程度の理解で大丈夫です)

TableOfContents
export const TableOfContents: React.FC<Props> = (props) => {
  let firstHeader: Header;
  let headers: Header[];
  [firstHeader, ...headers] = props.headers;

  if (firstHeader?.fieldId !== "mainHeader") {
    throw new Error("最初の見出しは大見出しとして下さい");
  }

  let tableOfContent: tableOfContent = {
    mainHeader: firstHeader,
    subHeaders: [],
  };

  let tableOfContents: tableOfContent[] = [];
  headers.forEach((header) => {
    if (header.fieldId === "mainHeader") {
      tableOfContents.push(tableOfContent);
      tableOfContent = { mainHeader: header, subHeaders: [] };
    } else if (header.fieldId === "subHeader") {
      tableOfContent.subHeaders.push(header);
    }
  });
  tableOfContents.push(tableOfContent);

  return (
    <>
      <div>
        <h3>目次</h3>
        <ol>
          {tableOfContents.map((toc) => (
            <MainHeaderListItem key={toc.mainHeader.text} header={toc.mainHeader}>
              <SubHeaderList headers={toc.subHeaders} />
            </MainHeaderListItem>
          ))}
        </ol>
      </div>
    </>
  );
};

さて、ここで気になるのは、本当にデータが正しく整形されているかです。

目次に大見出しがない場合は本当にエラーになるのか。逆にそれ以外はエラーにならないか。
小見出しだけがない場合は問題ない?
大見出しが1つだけの場合は?
...

色々不安がよぎります。

さて、ここからが本題です。

テストしずら過ぎ問題

テストをしたいが Props のデータを変えて、毎回正しい HTML が作成されていることを確認するのは、かなり手間ですし、例えば、<h3>目次</h3><h2> に変えるという変更が入った場合、全てのテストを修正する必要があります。これは楽しくありません。data-testid属性を使って、正しいテキストが存在することなどは確認できますが、まずは、データ整形が正しい事に焦点をあてたテストが実施したいです。

そこで、出てくるのが Container / Presentational パターン です。
これは、関心の分離を実施するパターンです。(パターンでは、よく出てきますよね。関心の分離)

プレゼンテーションコンポーネント: UIを責務として、どのように表示されるかを担う
コンテナコンポーネント: ロジックを責務として、どのようなデータをプレゼンテーションに渡すかを担う

今回の例をこのパターンに適用してみます

TableOfContents.tsx
export const TableOfContents: React.FC<Props> = (props) => {
  //ロジックの部分は同じ
  ...

  // データをプレゼンターに渡す
  return <Presenter tableOfContents={tableOfContents} />;
};
Presenter.tsx
export const Presenter: React.FC<{ tableOfContents: tableOfContent[] }> = ({ tableOfContents }) => {
  // html の部分は同じ
  return (
    ...
  );
};

ただ、コンポーネントをロジック(コンテナ)と表示(プレゼンター)で分けて、コンテナからプレゼンターにデータを渡しただけです。では、テストを見てみましょう。
今回は「データが正しく整形されているか」を確認したいので、プレゼンターはモックとします。そうすることにより、素敵なことが起きます。

以下のテストの expect の行を見て頂ければ分かると思いますが、データ整形が正しく行われているかのテストが出来ています!
INで入ってきたデータがOUTで正しい形になっているかのテストが出来ています!!

jest
jest.mock("../Presenter", () => ({
  Presenter(props: tableOfContent[]) {
    mockPresenter(props);
    return <div>モック</div>;
  },
}));

afterEach(() => {
  jest.clearAllMocks();
});

test("大見出しとそれに紐づく小見出しとして分割されること", () => {
  const headers: Header[] = [
    { fieldId: "mainHeader", text: "大見出し1" },
    { fieldId: "subHeader", text: "小見出し1-1" },
    { fieldId: "mainHeader", text: "大見出し2" },
    { fieldId: "subHeader", text: "小見出し2-1" },
    { fieldId: "subHeader", text: "小見出し2-2" },
    { fieldId: "mainHeader", text: "大見出し3" },
  ];

  render(<TableOfContents headers={headers} />);

  expect(mockPresenter).toHaveBeenCalledWith({
    tableOfContents: [
      {
        mainHeader: { fieldId: "mainHeader", text: "大見出し1" },
        subHeaders: [{ fieldId: "subHeader", text: "小見出し1-1" }],
      },
      {
        mainHeader: { fieldId: "mainHeader", text: "大見出し2" },
        subHeaders: [
          { fieldId: "subHeader", text: "小見出し2-1" },
          { fieldId: "subHeader", text: "小見出し2-2" },
        ],
      },
      { mainHeader: { fieldId: "mainHeader", text: "大見出し3" }, subHeaders: [] },
    ],
  });
});

どうでしょうか。「データが正しく整形されているか」が分かりやすいテストになっていると思いませんか。
小見出しがない場合のテストも簡単にできます。

jest
test("小見出しがない場合は大見出しだけで分類されること", () => {
  const headers: Header[] = [
    { fieldId: "mainHeader", text: "大見出し1" },
    { fieldId: "mainHeader", text: "大見出し2" },
    { fieldId: "mainHeader", text: "大見出し3" },
  ];

  render(<TableOfContents headers={headers} />);

  expect(mockPresenter).toHaveBeenCalledWith({
    tableOfContents: [
      {
        mainHeader: { fieldId: "mainHeader", text: "大見出し1" },
        subHeaders: [],
      },
      {
        mainHeader: { fieldId: "mainHeader", text: "大見出し2" },
        subHeaders: [],
      },
      {
        mainHeader: { fieldId: "mainHeader", text: "大見出し3" },
        subHeaders: [],
      },
    ],
  });
});

これなら、どんどんテスト書けそうですね。
また、プレゼンターはモックとなっているので、プレゼンター側のHTML変更が起きても、テストを変更する必要がありません。

楽しく実装したい

今回、入れ子の目次を作成する過程で、Container / Presentational パターン の良い例だなと思ったので、この記事を書きました。記事ではテスト作成が最後でしたが、最初からContainer / Presentational パターンで実装している場合は、テストファーストの開発も可能です。こういうデータがあれば良いという事が分かれば、その入力値と返り値のテストを書いてしまい、その後にコンテナコンポーネントを実装するという流れです。

Container / Presentational パターンTDD が正義という事はないですが、楽しく実装していきたいとは思っています。

こちらからは以上です。

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?