こんにちは、梅津です。
Kent C. Doddsのブログを見ていたら面白い記事を見つけました。
テストの書き方や考え方の参考になりそうだったので、内容をかいつまんで紹介します。
よくあるテストの書き方
テストを書くとき、次のような書き方をすることがあると思います。
describe('成功時', () => { ... // title, subtitleなどの定数を定義 let utils beforeAll(() => { getCourseInfo.mockResolvedValueOnce(buildCourse({title, subtitle, topics})) }) afterAll(() => { cleanup() jest.resetAllMocks() }) it('ローディングスピナーが表示されること', () => { utils = render(<Course courseId={courseId} />) expect(utils.getByRole('alert')).toHaveTextContent(/loading/i) }) it('getCourseInfoが適切に呼ばれていること', () => { expect(getCourseInfo).toHaveBeenCalledWith(courseId) }) it('タイトルが表示されること', async () => { expect(await utils.findByRole('heading')).toHaveTextContent(title) }) it('サブタイトルが表示されること', () => { expect(utils.getByText(subtitle)).toBeInTheDocument() }) it('トピックのリストが表示されること', () => { const topicElsText = utils .getAllByRole('listitem') .map(el => el.textContent) expect(topicElsText).toEqual(topics) }) }) describe('失敗時', () => { ... // messageなどの定数を定義 let utils, alert beforeAll(() => { getCourseInfo.mockRejectedValueOnce({message}) }) afterAll(() => { cleanup() jest.resetAllMocks() }) it('ローディングスピナーが表示されること', () => { utils = render(<Course courseId={courseId} />) alert = utils.getByRole('alert') expect(alert).toHaveTextContent(/loading/i) }) it('getCourseInfoが適切に呼ばれていること', () => { expect(getCourseInfo).toHaveBeenCalledWith(courseId) }) it('エラーメッセージが表示されること', async () => { await wait(() => expect(alert).toHaveTextContent(message)) }) })
特徴としては
- describe, itの入れ子
- beforeEach, beforeAllなどで処理を共通化する
- letを使って変数を使い回す
といったものがあげられます。
もちろんこのような書き方でもテストケースは網羅されているので十分な信頼性はあります。
ですが、こういったテストには次のような問題があるためやめたほうがいいとKentは言っています。
- The tests are not at all isolated (read Test Isolation with React)
テストが全く分離されていない(Test Isolation with Reactを読んでください)- Mutable variables are shared between tests (read Avoid Nesting when you're Testing)
Mutableな変数がテスト間で共有されます(Avoid Nesting when you're Testingを読んでください)- Asynchronous things can happen between tests resulting in you getting act warnings (for this particular example)
テスト間で非同期的なことが起こる可能性があり、その結果 act の警告が表示されます (この例では)。
たしかに今回の例ではテストが分離されていないので、テストの実行順によっては失敗する場合がありますね。
変数のスコープは長くなりがちで、どこで状態が変わるのかがパッと見ただけではわかりにくい。なにより let
と書くのが負けた気がします。全部 const
にしたい!
こういう書き方はどうですか
Kentがすすめる書き方は次のような形です。
afterEach(() => { jest.resetAllMocks() }) test('コース情報のロードとレンダリング', async () => { ... // 定数の定義 getCourseInfo.mockResolvedValueOnce(buildCourse({title, subtitle, topics})) render(<Course courseId={courseId} />) expect(getCourseInfo).toHaveBeenCalledWith(courseId) expect(getCourseInfo).toHaveBeenCalledTimes(1) const alert = screen.getByRole('alert') expect(alert).toHaveTextContent(/loading/i) const titleEl = await screen.findByRole('heading') expect(titleEl).toHaveTextContent(title) expect(screen.getByText(subtitle)).toBeInTheDocument() const topicElsText = screen.getAllByRole('listitem').map(el => el.textContent) expect(topicElsText).toEqual(topics) }) test('コース情報のロードに問題がある場合、エラーが表示される', async () => { ... // 定数の定義 getCourseInfo.mockRejectedValueOnce({message}) render(<Course courseId={courseId} />) expect(getCourseInfo).toHaveBeenCalledWith(courseId) expect(getCourseInfo).toHaveBeenCalledTimes(1) const alert = screen.getByRole('alert') expect(alert).toHaveTextContent(/loading/i) await wait(() => expect(alert).toHaveTextContent(message)) })
観点としては 個々の機能をテストするのではなく、そのコンポーネントが満たすユースケースに注目しよう ということです。
ユースケースを満たすような一つのテストケースに対して複数のアサーションを書きます。
当然一つ分のテストは長くなりますが、それを許容します。
こういった書き方であれば先程出ていたような問題は全て解消されます!
書きやすいし読みやすい。良いテストだなーと初めて見たときに思いました。
そもそもなぜテストケースを細かく分けていたのかというと、多くのテストツールではどのテストケースのどの行で失敗したかが特定できなかったから、という背景があるようです。
jestはその辺が優秀なので一つのテストケースに複数のアサーションを含めても問題ありません。
おわりに
いかがだったでしょうか。そういうやり方もあるのかと考え方を変えるきっかけになったのではないでしょうか。
僕も元々は最初の例のような書き方をしていましたが、こういうやりかたで本当にいいのかな?という課題感を抱えていました。
Kentの記事を見つけて書き方を変えたことでモヤモヤは解消されました。
今回紹介した内容は表面的な部分です。Kentの記事を一番最後に載せておきますので、一度目を通してみると良いと思います!
他にもいい記事がたくさんあって面白いですよ。
それでは、また。