Reactアプリのテスト(Jest, React Testing Library)で詰まったことのまとめ。
- 基本
- 要素のget方法について
- userEvent, fireEvent関連
- 関数のテスト
- サービス(API)のモックについて
- toBeInTheDocumentについて
- setTimeOutのモックについて
基本
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() }) })
- 一部だけbeforeEachの内容をif文でハンドリングできるっぽいけど保守性落ちそう
- https://github.com/mochajs/mocha/issues/2609
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になってしまうuserEvent
自体の使用するタイマーをセットしてあげることで回避可能- https://testing-library.com/docs/user-event/options#advancetimers
- jest.setup.tsでglobalに作成、jest.configで読み込む
- 詳細
// 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 }