お疲れ様です。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を構築しています。
(ここでは、何かごちゃごちゃして、データ整形しているな程度の理解で大丈夫です)
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を責務として、どのように表示されるかを担う
コンテナコンポーネント
: ロジックを責務として、どのようなデータをプレゼンテーションに渡すかを担う
今回の例をこのパターンに適用してみます
export const TableOfContents: React.FC<Props> = (props) => {
//ロジックの部分は同じ
...
// データをプレゼンターに渡す
return <Presenter tableOfContents={tableOfContents} />;
};
export const Presenter: React.FC<{ tableOfContents: tableOfContent[] }> = ({ tableOfContents }) => {
// html の部分は同じ
return (
...
);
};
ただ、コンポーネントをロジック(コンテナ)と表示(プレゼンター)で分けて、コンテナからプレゼンターにデータを渡しただけです。では、テストを見てみましょう。
今回は「データが正しく整形されているか」を確認したいので、プレゼンターはモックとします。そうすることにより、素敵なことが起きます。
以下のテストの expect
の行を見て頂ければ分かると思いますが、データ整形が正しく行われているかのテストが出来ています!
INで入ってきたデータがOUTで正しい形になっているかのテストが出来ています!!
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: [] },
],
});
});
どうでしょうか。「データが正しく整形されているか」が分かりやすいテストになっていると思いませんか。
小見出しがない場合のテストも簡単にできます。
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
が正義という事はないですが、楽しく実装していきたいとは思っています。
こちらからは以上です。