React+Jestのテストで詰まったことまとめ

Reactアプリのテスト(Jest, React Testing Library)で詰まったことのまとめ。

基本

  • act()awaitしなきゃいけない
    • Reactは非同期で画面が変化するため、それを動的に検知する仕組み
    • react-testing-libraryは標準で対応している
  • waitFor()はPromise

    なのでawaitしないと、テスト自体はSuccess⇒エラーが発生になっちゃう

  • waitFor()defaultTimeoutは1000ms=1秒、表示に1秒以上かかる場合はTimeoutを変更する必要がある

  • test()の第二引数は同期関数でも、非同期関数でも影響なし
  • renderはactでラップされているので囲む必要なし(actエラーは他に起因するエラーの可能性大)

要素のget方法について

  • 基本はgetBy~()関数で要素を取得できる
  • 要素が存在しない可能性がある場合(not判定のときなど)はqueryBy~()で要素取得を試みる。存在しない場合はnullが返却される
  • 要素が動的に遅れて配置される可能性がある場合、getBy~()で取得するとその瞬間にDOMに存在しないことがあるためfindBy~()で取得する
    • findBy関数はwaitForを内包しており、Promiseが返却される

userEvent, fireEvent関連

  • fireEvent, userEventの関数もラップされているのでactで囲む必要なし
  • screen.debug()では見えないが、userEvent.typeを使うとちゃんとvalueは入ってる
  • userEventにもawaitをつけて、その結果として求められるものをwaitForで待ってから処理を進める
    • userEventは非同期関数なのでawaitする必要が存在し、awaitしたとしてもuserEventがresolveされたあとですぐexpectを実行するとレンダリングされていなくて失敗することがある(数100ms程度までならエラーが出ない)
    • もともと処理に時間がかかる事がわかっている場合はTimeoutを伸ばすなどで対処
  • 公式的にはfireEventよりもuserEventを使うべきらしい
  • ユーザーフォームの取り方は

ボタン

screen.getByRole("button", { name: /ログイン/ })

インプット

screen.getByLabelText(/メールアドレス/)

関数のテスト

  • 関数は新たにjest.fn()で定義するか、jest.mock()で既存モジュールをモック化することで呼び出された時の状態を保持できるようになる
  • その関数が呼ばれたか、どんな引数で呼ばれたかを判定することでテストする
const testFn = jest.fn()

// 呼ばれたかどうか
expect(testFn).toHaveBeenCalled()
// 引数が含まれるか(部分検索)
// オブジェクトが呼ばれている場合は再帰的に検索してくれるらしい
// 複数指定するとAND検索
expect(testFn).toHaveBeenCalledWith("hoge")

おそらく基本waitForで待ったほうが良い

サービス(API)のモックについて

  • beforeEach関数内もしくはtest関数内でモック化関数を呼び出さないと作動しない
    • よくあるミスとしてdescribe内など
  • 流れとしてmock⇒renderの流れじゃないとコンポーネント内のサービスがモックされないので、renderしたあとにモック化をしても発動しない
describe("テスト", () => {
  beforeEach(async () => {
    mockReturnValue(service, "method", undefined)
    renderDOM()
  })

  test("ログインエラー", async () => {
    mockRejectedValueOnce(service, "method")
    // これはレンダーされたあとに実行されているので適用されていない
})
  • エラーのテストなどで一部だけモック内容を変更したいときは、renderを関数化してbeforeEachにふくめるのではなく、beforeEachで共通のモック化をして、test内で毎回mock⇒renderと言う流れでオーバーライドするのが丸いかもしれない
    • renderもbeforeEachにふくめると2回レンダリングされてダブったDOMになってしまうので注意
const renderDOM = () => {
  render(
    <MemoryRouter initialEntries={["/test"]}>
      <Routes>
        <Route path="/" element={<>Top</>} />
        <Route
          path="/test"
          element={
            <Test/>
          }
        />
      </Routes>
    </MemoryRouter>
  )
}

describe("画面の内容テスト", () => {
  beforeEach(() => {
      // 共通のモック
    jest.spyOn().mockReturnValue(...)
  })

  test("test", () => {
    // オーバーライド
    jest.spyOn().mockReturnValueOnce(...)
    renderDOM()
  })
})

toBeInTheDocumentについて

  • JSで生成されたり変更されたりするDOM要素を除いて、display:noneなどを指定しているDOM要素は存在はしているためtoBeInTheDocument=trueと認識される
  • 表示されて目に見えている⇒toBeVisible()を使用するほうが安全かもしれない
    • ただし、存在しないことを確認するときはexpect(screen.queryByText("Text")).not.toBeInTheDocument()を使う
    • これは、queryByTextの返り値がnullとなるが、toBeVisibleはこれをハンドリングできないため

setTimeOutのモックについて

  • LoadingのsetTimeOutなど意図して時間経過をセットしている場合はjest.useFakeTimers()jest.runAllTimers()orjest.runOnlyPendingTimers()を使って制御する
  • userEventは若干のdelayをsetTimeOutで実装しているらしく、useFakeTimersで時間関連をモックしているテストではtestがtimeoutになってしまう
// jest.setup.ts
import userEvent from "@testing-library/user-event"
const userEventWithFakeTimers = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
global.userEventWithFakeTimers = userEventWithFakeTimers

型指定しないとESLintに怒られる

// global.d.ts(tsconfigのincludeにふくめる)
import { UserEvent } from "@testing-library/user-event"

declare global {
  // eslint-disable-next-line
  var userEventWithFakeTimers: UserEvent
}