Next.jsのテスト戦略 => 結論 Container/Presentationalを採用しよう!
公開日時:2024/06/29
フロントエンドは技術の変化が激しいと言われており、最も注目度の高いReactのフレームワークであるNext.jsも例外ではない。(むしろ変化の中心といった方が正しいかも)
それに伴い、テストについても状況が目まぐるしく変わっている。私自身業務でNext.jsのapp routerを使っており、パッと思いつくだけでも結構な数つまづいた経験がある。
- app routerがMSWに対応していない。
この時点でテストでのみMSWを使うという判断にはならなかった。 - server componentはjestでテストできない?
結果としてコンポーネントを非同期関数として定義してそれをrenderするという方法で解決した。 - scrollのテストはどうやる?
jestはwindowの高さという概念を持たないのでjestではこのテストはできないという判断にした。テストしたいのであればE2Eテストの領域になる。 - storybookがserver componentに対応してない。
storybook8で暫定的に対応したが、当初はstorybook7だった。decoratorsを使ってstoryをSuspenseでラップすれば解決できた。どうやら8もオプションの裏でこの設定をやってるっぽい。 - storybookのmock化がうまくいかない。
使ってるaddon
storybookはjestのようにmock化ができない。addonを使えばできるが、storybook7から8にアップデートする際にmockのaddonのみ対応が遅れていた。現時点では使用できるようになっている。
しかし、openAPIの定義ファイルから型安全にfetchを行うためにapiClientを作成したらmock化がうまくできなくなってしまった。どうやらfetch関数までの間にapiClientが挟まったことによりstorybookのmock化が干渉できず、fetchが走ってしまうよう。
このaddonはapi関数をmock化しているのではなく、fetchのエンドポイントを指定してレスポンスをmock化しようとしているので、それがうまくいかないのだろう...。mswのようにhttpリクエストをインターセプトするようなことをやっていると予想。
storybookでもmswを使うことができ、mswを使えばうまくmock化できることは確認した。
app routerでのテストに関しては情報が少なく、かなり苦戦した記憶がある。(現在進行形も...)
Next.js公式の方でもテストに関する記載は少ない。どちらかというとE2Eテスト(Playwrightなど)推しなのかな?という印象がある。
確かにE2Eテストも進化していてできることが増えているが、実行速度としては Jest > Storybook > E2E となる。可能な限り実行速度が速い方でテストをしたいものである。
では、テストを作成するにあたって苦戦する原因となったのはなんだったのだろうか?
そのほとんどがserver component絡み、つまりサーバサイドの処理を含んだコンポーネントをそのままテストしようとしていることだった。
それを解決するのがContainer/Presentationalパターン!
Container/Presentationalとは?
フロントエンドのデザインパターンの一つ。簡単に言ってしまうとロジック部分とUIを分離するデザインパターン。
ContainerがAPIからのデータ取得などのロジックを持ち、Presentationalはそれらをpropsで受け取りUIを構築する。
これはNext.jsの pages router, app router どちらにも当てはまり、
pages routerであればgetStaticPropsなどのサーバサイドの処理をContainerが担当し、それらをpropsでPresentationalに渡してUIを構築する。
app routerであれば、fetchでのデータ取得をContainerが担当し、それらをpropsでPresentationalに渡してUIを構築する。
このデザインパターンを採用することでロジックの分離だけでなく、テスト容易性にも繋がる。
Jest,Storybookでテストしたいのは受け取ったpropsによってコンポーネントがどのようなUIをもたらすかなので、まさにPresentationalがそれに該当する。恐らく技術的な変遷があったとしてもこのPresentationalが分離されていればテストへの影響はあまり起こらないのではないかと思う。
pages router での実例
学習途中だったのでpages routerでの実例になるが、app routerでも同じ。
blogページを作成していて、getStaticPropsで記事のID一覧を取得してリンク一覧を表示しているページがあるとする。
import { Layout } from '@/components/Layout';
import { Post } from '@/components/Post';
import { getAllPostsData } from '@/lib/fetch';
import { Post as PostType } from '@/types/Types';
import { GetStaticProps } from 'next';
const Blog = ({ posts }: { posts: PostType[] }) => {
return (
<Layout title='Blog'>
<p className='text-4xl mb-10'>BlogPage</p>
<ul>{posts && posts.map((post) => <Post key={post.id} {...post} />)}</ul>
</Layout>
);
};
export default Blog;
export const getStaticProps: GetStaticProps = async () => {
const posts = await getAllPostsData();
return {
props: { posts },
};
};
このページがレンダリングされた時にタイトルの`BlogPage`が表示されることをテストしたいが、このページのテストをしたい場合getStaticPropsの処理があるため、Jestではコンポーネントをテストすることができない。(後から気づいたがpropsを渡せば普通にできた。)
これをContainer/Presentationalパターンに置き換えると以下のようになる。
import { BlogPagePresenter } from '@/components/BlogPagePresenter';
import { getAllPostsData } from '@/lib/fetch';
import { Post as PostType } from '@/types/Types';
import { GetStaticProps } from 'next';
const Blog = ({ posts }: { posts: PostType[] }) => {
return <BlogPagePresenter posts={posts} />;
};
export default Blog;
export const getStaticProps: GetStaticProps = async () => {
const posts = await getAllPostsData();
return {
props: { posts },
};
};
import { Layout } from './Layout';
import { Post as PostType } from '@/types/Types';
import { Post } from './Post';
export const BlogPagePresenter = ({ posts }: { posts: PostType[] }) => {
return (
<Layout title='Blog'>
<p className='text-4xl mb-10'>BlogPage</p>
<ul>{posts && posts.map((post) => <Post key={post.id} {...post} />)}</ul>
</Layout>
);
};
特に難しいことはなく、BlogPagePresenterコンポーネントにUI部分を分離することができている。これならばテストやstorybookもmock化する必要がなくなるため容易に作成することができる。以下テスト例
import { Post } from '@/types/Types';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { BlogPagePresenter } from '@/components/BlogPagePresenter';
const postsMock = [
{ userId: 1, id: 1, title: 'dummy title 1', body: 'dummy body 1' },
{ userId: 2, id: 2, title: 'dummy title 2', body: 'dummy body 2' },
] as const satisfies Post[];
describe('BlogPagePresenter', () => {
it('should render posts', () => {
render(<BlogPagePresenter posts={postsMock} />);
expect(screen.getByText('BlogPage')).toBeInTheDocument();
expect(screen.getByText('dummy title 1')).toBeInTheDocument();
expect(screen.getByText('dummy title 2')).toBeInTheDocument();
});
});