logo

NextJSでreact-testing-library、Jestを利用する

2023-02-09
a year ago

開発環境

  • next 13.1.2
  • react 18.2.0
  • jest 29.3.1
  • msw 1.0.0
  • next-page-tester 0.33.0

前提

環境のセットアップとLink、SSGのテストコードの実装例の紹介になります。

APIはJsonPlaceholderを利用しています。

本題

環境構築

まずJestが組み込まれたテンプレートを利用してNextのプロジェクトを立ち上げ、モックサーバを利用したいので、mswをインストールします。

$ yarn create next-app --example with-jest [プロジェクト名]
$ yarn add msw

次に設定ファイルを追加編集していきます。

$ touch next.config.js
module.exports = {
  trailingSlash: true,
};
.babelrc
{
  "name": "test-nextjs",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "jest --env=jsdom --verbose -u", // 変更
    "test:ci": "jest --ci"
  },
	...省略
}
package.json
const nextJest = require('next/jest');

const createJestConfig = nextJest({
  // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
  dir: './',
});

// Add any custom config to be passed to Jest
const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapper: {
    // Handle module aliases (this will be automatically configured for you soon)
    // '^@/components/(.*)$': '<rootDir>/components/$1',

    // '^@/pages/(.*)$': '<rootDir>/pages/$1',

    '^@/(.*)$': '<rootDir>/src/$1',
  },
  testEnvironment: 'jest-environment-jsdom',
};

// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);
jest.config.js
// Optional: configure or set up a testing framework before each test.
// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`

// Used for __tests__/testing-library.js
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';
import { TextEncoder, TextDecoder } from 'util';

// Polyfill for encoding which isn't present globally in jsdom
if (typeof global.TextEncoder === 'undefined') {
  global.TextEncoder = TextEncoder;
}

if (typeof global.TextDecoder === 'undefined') {
  global.TextDecoder = TextDecoder;
}
jest.setup.js

Linkのテスト

まず必要なライブラリをインストールします。

$ yarn add -D next-page-tester

※indexページにnext/linkを利用したナビゲーションバーがあることを前提にしています。

※各種Linkにはdata-testidのような一意の属性を持たせることを前提にしています。

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getPage, initTestHelpers } from 'next-page-tester';

initTestHelpers();

describe('Navigation by Link', () => {
  it('Should selected link page navigations', async () => {
    // 1. ページを取得
    const { page } = await getPage({
      route: '/index',
    });
    // 2. ページをレンダリング
    render(page);
    // 3. ユーザーイベント発生(data-testid = 'a-nav'のLinkコンポーネントをクリック)
    await waitFor(() => userEvent.click(screen.getByTestId('a-nav')));
    // 4. 期待する動作(上記リンク先のページに「A Page」という文字がある)
    expect(await screen.findByText('A Page')).toBeInTheDocument();

    await waitFor(() => userEvent.click(screen.getByTestId('b-nav')));
    expect(await screen.findByText('B Page')).toBeInTheDocument();
		
		screen.debug(); // 必要に応じて
  });
});

next-page-testerを利用する際は非同期で実行することになります。

ユーザーイベントの発生をwaitForで実行しないとテストはパスしますが、ログに警告が出るのでこちらの書き方が推奨されているようです。

SSGのテスト

SSGで実装された一覧画面、詳細画面のテスト例を実装してみます。

一覧画面(※ブログ記事を想定)

import { render, screen, cleanup } from '@testing-library/react';
import { getPage } from 'next-page-tester';
import { initTestHelpers } from 'next-page-tester';
import { rest } from 'msw';
import { setupServer } from 'msw/node';

initTestHelpers();

// 1. apiを叩いた時のレスポンスデータを定義
const handlers = [
  rest.get('https://jsonplaceholder.typicode.com/posts/', (req, res, ctx) => {
    const _limit = req.url.searchParams.get('_limit');
    if (_limit === '10') {
      return res(
        ctx.status(200),
        ctx.json([
          {
            id: 1,
            title: 'dummy title 1',
            body: 'dummy body 1',
            userId: 1,
          },
          {
            id: 2,
            title: 'dummy title 2',
            body: 'dummy body 2',
            userId: 2,
          },
        ])
      );
    }
  }),
];

// 2. モックサーバー連携
// 2-1. 設置
const server = setupServer(...handlers);
// 2-2. 起動
beforeAll(() => server.listen());
// 2-3. テストケースごとに副作用が起こらないように都度初期化
afterEach(() => {
  server.resetHandlers();
  cleanup();
});
// 2-4. 切断
afterAll(() => server.close());

describe('Blog Page', () => {
  it('Should render the list of blogs pre-fetched by SSG', async () => {
    const { page } = await getPage({
      route: '/blog-page',
    });

    render(page); // 構造の取得

    expect(await screen.findByText('Blog Page')).toBeInTheDocument();
    expect(screen.getByText('dummy title 1')).toBeInTheDocument();
    expect(screen.getByText('dummy title 2')).toBeInTheDocument();
  });
});
BlogPage.test.tsx

詳細画面

import { render, screen, cleanup } from '@testing-library/react';
import { getPage } from 'next-page-tester';
import { initTestHelpers } from 'next-page-tester';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import userEvent from '@testing-library/user-event';

initTestHelpers();

const handlers = [
  rest.get('https://jsonplaceholder.typicode.com/posts/', (req, res, ctx) => {
    const _limit = req.url.searchParams.get('_limit');
    if (_limit === '10') {
      return res(
        ctx.status(200),
        ctx.json([
          {
            id: 1,
            title: 'dummy title 1',
            body: 'dummy body 1',
            userId: 1,
          },
          {
            id: 2,
            title: 'dummy title 2',
            body: 'dummy body 2',
            userId: 2,
          },
        ])
      );
    }
  }),
  rest.get('https://jsonplaceholder.typicode.com/posts/1', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        id: 1,
        title: 'dummy title 1',
        body: 'dummy body 1',
        userId: 1,
      })
    );
  }),
];

const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => {
  server.resetHandlers();
  cleanup();
});
afterAll(() => server.close());

describe('Blog detail page', () => {
  it('Should render detail content of id=1', async () => {
    const { page } = await getPage({
      route: '/posts/1',
    });

    render(page);

    // 初めにコンテンツの取得するまでawaitをかける
    // findはPromiseを返す
    expect(await screen.findByText('dummy title 1')).toBeInTheDocument();
    expect(screen.getByText('dummy body 1')).toBeInTheDocument();
  });

  it('Should route back top-page from blog detail page', async () => {
    const { page } = await getPage({
      route: '/posts/1',
    });

    render(page);
    // コンテンツを取得できるまで待つ
    await screen.findByText('dummy title 1');
    // クリックイベントを発火
    userEvent.click(screen.getByTestId('back-blog'));

    // イベント発火後なのでawaitかける
    expect(await screen.findByText('Blog Page')).toBeInTheDocument();
  });
});

さいごに

react-queryuseSWRなどライブラリを絡めたテストも今後調べていきたいです。

参照