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()
})
})- 一部だけ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
}