フロントエンド(Next.js)のテストについてまとめる

## はじめに

私はこれまで、独学でフロントエンドの技術を学習し、いくつか練習用にWebアプリケーションも開発してきました。

2021年の10月にフロントエンドエンジニアへの転職を果たし、現在は研修を行っています。
そしてこの度、他の研修生とチームを組んで、社内用(研修生用)のSNSアプリケーションを開発することとなりました。

今までは趣味レベルの個人開発しかしておりませんでしたが、今回初めて、実際に人に使ってもらうものを作成することになります。
そこで、これまで意識してこなかった(避けてきてしまった)ソフトウェアテストもしっかりと行わなければならない、と感じ、１からフロントエンドのテストについて学習することにしました。

そこで学んだことを、メモ帳代わりに、ここにまとめたいと思います。

まずはじめにフロントエンド全体のテスト手法について、その後、現在開発で使用しているNext.jsでどのようなテストを行うのか、また、各テストツールについてまとめています。

## 主なテストの種類
フロントエンドにおけるテストの種類とその内容。

### ▶︎ユニットテスト(単体テスト)
- 定義 : テスト可能な**最小単位**でのテスト
- 各開発現場によってその粒度は様々
    - **関数やモジュール**のような単位でのテストを指すことや、**各機能単位**でのテストを指すことも。
    - ただし、**関数やモジュール**のような単位でのテストを指すことが一般的？
- あらゆるフロントエンドのアプリケーションに必要不可欠である

Reactでの良いコンポーネント設計とユニットテストは、切っても切れない関係にあると書籍で読んだことがあります。
良いコンポーネント設計をしなければユニットテストを書くことはできず、ユニットテストを書きながら(ユニットテストを書けるように)コードを書くことで、自ずと良いコンポーネント設計ができるようになる、と。

良い設計でコードを書くという観点からも、ユニットテストは必要なテストなんだと理解しました。

:::note info
<strong>良いコンポーネント設計</strong>とは？
  「コンポーネントが担う責務が明確であること」という、<strong>単一責任の原則</strong>に則ってコンポーネント分割を行うこと。
:::

