CRA + Redux Toolkit の初期テストコード

### 概要
Redux Toolkitを勉強しています。
CRA(Create React App)でプロジェクトを作成して、初期テストコードがちょっとしか無かったので、RTL(React Testing Library)による基本的なテストを書いてみました。
細かいとこを詰めてないのですが、ご参考ください。

もっと適切な書き方などあれば、教えてもらえると嬉しいです。

### 環境
* @testing-library/jest-dom : ^5.14.1
* @testing-library/react : ^12.0.0
* react-scripts : 4.0.3
* typescript : ^4.1.5

### 始め方

* CRAでプロジェクト作成
  * テンプレートはtypescript, redux-typescript

```
yarn create react-app my-app --template redux-typescript
```

* まずはstartしてみます

```
yarn start
```

* ブラウザに表示されます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227546/bc4383a8-8681-67ac-6b76-1d840d67d602.png)

* プロジェクト直下にtestディレクトリを作成してテストファイルを配置しましょう
    * hoge.test.tsx など

* [一部モジュールをインストールし直さないとエラーが出る](https://qiita.com/qkk/items/9003b22f3a4b1a9c9d56)場合があります

```
yarn remove @testing-library/react @testing-library/jest-dom && yarn add @testing-library/react @testing-library/jest-dom
```

### 初期表示へのテスト

* テストコードは以下です。

```typescript
import { render, screen, getDefaultNormalizer } from '@testing-library/react'
import userEvent from "@testing-library/user-event";
import { Provider } from 'react-redux';
import { store } from '../app/store';
import App from '../App';

test('first render', () => {
  render(
    <Provider store={store}>
      <App />
    </Provider>
  );

  expect(screen.getByText('-')).toBeInTheDocument()
  expect(screen.getByRole('button', {name: 'Decrement value'})).toBeInTheDocument()

  const minusButton = screen.getByRole('button', {name: 'Decrement value'})
  expect(minusButton.nextElementSibling?.textContent).toBe("0")

  expect(screen.getByText('+')).toBeInTheDocument()
  expect(screen.getByRole('button', {name: 'Increment value'})).toBeInTheDocument()

  const input = screen.getByRole('textbox', {name: 'Set increment amount'})
  expect(input).toBeInTheDocument()
  expect(input).toHaveValue("2")

  expect(screen.getByRole('button', {name: 'Add Amount'})).toBeInTheDocument()
  expect(screen.getByRole('button', {name: 'Add Async'})).toBeInTheDocument()
  expect(screen.getByRole('button', {name: 'Add If Odd'})).toBeInTheDocument()

  // test Text in App.tsx
  expect(screen.getByText(/Edit/)).toBeInTheDocument();
  expect(screen.getByText(/src\/App.tsx/, {normalizer: getDefaultNormalizer({ trim: false })}  )).toBeInTheDocument();
  expect(screen.getByText(/and save to reload./, {normalizer: getDefaultNormalizer({ trim: false })}  )).toBeInTheDocument();
  /* OR */
  // const Element = screen.getByText(( _content: string, node: Element | null ): boolean => {
  //   return node ? /^Edit src\/App\.tsx and save to reload\.$/.test(node.textContent || '') : false
  // });
  // expect(Element).toBeInTheDocument()
  expect(_getByNestedText('Edit src\/App\.tsx and save to reload\.')).toBeInTheDocument();
  
  expect(_getByNestedText('Learn React, Redux, Redux Toolkit, and React Redux')).toBeInTheDocument();

  expect(screen.getByText('React').closest('a')).toHaveAttribute('href', 'https://reactjs.org/')
  expect(screen.getByText('Redux').closest('a')).toHaveAttribute('href', 'https://redux.js.org/')
  expect(screen.getByText('Redux Toolkit').closest('a')).toHaveAttribute('href', 'https://redux-toolkit.js.org/')
  expect(screen.getByText('React Redux').closest('a')).toHaveAttribute('href', 'https://react-redux.js.org/')
})

/** 
 * A custom query of getByText.
 * It gets a element has full text in nested tags.
 * 
 * parameter
 *  text: should be escaped for Regex
 */ 
const _getByNestedText = (text: string): HTMLElement => {
  const regex = new RegExp("^" + text + "$")
  return screen.getByText(( _content: string, node: Element | null ): boolean => {
    return node ? regex.test(node.textContent || '') : false
  })
}
```

### 要素の検索
* getByTextで該当テキストを拾えるわけですけど、同じテキストが複数ある場合は「どこの」テキストか特定が必要ですね。
* 画面上は繋がったテキストに見えてもHTML上はタグの入れ子になっている場合、少し工夫が必要でした。
  * getByTextに関数を渡す形でカスタムクエリーを作成しました。

### 初期表示後のユーザアクションへのテスト

* テストコードは以下です。

```typescript
test('user action', async () => {
  render(
    <Provider store={store}>
      <App />
    </Provider>
  );

  /* <span> 0 </span> */
  // click minus button
  const minusButton = screen.getByRole('button', {name: 'Decrement value'})
  for (let i=0; i<3; i++){
    userEvent.click(minusButton)
  }
  expect(minusButton.nextElementSibling?.textContent).toBe("-3")

  /* <span> -3 </span> */
  // click plus button
  const plusButton = screen.getByRole('button', {name: 'Increment value'})
  for (let i=0; i<6; i++){
    userEvent.click(plusButton)
  }
  expect(plusButton.previousElementSibling?.textContent).toBe("3")

  /* <span> 3 </span> */
  /* <input value="2" /> */
  // change the value of text box 
  const input = screen.getByRole('textbox', {name: 'Set increment amount'})
  userEvent.type(input, "5")
  expect(input).toBeInTheDocument()
  expect(input).toHaveValue("5")

  /* <span> 3 </span> */
  /* <input value="5" /> */
  // click [Add Amount] button
  const addAmountButton = screen.getByRole('button', {name: 'Add Amount'})
  userEvent.click(addAmountButton)
  expect(plusButton.previousElementSibling?.textContent).toBe("8")

  /* <span> 8 </span> */
  /* <input value="5" /> */
  // click [Add Async] button
  const addAsyncButton = screen.getByRole('button', {name: 'Add Async'})
  userEvent.click(addAsyncButton)
  // counterAPI will take 0.5 seconds and change the state.
  await new Promise(resolve => setTimeout(resolve, 1000))
  expect(plusButton.previousElementSibling?.textContent).toBe("13")

  /* <span> 13 </span> */
  /* <input value="5" /> */
  // click [Add If Odd] button
  const addIfOddButton = screen.getByRole('button', {name: 'Add If Odd'})
  userEvent.click(addIfOddButton)
  expect(plusButton.previousElementSibling?.textContent).toBe("18")  //from 13
  // click [Add If Odd] button
  userEvent.click(addIfOddButton)
  expect(plusButton.previousElementSibling?.textContent).toBe("18")  //from 18 (no change)

})
```

#### ユーザアクション
* ボタンクリックなどfireEventでも可能ですが、user-eventの方がkeydownなどユーザが実際に起こすアクションに則しているそうです。

#### 非同期処理の結果を待つ
* [Add Async]ボタンは0.5秒かかるように実装されていますので、ボタン押下後に1秒待ってから結果を確認しました。
    * sleep()はJSに無いのでawaitで待つようにしています。
      * ```
    await new Promise(resolve => setTimeout(resolve, 1000))
    ```

* 非同期処理結果で新たな要素が描画されるならfindBy***()で探索すればいいかもです。

以上です

### 参考
* [テンプレート cra-template-redux-typescript](https://github.com/reduxjs/cra-template-redux-typescript)
* [公式 Redux - Writting Tests](https://redux.js.org/usage/writing-tests)
* [Five thins you (probably) didn't know about testing library](https://polvara.me/posts/five-things-you-didnt-know-about-testing-library)
* [CRA(v3.4.1)のReact Testing Libraryで最初に対処すること](https://qiita.com/qkk/items/9003b22f3a4b1a9c9d56)