Reactテスト道場 〜Components編〜

hooks含め、ロジックのテストはできるようになったぞ...！
ただ、それではComponentが正しく動いているかまでは担保ができないぞ...

そんな時に行うのが、「Componentテスト」です。
Componentをテストする方法としては、通常の単体テストと、スナップショットテストがあります。

## 準備
reactのComponentテストを行うには、`@testing-library/react`をインストールします。

```sh
yarn add -D @testing-library/react
```

:::note warn
公式が提供しているreact-test-rendererを使っているサイトも多くみられますが、
@testing-library/reactでも同様な使い方ができます。
また、公式も@testing-library/reactを推奨しています。
:::

## テストするComponent

![画面収録-2021-12-20-16.38.38.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142322/de113525-a06a-2b8a-4a99-66ecd65c1e08.gif)


```jsx
import React, { VFC, useState } from "react";

type Props = {
  max: number;
};

export const Pagination: VFC<Props> = ({ max }) => {
  // setCurrentIndex、pageIndexArray あたりは本来はカスタムhooks化 & 別途ユニットテストすべき。
  // そのため、今回はテスト対象外
  const [currentIndex, setCurrentIndex] = useState(1);
  // 1〜max値までの数字の配列を作る
  const pageIndexArray = [...Array(max)].map((_, index) => index + 1);

  return (
    <div>
      <ul>
        {pageIndexArray.map((index) => (
          <li key={`paginationItem_${index}`}>
            {index} {index === currentIndex && <span>◀︎ now</span>}
          </li>
        ))}
      </ul>
      <div>
        <button
          onClick={() => setCurrentIndex((prev) => prev - 1)}
          disabled={currentIndex <= 1}
        >
          prev
        </button>
        <button
          onClick={() => setCurrentIndex((prev) => prev + 1)}
          disabled={currentIndex >= max}
        >
          next
        </button>
      </div>
    </div>
  );
};

```

## コンポーネントの単体テスト
### どんな時に必要？
主に、コンポーネントが正しく動くかを検証する時に使います。

### 書き方
コンポーネントの要件としては、下記が挙げられます。

* `currentIndex`と`index`が一致した時に、「◀︎ now」が表示される
* `currentIndex`が1以下の時、prevボタンがdisabledになる
* `currentIndex`がmax以上の時、nextボタンがdisabledになる



#### Componentのレンダリング
まずは、テストしたいComponentをレンダリングします。
レンダリング後、screenを用いることでComponentにアクセスできるようになります。

```jsx
import { render, screen } from '@testing-library/react';

describe('Pagination', () => {
  test('currentIndexとindexが一致した時に、"◀︎ now"が表示される', () => {
    // Componentをレンダリング
    render(<Pagination max={5} />);
    // Componentにアクセス
    screen.debug();
  }
}
```

#### 要素の検索
要素を検索するには、基本的には`queryBy`を用いて検索します。
(他にも複数検索する`getAllBy`や、非同期の時に用いる`findBy`などもあります。)

* queryByText　- 要素が持つテキスト
* queryByRole - 要素が持つロール
    * domが持つデフォルトのRole - [ARIA in HTML](https://www.w3.org/TR/html-aria/#docconformance)
    * role属性 - `role="button"`など
* queryByLabelText - 要素が持つラベル
    * aria-label
    * aria-labelledby
    * selectorも一緒に検索できる `screen.getByLabelText('Username', {selector: 'input'})` 
* queryByPlaceholderText
    * placeholder属性
* queryByAltText
    * alt属性
* queryByDisplayValue
    * inputなどで、表示されているvalue


こんな感じ

```jsx
test('currentIndexとindexが一致した時に、"◀︎ now"が表示される', () => {
  render(<Pagination max={5} />);
  screen.queryByText(/1/)
});
```

#### アサーション
あとは、いつも通り検証するだけです。
`jest`のマッチャーに加えて、`jset-dom`に含まれているマッチャーが使えます。

https://github.com/testing-library/jest-dom

```jsx
expect(screen.queryByText(/1/)).toHaveTextContent(/◀︎ now/);
expect(screen.queryByText(/2/)).not.toHaveTextContent(/◀︎ now/);
```

#### イベントを発火
`fireEvent`を用いて、ボタンをclickしたりフォームをchangeさせたりすることができます。

```js
import { render, screen, fireEvent } from '@testing-library/react';

describe('Pagination', () => {
  test('currentIndexとindexが一致した時に、"◀︎ now"が表示される', () => {
    // 対象Componentのレンダリング
    render(<Pagination max={5} />);

    // ユーザーの操作
    fireEvent.click(screen.queryByText(/next/));
  });
});
```

#### 完成！
だいっぶ愚直に書いてしまいましたが、こんな感じになりました！

```jsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Pagination } from './Pagination';

describe('Pagination', () => {
  test('currentIndexとindexが一致した時に、"◀︎ now"が表示される', () => {
    // 対象Componentのレンダリング
    render(<Pagination max={5} />);
    // 要素の検索と表示の検証
    expect(screen.queryByText(/1/)).toHaveTextContent(/◀︎ now/);
    expect(screen.queryByText(/2/)).not.toHaveTextContent(/◀︎ now/);
    // ユーザーの操作
    fireEvent.click(screen.queryByText(/next/));
    expect(screen.queryByText(/2/)).toHaveTextContent(/◀︎ now/);
    expect(screen.queryByText(/1/)).not.toHaveTextContent(/◀︎ now/);
  });

  test('currentIndexが1以下の時、prevボタンがdisabledになる', () => {
    render(<Pagination max={5} />);
    expect(screen.queryByText(/prev/)).toBeDisabled();
    fireEvent.click(screen.queryByText(/next/));
    expect(screen.queryByText(/prev/)).toBeEnabled();
    fireEvent.click(screen.queryByText(/prev/));
    expect(screen.queryByText(/prev/)).toBeDisabled();
  });

  test('currentIndexがmax以上の時、nextボタンがdisabledになる', () => {
    render(<Pagination max={5} />);
    fireEvent.click(screen.queryByText(/next/));
    fireEvent.click(screen.queryByText(/next/));
    fireEvent.click(screen.queryByText(/next/));
    fireEvent.click(screen.queryByText(/next/));
    expect(screen.queryByText(/next/)).toBeDisabled();
  });
});

```


## スナップショットテスト

### そもそもスナップショットテストとは？
スナップショットテストは、予期していない見た目の変更があった時に検知するためのテストで、リグレッションテストの一つです。
テスト実行時に、スナップショットがなければ作成し、スナップショットがあればの一致しているかを検証します。

### どんな時に必要？
主に、コンポーネントの見た目の変更を検知させたい時に使います。

### 書き方
```jsx
test('snapshot', () => {
  const { asFragment } = render(<Pagination max={5} />);
  expect(asFragment()).toMatchSnapshot();
});
```

### 補足
Storybookのアドオンでもスナップショットテストはできるので、Storybook導入しているプロダクトではStorybookに任せちゃった方がいいかもですね！

---
参考

https://blog.gaji.jp/2020/07/31/4618/