- テストツール：[Jest](https://jestjs.io/), [Mocha](https://mochajs.org/), [Jasmine](https://jasmine.github.io/) など
    - かつてはJavaScriptのテストフレームワークとして、MochaやJasmineがよく使われていたが、近年Jestが一番人気のフレームワークとなっているらしい。

### ▶︎結合(統合)テスト

- 定義：個々のモジュールや関数を結合させて、うまく連携・動作しているかを確認するテスト
- フロントエンドのテストにおいては、テストコードを書いてテストする場合と、実際にブラウザで動かしてテストする場合がある(手動で動かしながら,UIを確認してテストすることが多い)
- テストツール：Jest, [Testing Library](https://testing-library.com/) 

### ▶︎E2E(エンドツーエンド)テスト
- 定義：**システム全体**が正しく動作することを確認するテスト
- ユーザと同じようにブラウザを操作し、挙動が期待通りになっているかを確認する
- モックアップではなく、**実際のブラウザ上**で実行される + 実際のデータと実際のAPIを使用
- 多くの開発現場では、テスト仕様書を基に人の手で行われる
- テストツール: [Cypress](https://www.cypress.io/), Puppeteer,  Selenium
    - コードを書いてE2Eテストを行うテスト自動化ツール。地道にぽちぽちと画面操作をせずに済む。
    - 今だと**Cypress**が有名で便利そう。テスト構築〜実行、バグ検知が行え、コマンドごとの画面のスナップショットやテスト一連のビデオを残してくれるらしい。

## Next.jsアプリケーションでのテスト
下記のUdemy講座を受講し、Next.jsアプリケーションのテスト手法について学びました。
[Next.js + TypeScript + Tailwind CSS]というモダンな技術スタックのアプリケーションを作成し、それに対するテストをハンズオンで学べます。

https://www.udemy.com/course/nextjs-react-testing-library-react/

### Next.jsでよく使用されるテストツール
Next.js(React)のユニットテストでは、JestとReact-Testing-Libraryを用いるのがベストプラクティスとされている印象です。
Reactの公式サイトでも、JestとReact-Testing-Libraryが推奨ツールとして挙げられています。（ https://ja.reactjs.org/docs/testing.html )

▶︎**Jest**
JavaScriptのテストランナー(テストを実行するプログラム)。テストケースを読み取って実行、結果の出力を行う。
Jsdomを通じてDOMにアクセスする（ブラウザの模倣環境であり、実際のブラウザで実行するのではない）。
package.jsonに test scriptを追加することで、コマンドで実行することができる。

▶︎**React-Testing-Library**
　Reactのコンポーネントをテストするライブラリ。
Jestと組み合わせて使用することで、アプリケーションへのアクセスを容易にし、コンポーネントの挙動を実際にユーザーが使用するのと近い形でテストすることができる。

### JestとReact-Testing-Libraryの導入
- 必要なモジュールのインストール(Next.jsプロジェクトが作成済みであることが前提)

```
npm i -D jest @testing-library/react @types/jest @testing-library/jest-dom @testing-library/dom babel-jest @testing-library/user-event jest-css-modules
```
or

```
yarn add --dev jest @testing-library/react @types/jest @testing-library/jest-dom @testing-library/dom babel-jest @testing-library/user-event jest-css-modules
```

- .babelrcファイル設定

```
    {
        "presets": ["next/babel"]
    }
```
…babelに対して、Next.jsのプロジェクトに対しJestでテストを行うことを伝える

- package.json に jest の設定を追記

```
"jest": {
        "testPathIgnorePatterns": [
            "<rootDir>/.next/",         // テストの対象外にする
            "<rootDir>/node_modules/"
        ],
        "moduleNameMapper": {　　　　　　// jest-css-moduleでCSSファイルをモッキング(モック化)する設定
            "\\.(css)$": "<rootDir>/node_modules/jest-css-modules"
        }
    }
```

- package.jsonに test scriptを追記

```
"scripts": {
        ...
        "test": "jest --env=jsdom --verbose"  // 追加
    },
```
… 「npm test」 もしくは「yarn test」でテストを実行させることができる
　※オプション　--env=jsdom --verbose　　
　　　　　　　→ テストファイル一つ一つに記載されているテストケースに対して、テストにパスしたかしていないかを出力してくれるように設定

### JestとReact-Testing-Libraryでテストコードを書いてみる

実際に、Next.jsのアプリケーションに対して、テストコードを書いていきます。
プロジェクト直下に`__test__`フォルダを作成し、その中にテスト用ファイルを配置します。
テスト用ファイルの名前は、基本的に`.spec.ts` or `.test.ts`で終わるようにします。

#### ▶︎ テキストがあるかどうかのテスト

```__test__/Home.test.tsx
/**
* @jest-environment jsdom　　　　
*/
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import Home from '../pages/index'

describe('Rendering', () => {
  it('Should render hello text', () => {
    render(<Home />)  // Homeコンポーネントをレンダリング
    expect(screen.getByText('Hello Next.js')).toBeInTheDocument()
  })
}
```

- **describe**はテストのタイトルを定義するもの、**it**はテストケースを定義するもの
    - テスト結果がターミナルに出力された時に、階層になって表示されわかりやすい。
- **render()**では、指定したページやコンポーネントのHTML構造を取得し、レンダリングする
- レンダリングされた内容に対し、テストしたい内容を記述
    - **expect()**を使うことで、検証したい内容でテストを行うことができる
    - レンダーしたコンポーネント内の要素にアクセスするためにReact Testing Libraryの**screen**関数
を使用
    - **getByText('')**でHTML要素内のテキスト内容を取得
    - **toBeInTheDocument()**でドキュメント内に要素が存在するか確認できる

↓ 'yarn test'を実行してテストが通る
<img width="400" alt="スクリーンショット 2022-01-03 7.30.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1007943/c7afb98d-ade7-e7d4-d230-321eae845bf9.png">

#### ▶︎ページ遷移のテスト

- next-page-testerをインストール

```
yarn add next-page-tester
```

- data-testidでテストしたい場所にidを付与

```
     <Link href='/blog-page'>
       <a
         data-testid='blog-nav'
         className='text-gray-300 hover:bg-gray-700 px-3 py-2 rounded'
       >
         Blog
       </a>
     </Link>
```

… 今回は、headerのnav barのリンクでテストする

- テストコード

```__test__/NavBar.test.tsx
/**
 * @jest-environment jsdom
 */
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import userEvent from '@testing-library/user-event'; // ユーザーにクリックさせるため必要
// next-page-testerからgetPageとinitTestHelpersをインポート
import { getPage } from 'next-page-tester';
import { initTestHelpers } from 'next-page-tester'; // 初期設定を行うもの

// next-page-testerを使用するために実行しておく
initTestHelpers();

// Linkタグに対するページ遷移のテストを実施
// describe でテストタイトルを設定
describe('Navigation by Link', () => {
  // next-page-testerを使うには、関数をasyncにする
  it('Should route to selected page in navar', async () => {
    const { page } = await getPage({
      route: '/index', // 取得したいページのパス
    });
    render(page); // HTMLの構造を取得

    // getByTestIdでテストIDを取得し、それに対しクリックのシミュレーションを実施
    userEvent.click(screen.getByTestId('blog-nav'));
    // 非同期の場合は、findByTextでテキストを検索
    expect(await screen.findByText('Blog Page')).toBeInTheDocument();
  });
});
```

- @testing-library/user-eventから**userEvent**をインポート
    - ユーザーの画面操作を発動させるもの
- next-page-testerからgetPageとinitTestHelpersをインポート
    - **getPage()**を実行し、オブジェクト形式でrouteに取得したいページの絶対パスを渡すことで、ページを取得。その後、レンダー。
- **getByTestId**でテストIDを取得し、それに対しクリックのシミュレーションを実施
- **findByTex(''）**でテキストを検索し、意図通りページ遷移していることを確認

#### ▶︎getStaticPaths, getStaticPropsのテスト

- BlogPageでgetStaticPropsによってgetAllPostsData(投稿一覧を取得する関数)を実行しているとする

```TypeScript
export const getStaticProps: GetStaticProps = async () => {
  const posts = await getAllPostsData()
  return {
    props: { posts },
  }
}
```

- **[Mock Service Worker](https://mswjs.io/)(msw)**を用いてモックサーバーをたてる
    - フロントのユニットテスト(・結合テスト)では、実際の本番API(実際のデータ)は使用しない
    - テスト用に定義したエンドポイントにアクセスし、擬似的なレスポンスを取得してテストを行う
        - ○APIの不具合等に関わらず、一貫したテストを実行できる
        - ○バックエンドのサーバーを起動していなくても、テストを行うことができる
        - ○レスポンスの内容を自由に操作できるため、失敗した時のテストも楽に行うことができる

:::note info
<strong>Mock Service Worker</strong>とは
ネットワークレベルで API リクエストをインターセプトして mock のデータを返すためのライブラリ
:::

```__tests__/BlogPage.test.tsx
// APIをモック化
// どの URL のリクエストに対して、どのようなレスポンスを返すのかを定義
const handlers = [
  rest.get(
    'https://jsonplaceholder.typicode.com/posts/?_limit=10',
    (req, res, ctx) => {
      return res(
        // 以下はダミーデータ
        ctx.status(200),
        ctx.json([
          {
            userId: 1,
            id: 1,
            title: 'dummy title 1',
            body: 'dummy body 1',
          },
          {
            userId: 2,
            id: 2,
            title: 'dummy title 2',
            body: 'dummy body 2',
          },
        ]),
      );
    },
  ),
];

// setupServerを使ってサーバーをたてておく
const server = setupServer(...handlers);

beforeAll(() => {
  // モックサーバの起動
  server.listen();
});
// 各テストケースが終わるたびに呼ばれる
afterEach(() => {
  // モックサーバーのリセットとクリーンアップをして、テスト間の副作用が起こらないように。
  server.resetHandlers();
  cleanup();
});
afterAll(() => {
  // サーバーを閉じる
  server.close();
});
```

- テストコード

```__tests__/BlogPage.test.tsx
describe('Blog Page', () => {
  it('Should render the list of blogs pre-fetched by GetStaticProps', async () => {
    const { page } = await getPage({
      route: '/blog-page',
    });
　 // renderでpageの内容を取得する
    render(page);
 　// ブログページのテキストが取得できるまで待機する
    expect(await screen.findByText('Blog Page')).toBeInTheDocument();
 　// ダミーデータのレスポンスがDOMに表示されているかを確認する
    expect(screen.getByText('dummy title 1')).toBeInTheDocument();
    expect(screen.getByText('dummy title 2')).toBeInTheDocument();
  });
});
```

#### ▶︎Propsのテスト
きちんと正しいPropsがコンポーネントに渡され、表示されるかのテスト
コンポーネント、PostComponentにdummyPropsを渡して、それがちゃんと表示されるかをテストする

```__tests__/Props.test.tsx
/**
 * @jest-environment jsdom
 */
import '@testing-library/jest-dom/extend-expect';
import { render, screen } from '@testing-library/react';
import { PostComponent } from '../src/components/Post';
import { Post } from '../src/types/types';

describe('Post component with given props', () => { 

 // ダミーのpropsを定義
  let dummyProps: Post;
  beforeEach(() => {
    dummyProps = {
      userId: 1,
      id: 1,
      title: 'dummy title 1',
      body: 'dummy body 1',
    };
  });

  it('Should render correctly with given props value', () => {
　 // ダミーのpropsを渡してコンポーネントをレンダー
    render(<PostComponent {...dummyProps} />);
    expect(screen.getByText(dummyProps.id)).toBeInTheDocument();
    expect(screen.getByText(dummyProps.title)).toBeInTheDocument();
  });
}); 
```

#### ▶︎useSWR(CSR)のテスト
useSWRでデータをfetchした時の、Success時とError時に正しい表示がされているかのテスト

```__tests__/CommentPage.test.tsx
// APIのモックサーバーを立てる
const handlers = [
  rest.get(
    'https://jsonplaceholder.typicode.com/comments/?_limit=10',
    // ダミーデータ
    (req, res, ctx) => {
      return res(
        ctx.status(200),
        ctx.json([
          {
            postId: 1,
            id: 1,
            name: 'A',
            email: 'dummya@example.com',
            body: 'dummy body A',
          },
          {
            postId: 2,
            id: 2,
            name: 'B',
            email: 'dummyb@example.com',
            body: 'dummy body B',
          },
        ]),
      );
    },
  ),
];
const server = setupServer(...handlers);
beforeAll(() => {
  server.listen();
});
afterEach(() => {
  server.resetHandlers();
  cleanup();
});
afterAll(() => {
  server.close();
});

describe('Comment page with useSWR / Success+Error', () => {
 // Success時
  it('Should render the value fetched by useSWR', async () => {
    render(
      // useSWRの機能もテストしたい場合は、SWRConfigでラップ
      // dedupingIntervalを0にすることで、useSWRのデータをキャッシュしないようにする
      <SWRConfig value={{ dedupingInterval: 0 }}>
        <CommentPage />
      </SWRConfig>,
    );
    expect(await screen.findByText('1: dummy body A'));
    expect(screen.getByText('by A'));
    expect(screen.getByText('2: dummy body B'));
    expect(screen.getByText('by B'));
  });

 // Error発生時
  it('Should render Error text when fetch failed', async () => {
    // エラー用にモックサーバの上書き
    server.use(
      rest.get(
        'https://jsonplaceholder.typicode.com/comments/?_limit=10',
        (req, res, ctx) => {
          return res(ctx.status(400));
        },
      ),
    );
    render(
      <SWRConfig value={{ dedupingInterval: 0 }}>
        <CommentPage />
      </SWRConfig>,
    );
    expect(await screen.findByText('Error!'));
    screen.debug();
  });
});
```

- **server.use()**でモックサーバーを上書きすることができる
    - エラー用にモックサーバの上書きを行い、400ステータスを返すよう定義

#### ▶︎getStaticProps + useSWRのテスト
getStaticPropsで予め取得したデータを初期値としてプリレンダリング。
画面のマウント時に、useSWRで最新情報を取得して表示。
そういったページのテスト手法。
2段階にテストを行う。

- getStaticPropsのテスト
    - 上記のgetStaticPropsのテストと同様に
- useSWR のテスト
    - 上記のuseSWR(CSR)のテストと同様だが、Propsとして渡される初期値はダミーデータを用意

#### ▶︎useContextのテスト

後日記載予定

#### ▶︎カスタムhooksのテスト

後日記載予定

#### ▶︎認証(フォーム)のテスト

```AuthPage.test.tsx
describe('AdminPage Test Cases', () => {
  it('Should route to index page when login succeeded', async () => {
    const { page } = await getPage({
      route: '/admin-page',
    });
    render(page);
    expect(await screen.findByText('Login')).toBeInTheDocument();
    // userEvent.type … タイピング
    // getByPlaceholderTextで、placeholderの文字列で要素を取得
    userEvent.type(screen.getByPlaceholderText('username'), 'user1');
    userEvent.type(screen.getByPlaceholderText('password'), 'dummypw');
    userEvent.click(screen.getByText('Login with JWT'));
    expect(await screen.findByText('Blog page')).toBeInTheDocument();
  });

  // ログインに失敗した時
  it('Should not route to index-page when login is failed', async () => {
    // JWTトークン取得のレスポンスがBad requestであるようにAPIをモック
    server.use(
      rest.post(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/jwt/create`,
        (req, res, ctx) => {
          return res(ctx.status(400));
        }
      )
    );
    const { page } = await getPage({
      route: '/admin-page',
    });
    render(page);
    expect(await screen.findByText('Login')).toBeInTheDocument();
    userEvent.type(screen.getByPlaceholderText('username'), 'user1');
    userEvent.type(screen.getByPlaceholderText('password'), 'dummypw');
    userEvent.click(screen.getByText('Login with JWT'));
    // エラーがちゃんと表示されるか
    expect(await screen.findByText('Login Error')).toBeInTheDocument();
    // ログインページに居続けるか
    expect(screen.getByText('Login')).toBeInTheDocument();
    // queryByText('') … 指定した文字列が含まれるかどうかを返す
    // toBeNull() … 存在しないことをチェック
    expect(screen.queryByText('Blog page')).toBeNull();
  });
```
 - **userEvent.type()**で、第一引数で指定したテキストボックスに対して第二引数の文字列を入力していることをシミュレートできる
 - **getByPlaceholderText**で、placeholderの値によって要素を取得
 - **queryByText('')** … 指定した文字列が含まれるかどうかを返す
    - 該当しない場合はnullを返す
 - **toBeNull()** … 存在しないことをチェックする

## フロントエンドのテストを調べた所感(ざっくりとメモ)
- フロントエンド側のテストの手法に関しては、つよつよエンジニアの間でも議論になっていて、確立された指標はなさそう？
- サーバー側と違い、フロントはブラウザで確認できるところが多く、テストコードを書く手間に対して得られるものは少ない？
- テストの粒度が個々人で異なってしまうという問題点
    - ユニットテストと結合テストの境目？
- JestとReact Testing Libraryで、コンポーネントごとのユニットテストをコードベースで書いて、あとはコードを書きながらブラウザ上でUIや挙動を確認しながら開発。アプリケーション全体の挙動を、実際のデータベースと接続しながらE2Eテストを実施して確認する、という流れが最適解？
    - 実際に現場で開発してみないと、どこまでをコードベースでテストすべきなのかわからないなというのが、正直な感想
    - E2Eテストを、手動で行うのとCypressなどを使うのと、どっちが楽なのかもわからない。Cypressを使ってみたい気もする。

## 参考文献

https://postd.cc/frontend-testing-is-for-everyone/

https://dev.classmethod.jp/articles/what-is-frontend-unit-test/

https://zenn.dev/seya/scraps/6f930e359d6a7c

http://www.code-magagine.com/?p=13187

http://www.code-magagine.com/?p=13193

https://zenn.dev/ryo_kawamata/articles/introduce-mock-service-worker

## その他文献リスト

この記事を書き終わった後に読んで、有益だった記事をここにメモします。

- React Testing Libraryの使い方が非常にわかりやすく簡潔にまとまっている記事

https://qiita.com/ossan-engineer/items/4757d7457fafd44d2d2f


- React(Next.js)でのReact Testing Libraryを用いたテスト手法が具体的に書かれている書籍
Reactコンポーネントテストのアプローチについて、イメージのつきやすい良書でした。

    - 「**Web アプリケーションがどのようにユーザーに見えるか・操作されるかをトレースできるテスト**」 が良い
        -  実装の詳細をテストしない

https://zenn.dev/tkdn/books/react-testing-patterns/viewer