React/Jestでのユニットテストに少し慣れてきたら役に立つtips
スペースマーケット所属のfumink7です。
北欧へのあこがれが高まっています☃️
ReactでのWebアプリケーション開発をはじめる中で、ユニットテストを書き始めたときに知って役立ったtipsをまとめてみました。
テスト環境
テスティングフレームワークはJest、UIテストのためにTesting Libraryを使用しています
- typescript@4.9.4
- React@18.2.0
- jest@28.1.0
- @testing-library/react@13.3.0
①アサーション
特定の要素内に絞って要素検索を行う - within
getBy、findByなどで「要素A内にある要素Bを取得する」場合にwithinを使って要素Aを指定することができます。
const formElement = screen.getByRole('form')
expect(
within(formElement).getByRole('button', { name: 'XXボタン' }),
).toBeInTheDocument()
非同期処理を待ってからアサーションを行う - waitFor
非同期で何らかの処理を行った後アサーションを行う場合、非同期の処理が終わる前に実行されて期待した結果が得られない場合があります。
waitForを使うことで、非同期の処理が終わるのを待ってアサーションを行うことができます。
// 非同期での処理を行なう…
// 非同期処理の終了を待って引数に渡した処理を実行する
await waitFor(
() => screen.getByRole('dialog', { name: 'XX確認' })
)
(追記)非同期処理を待ってから要素の取得を行うならfindBy
コメントいただいたのですが、単に非同期処理を待って要素を取得するのであればfindBy
を利用するほうが簡潔に書けますね。
findBy methods are a combination of getBy queries and waitFor.
// 非同期処理の終了を待って要素を取得する
await screen.findByRole('dialog', { name: 'XX確認' })
これでよさそう
曖昧な値の比較
expectの対象が「数字であれば何でもいい」「オブジェクトがこの値を含んでいればあとはなんでもいい」みたいな場合の書き方です。
何かしらの値(型)であることを確認したい
// 関数がXXを引数として実行されたことをテスト
// なんでもいいから引数が渡された
expect(funcMock).toBeCalledWith(expect.anything())
// 数値が引数として渡された
expect(funcMock).toBeCalledWith(expect.any(Number))
ArrayやObjectが対象の値を含んでいるかどうか
// someArrayに'hoge'が含まれていること
expect(someArray).toEqual(
expect.arrayContaining([ 'hoge' ]),
)
// someObjectにname(文字列)が含まれていること
expect(someObject).toEqual(
expect.objectContaining({ name: expect.any(String) }),
)
テキストの部分一致を確認
// 正規表現にマッチするかどうか
expect(somePathText).toEqual(
expect.stringMatching(/\/hogehoge\/fugafuga\/$/),
)
// 文字列にhogehogeを含んでいるか
expect(someText).toEqual(expect.stringContaining('hogehoge'))
余談:いろんな場面で正規表現が使える
正規表現で思い出しましたがTesting Libratyでテキストを扱うものは大抵(?)正規表現が使えます。便利ですね〜
// テキストで要素を探すときにも、テキストを渡すマッチャーでも使える
expect(screen.getByText(/正規表現/)).toHaveTextContent(/正規表現/)
アサーションにスナップショットを使う - toMatchInlineSnapshot
意図した変更が含まれていないことを確認するスナップショットテストですが、レンダリングしたコンポーネント全体を比較するテストだけではなく、特定の値に変更が入っていないかどうかを確認するアサーションで使えるスナップショット機能があります。
expect(someObject).toMatchInlineSnapshot()
// ↑の状態でテストを実行すると、取得された値が引数に自動で記録される↓
expect(someObject).toMatchInlineSnapshot(`
Object {
hoge: 'fugafuga'
}
`)
次回以降テスト実行時にsomeObjectが違う値になっている場合テストが失敗します。
タグの属性をテストする - toHaveAttribute
HTML要素の属性の値もtoHaveAttributeで簡単にテストできます。
// 取得した要素のaria-hiddenの値をテストする
const text = screen.getByText('description')
expect(text).toHaveAttribute('aria-hidden', 'false')
Promiseでの非同期処理のエラーテストの書き方のバリエーション
Promiseでの非同期処理においてエラーが発生することを確認するテストは
await expect(promiseInstance).rejects.toThrowError()
と書くのが一般的ですが、アサーションって一般的に実行結果を何かと比較するというものが多いので、なんか上記の書き方だとぱっと理解がしにくいんですよね(そんなことない?)
チームで話していたときに、下記のような書き方のほうがわかりやすいのでは?という意見が出ました。
const errorObject = await promiseInstance().catch(
(error) => error,
)
expect(errorObject).toEqual(expect.any(Error))
エラーが発生するのならば、実行した際にcatchの処理が走ってerrorObjectが返ってくるよね、というテストのほうがわかりやすいのではという話になったのですが、みなさんはどうでしょうか??
②Mock
モジュールを部分的にMockしたい - requireActual
importするモジュールをmockする際、requireActualを使うことで一部はMock、それ以外はもとのままimportするということが可能になります。
const someFuncMock = jest.fn(() => false)
jest.mock('../someModulePath', () => {
const actual = jest.requireActual('../someModulePath')
return {
...actual,
someFunc: someFuncMock,
}
})
// 「XXXの時、someFuncMockが実行される」テスト内でのアサーション
expect(someFuncMock).toBeCalledTimes(1)
※jest.spyOnでも同じようなことができそう
import * as someModule from "../some-module-path";
// テスト内
const spySomeFunc = jest.spyOn(someModule, "someFunc");
expect(spySomeFunc).toBeCalledTimes(1)
③APIレスポンスのMock(msw)
(記事紹介)mswを使ったネットワークレベルでのMock
フロントエンド開発では必ず発生するであろうAPIへのリクエスト(REST、GraphQL)ですが、mswを使うことで簡単にリクエストを行うロジックのテストを書くことができます。
mswでは指定したエンドポイントへのリクエストに対するレスポンスをMockとして作成できるので、リクエストを送るところ&レスポンスが帰ってきた後をテストコードで再現できるのがありがたいですよね。
実際の使用イメージに関してはチームメンバーが書いている記事を紹介させていただいて。
Zennにも記事が沢山
fetch is not defined - fetchはnodeに存在しない
さあこれでAPIリクエストのテストが書けるぞ!と意気込んでみたはいいものの、fetchを利用している場合にテスト実行するとfetch is not defined
と怒られてしまいます。。
それもそのはず、node.jsにはfetchが存在しないので実行できません。
mswのissueを参考するに、「テストのためにfetchを用意するのはテスティングライブラリの責任から外れるので、自分でポリフィルを行ってね」とのことだそう。
スレッド内ではnode-fetch
やwhatwg-fetch
などが挙がっていましたが、弊社ではisomorphic-fetch
を使っています。
テストのセットアップファイルなどでimportしておくことでfetchが実行できるようになります。
resetHandlersでデフォルト状態に戻す
特定のテスト実行時にMockを変更して、そのテストが終わればMockを元に戻して次のテストを実行したいときはresetHandlersで初期化してあげましょう。
// mswを使って、指定したリクエスト実行時に返ってくる値をmockできる
// resetHanlersを実行するとこの状態に戻る
const server = setupServer(
rest.post('http://hogehoge/fugafuga', (req, res, ctx) =>
res(
ctx.json(
// 省略
),
),
),
)
beforeAll(() => {
server.listen()
})
// テスト毎に初期化
beforeEach(() => {
server.resetHandlers()
})
afterAll(() => {
server.close()
})
description('いくつかのテスト', () => {
test('テスト1', () => {
// デフォルト状態でテスト実行
}
test('テスト2', () => {
server.use(
rest.post('http://hogehoge/fugafuga', (req, res, ctx) =>
res(ctx.status(500),
ctx.json(
// 省略
)
),
)
// ↑変更されたMockでテスト実行
}
test('テスト3', () => {
// リセットされるのでデフォルト状態でテスト実行
}
}
③その他
複数テストパターンをまとめて実行 - test.each
値の組み合わせを変えて複数パターンまとめてテストを行いたいという場合はeachを使うと簡潔にかけて便利です。
// 各パラメータと実行結果を各変数にセットにして、5パターンのテストを実行する
test.each`
typeName1 | value1 | typeName2 | value2 | expected
${'猫'} | ${cat} | ${'犬'} | ${dog} | ${-1}
${'猫'} | ${cat} | ${'うさぎ'} | ${rabbit} | ${-1}
${'猫'} | ${cat} | ${'猫'} | ${cat} | ${0}
${'犬'} | ${dog} | ${'猫'} | ${cat} | ${1}
${'うさぎ'} | ${rabbit} | ${'猫'} | ${cat} | ${1}
`(
// テスト名に各値を含めることができる
'第一引数が $typeName1 、第二引数が $typeName2 の場合 $expected が返却されること',
({ value1, value2, expected }) => {
const actual = someFunction(value1, value2)
expect(actual).toBe(expected)
},
)
// テスト名の例:
// 第一引数が 猫 、第二引数が 犬 の場合 -1 が返却されること
(最後に宣伝)スペースマーケットはエンジニアを募集しています!
月並みですが、、、エンジニア大募集中です!
現在(2023/01)ではインフラ、モバイルアプリ、バックエンドを中心に募集しているので、もし興味ある方いらっしゃればこちら覗いてみてください〜
スペースを簡単に貸し借りできるサービス「スペースマーケット」のエンジニアによる公式ブログです。 弊社採用技術スタックはこちら -> whatweuse.dev/company/spacemarket
Discussion
非同期処理を待ってからアサーションを行う - waitFor のコードですが、 findByRole を使うことでより簡潔に書けます
確かに非同期処理待って要素を取得するのは
findBy
のほうが簡潔に書けますね!ありがとうございます、追記しておきます〜😄
(だいぶ端折ってコメントしてしまってすいません) たしか expect().toBeInTheDocument で囲まなくても同じ動作をしたと思います
(追記: 改めて調べてみてますが囲むかどうかで微妙に挙動が違うかもしれないです)
あ、たしかに要素見つからなければテスト失敗しますもんね。
簡潔にexpectで囲まないように修正しておきます!