Open4

React, Jest, Testing Library

inoyinoy

Arrange

変数定義

jest.Mocked<Source>利用でオブジェクトにプロパティ追加時、テストコードの修正箇所が限定できて良さそう。

interface DataModelA {
  id: number
  value: string
  someOtherProp1: string
  someOtherProp2: string
}

テストコードでは、コンポーネントで必要なプロパティのみを設定する。idとnameしか参照しないなら以下。

const DUMMY_MAIN_SERVICE = {
  id: 1, name: 'dummy',
} as jest.Mocked<DataModelA>

そうすると、DataModelAsome-other-props-3が増えてもそれが不要なコンポーネントのテストは修正不要。

https://jestjs.io/ja/docs/jest-object#jestmockedsource

Custom Hooksのモック

const mock = jest.fn()
jest.mock(`${Custom Hookへのパス importのfrom指定と同じ}`, () => ({
  <exportしてる変数名>: (): jest.Mock => mock,
}))

テストが実行されるより前にモックする必要があるため、テストファイルの先頭の方に書くと良さそう。

Note: expect(mock).toHaveBeenCalledWith('testParam') でテストできる。

inoyinoy

Act

コンポーネントをrenderするにはReact Testing Libraryのrender関数が利用可能。
render対象のコンポーネントに非同期処理が含まれるなら、render関数の実行結果RenderResultのメソッドfindByRoleなどで、テスト対象の表示を待つことができる。

たとえば以下のAppコンポーネントにデータfetchなど非同期処理が含まれていて、それが完了するまで「テスト対象ボタン」が表示されない場合 await renderResult.findByRole('button', {name: /テスト対象ボタン/}) すれば、これより後は「テスト対象ボタン」が表示された状態をassertすることができる。

const renderResult = render(<App />)
const button = await renderResult.findByRole('button', {name: /テスト対象ボタン/})
fireEvent.click(button)

input要素のchangeValue

fireEvent.change(getByRole('textbox', {name: '本文'}), {target: {value: 'テスト本文更新'}})

要素が消えることを待つ

waitForElementToBeRemoved
https://testing-library.com/docs/guide-disappearance/#waiting-for-disappearance

        it('キャンセルボタンクリックでダイアログを非表示', async () => {
          const {getByRole, queryByText, queryByTestId} = subject({})
          fireEvent.click(getByRole('button', {name: '登録'}))
          fireEvent.click(getByRole('button', {name: 'キャンセル'}))
          await waitForElementToBeRemoved(() => queryByText('登録します。 よろしいですか?'))


          expect(await queryByTestId('register-confirm-dialog')).not.toBeInTheDocument()
        })
inoyinoy

Assert/Expect

要素が存在しないこと

    expect(screen.queryByTestId('testIdOfTestTarget')).not.toBeInTheDocument()

要素にテキストがあること

    const {getByText} = render(<SutComponent><>存在すべきテキスト</></SutComponent>)
// or
    const {getByText} = within(screen.getByTestId('testIdOfTestTarget'))

    expect(getByText('存在すべきテキスト')).toBeInTheDocument()

https://testing-library.com/docs/dom-testing-library/api-within/

<li>の要素が正しい並び順であること

    <ul>
      {items.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>

があるとき

    const {getAllByRole} = within(screen.getByTestId('testIdOfTestTarget'))

    const items = getAllByRole('listitem')
    expect(items).toHaveLength(2)
    expect(items[0]).toHaveTextContent('アイテム1')
    expect(items[1]).toHaveTextContent('アイテム2')

Parameterized Test

  it.each`
    items | description
    ${undefined} | ${'undefined'}
    ${[]} | ${'空配列'}
    ${[new Item(1, 'アイテム1')]} | ${'アイテムが1つ'}
  `('アイテムが$descriptionの場合はアイテム情報を表示しない', ({items}) => {
    render(<Component items={items} >)

    // expect
  })

https://jestjs.io/ja/docs/api#2-testeachtablename-fn-timeout

要素のリンクが正しいこと

<a href="https://www.google.co.jp/" target="_blank" rel="noopener noreferrer" class="css-bqp9i"><div direction="row" class="css-1j132cc">Googleへのリンク<img src="data:image/png;base64,iVB...(省略)=" alt="whiteArrowForwardIcon" class="css-q7z9e3"></div></a>

この要素は name Name "利用規約 whiteArrowForwardIcon"として見えてるので、以下の通り正規表現でマッチさせると良い。

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

expect(screen.getByRole('link', {name: /Googleへのリンク/})).toHaveAttribute('href', 'https://www.google.co.jp/')

以下のPriorityに従いgetByRoreを積極的に使っていきたい。
https://testing-library.com/docs/queries/about/#priority

getByRole利用することでラベル設定間違えていたときにgetByRoleで要素が取れなくてミスに気付けた。テスト expect(getByRole('textbox', {name: 'コメント'})).toHaveValue('テストコメント') 書いたら Unable to find an accessible element with the role "text" and name "コメント" でテストが失敗した。そのときにエラーコンソールで出力された内容が以下。nameが空文字列になっていた。対象のtsx定義確認したところ htmlFor 指定値が誤っていた。

  textbox:

  Name "":
  <textarea
    class="..."
    id="content-comment"
    name="comment"
  />

input要素のvalueが正しいこと

getByRolegetByLabelTextgetByPlaceholderText が使えればそれを使って要素を取得。以下みたいに使えない場合はたとえば getByDisplayValue で取得。

          <input
            aria-invalid="false"
            class="MuiInputBase-input MuiOutlinedInput-input ..."
            id="date-input"
            type="date"
            value="2020-01-01"
          />
        const {getByDisplayValue} = render(<DatetimePicker datetime={'2020/01/01 00:00'} />)

        expect(getByDisplayValue('2020-01-01')).toBeInTheDocument()

input要素のonChangeでコールバックされる関数の引数を検査

      it('日付を変更したとき、更新された日付でonChangeを呼び出す', async () => {
        const fn = jest.fn()
        const {getByDisplayValue} = render(
          <DatetimePicker datetime={'2020/01/01 12:00'} onChange={fn}/>)

        const inputElement = (await getByDisplayValue('2020-01-01')) as HTMLInputElement
        fireEvent.change(inputElement, {target: {value: '1900-01-01'}})


        expect(fn).toHaveBeenCalledWith('1900/01/01 12:00')
      })
inoyinoy

Note

テストに直接は無関係なこと。

useReducer

オブジェクトの一部を更新するstateはuseReducer利用すると便利。

const [content, setContent] = useReducer((a: ContentDetail, b: Partial<ContentDetail>) => ({...a, ...b}), a)

// ...

onChange={(e): void => setContent({comment: e.target.value})}