💡 Reactアプリケーションへのフロントエンドのテスト導入期に作成した社内共有ドキュメントをリメイクしたものです。package.jsonのscriptなどは自分のprojectにあったものとして読み替えてください。
テストの実行方法
基本のテスト実行パターン
テスト実行script
- 基本想定package.jsonのscriptは以下のようになります
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
オプション
カバレッジの出力
- npm run coverage
を実行すると、カバレッジを計測してくれます
- このとき、テストしていない行が赤色ハイライトされるようになります
一部のテストを回してほしい
- npm run test src/components/data/export/index.test.tsx
というように相対パスにてテストするファイルを指定することができます
- npm run test
を一度実行すると、testファイルを変更する度に該当ファイルのテストを回してくれます(watchモードといいます)
- その際に、修正中でテストを回してほしくないものは、以下のようにskip
をつけて書くことで、スキップしてくれます
describe.skip('ページ単位のテスト", () => { //describeごとskipする場合
it.skip('正常系テスト', async () => { //it単位でskipする場合
- ターミナル実行が面倒な場合はvs codeのvitest拡張機能で実行するとより楽です
基本的な書き方
調べれば色々とチュートリアルがでてくると思うので詳細は省きますが、大体以下の流れです
1. テストするコンポーネントで何をテストしたいかを書き出す
どのような挙動が正なのかがわからない場合は、要件をすりあわせること
describe('sampleページ期間入力および送信前バリデーション', () => {
it('正常系:出力ボタン押下後inputエラーが表示されずアナウンスが表示される(依頼日)', async () => {
// 依頼日を選択し、inputに正常な期間を指定する
// 出力ボタンを押下したらcsvが出力されたアナウンスを表示したい
// csv内容のチェックはこのコンポーネントでは行っていないため、テスト対応外
it('正常系:出力ボタン押下後inputエラーが表示されずアナウンスが表示される(納期)', async () => {
// 納期を選択し、inputに正常な期間を指定する
// 出力ボタンを押下したらcsvが出力されたアナウンスを表示したい
// csv内容のチェックはこのコンポーネントでは行っていないため、テスト対応外
it('正常系:リセットボタン押下後input内容が消える(依頼日)', async () => {
// 依頼日を選択し、inputに任意の値を指定する
// リセットボタンを押下したらinput内容が消える
it('準正常系:出力ボタン押下後inputエラーが表示される(納期)', async () => {
// 納期を選択し、inputに不正な期間を指定する
// 出力ボタンを押下したらエラーが表示される
it('準正常系:出力ボタン押下後500エラーとなったらエラーが表示される', async () => {
// 任意のものを選択し、inputに正常な期間を指定する
// 出力ボタンを押下したらapi500エラーに対応するエラーメッセージを表示したい
})
})
2. 各アクションとアサーションを分割する
- アクション:ユーザーの行動(入力する、押下する)
- アサーション:チェック(確認する)
describe('sampleページ期間入力および送信前バリデーション', () => {
it('正常系:出力ボタン押下後inputエラーが表示されずアナウンスが表示される(依頼日)', async () => {
// 依頼日を選択する
// 開始日inputに日付を入力する
// 終了日にも自動でinputに日付が入力されていることを確認する
// 終了日inputに日付を入力する
// 正しく入力されていることを確認する
// 出力ボタンを押下する
// エラーなしでapiが呼ばれる
// データが出力されたアナウンスが表示されていることを確認する
// データ内容のチェックはこのコンポーネントでは行っていないため、テスト対応外
// * 省略 * //
it('準正常系:出力ボタン押下後inputエラーが表示される(納期)', async () => {
// 納期を選択する
// 開始日inputに日付を入力する
// 終了日にも自動でinputに日付が入力されていることを確認する
// 終了日inputに開始日よりも前の日付を入力する
// 想定どおり入力されていることを確認する
// 出力ボタンを押下する
// エラーが表示される
// アナウンスが表示されていないことを確認する
})
})
3. 各入力値のギリギリの値を厳選する
describe('exportページ期間入力および送信前バリデーション', () => {
it('正常系:出力ボタン押下後inputエラーが表示されずアナウンスが表示される(依頼日)', async () => {
// 依頼日を選択する
// 開始日inputに日付を入力する **← テスト実施日が境界値**
// 終了日にも自動でinputに日付が入力されていることを確認する
// 終了日inputに日付を入力する **← 開始日=終了日が境界値**
// 正しく入力されていることを確認する
// 出力ボタンを押下する
// エラーなしでapiが呼ばれる
// データが出力されたアナウンスが表示されていることを確認する **← アナウンスメッセージを完全一致で確認**
// データ内容のチェックはこのコンポーネントでは行っていないため、テスト対応外
// * 省略 * //
it('準正常系:出力ボタン押下後inputエラーが表示される(納期)', async () => {
// 納期を選択する
// 開始日inputに日付を入力する **← 納期なので、任意の値で問題ない**
// 終了日にも自動でinputに日付が入力されていることを確認する
// 終了日inputに開始日よりも前の日付を入力する **← 終了日=開始日-1日が境界値**
// 想定どおり入力されていることを確認する
// 出力ボタンを押下する
// エラーが表示される **← エラーメッセージ自体が表示されることを確認するため、エラーメッセージを部分一致で確認**
// アナウンスが表示されていないことを確認する
})
})
4. API通信が発生する場合は、モックを作成する
- src/mocks/handlers.tsに追記する
- 増えてきたら各ディレクトリ内にmockディレクトリを追加し、handlers.tsを作成しても問題ないです
import { http, HttpResponse } from 'msw'
export const handlers = [
//* データ取得テスト用 *//
http.get(
'http://localhost:8080/api/data/:id',
({ request }) => {
// このテストではidの設定をする意味もあまりないので、分岐を作成する
let id = null
if (!id) {
id = 1
}
// 各パラメータのアレンジ
const url = new URL(request.url)
const requestDateStart = url.searchParams.get('requestDateStart')
const requestDateEnd = url.searchParams.get('requestDateEnd')
const subDateStart = url.searchParams.get('subDateStart')
const subDateEnd = url.searchParams.get('subDateEnd')
// 各リクエスト別のダミーjson内容を追加する
if (requestDateStart && requestDateEnd) {
return HttpResponse.json(
[
ダミーのjsonを返す
],
{ status: 200 }
)
}
if (subDateStart && subDateEnd) {
return HttpResponse.json(
[
ダミーのjsonを返す
],
{ status: 200 }
)
}
return null
}
),
]
export default handlers
5. アレンジ→アクト→アサートにて整理して書き始める
- 本当はエラー対応でもうちょっと記述量増えるのですが、エラーがない場合は以下のようになる想定
- 上から読んでテストの流れがわかるように記載すること
- カプセル化するときは順番がわからなくならないようにする
describe('sampleページ期間入力および送信前バリデーション', () => {
it('正常系:出力ボタン押下後inputエラーが表示されずアナウンスが表示される(依頼日)', async () => {
// アレンジ1
render(<ExportContents contentsTitle="データ出力" />)
const user = userEvent.setup()
const selectRequestDate = screen.getByRole('radio', { name: '依頼日' })
const startDate = screen.getByLabelText(
'出力期間の開始日を半角数字で入力してください。必須入力です。'
) as HTMLInputElement
const endDate = screen.getByLabelText(
'出力期間の終了日を半角数字で入力してください。必須入力です。'
) as HTMLInputElement
const exportButton = screen.getByRole('button', { name: '出力' })
// アクト1
// 依頼日を選択する
await user.click(selectRequestDate)
// 開始日inputに日付を入力する
await user.type(startDate, '2024-01-01')
// アサート1
// 正しく入力されていることを確認する
await waitFor(() => expect(startDate.value).toBe('2024-01-01'))
// 終了日にも自動でinputに日付が入力されていることを確認する
await waitFor(() => expect(endDate.value).toBe('2024-01-01'))
// 終了日inputに日付を入力する
/**開始日=終了日が境界値なので特に何もしない**/
// アクト2
// 出力ボタンを押下する
await user.click(exportButton)
// エラーなしでapiが呼ばれる
// アレンジ2(何かのアクションで初めて画面に要素が表示されるため、ここでアレンジが必要)
const errorMessagePushed = screen.queryByText(/指定してください/i)
// アサート2
await waitFor(() => expect(errorMessagePushed).not.toBeInTheDocument())
// データが出力されたアナウンスが表示されていることを確認する
// アレンジ3(api呼び出しで初めて画面に要素が表示されるため、ここでアレンジが必要)
const announceMessage = await screen.findByText('データを出力しました')
// アサート3
await waitFor(() => expect(announceMessage).toBeInTheDocument())
})
// * 省略 * //
})
時期別に気をつけること
導入期:いきなりテストを書きすぎないこと
-
正常系がデグレしてしまうと致命的なので、まずは正常系のテストを充実させましょう
-
異常系は部分アサーションを利用し、共通化して1つ書く
- フロントはたまにエラーメッセージ自体が表示されないというごっそりデグレがあるため、1つ書くというのが大事
-
実装例
describe('exportページ期間入力および送信前バリデーション', () => { it('正常系:出力ボタン押下後inputエラーが表示されない(依頼日)', async () => { // 正常系テスト1 }) it('正常系:出力ボタン押下後inputエラーが表示されない(納期)', async () => { // 正常系テスト2: 正常系テスト1の別分岐 }) it('正常系:リセットボタン押下後input内容が消える(納期)', async () => { // 正常系テスト3: 正常系テスト1と2とは違うボタンのテスト }) it('準正常系:出力ボタン押下後inputエラーが表示される(納期)', async () => { // 準正常系のテスト 1 :エラー共通テキストについて、部分アサーションを行う // 詳細実装は省略 // アサーション // 詳細条件を変更することが近々で想定されないため、エラーメッセージ表示だしわけの有無のみ判定する const errorMessagePushed = screen.queryByText(/指定してください/i) expect(errorMessagePushed).toBeInTheDocument() }) })
テスト網羅期:C1レベルでのテストを追加していく
- あとで異常系を充実させていくイメージです
テストを置く場所について
コロケーションの考え方を採用し、そのコンポーネントのディレクトリ内に配置すれば迷子にならないかと思います
-
カスタムフックとか外部ファイルに切り出した関数をテストする時は、対象となるコンポーネントと同じディレクトリに置くイメージです
-
基本的には「コンポーネント名.test.tsx」となるので、テストしたいコンポーネントを探しにいくと、隣にテストファイルが並んでいる状態を目指すと迷子にならないかと思われます
- その対象ディレクトリにカスタムフックが多すぎる場合は、「test」や「integrationTest」といったディレクトリを作成することで、対象とするtest範囲が広いことを表現します
-
複数コンポーネント横断テストの配置例
-
以下を参考にするのがおすすめです
どの範囲でテストするのか
単体テストなので基本的にはコンポーネント単位です。
が、現状のコンポーネントサイズ状況とリソースの関係上、以下のようにすすめていくことを推奨します
-
コンポーネント分割ができており、責務が明確なコンポーネントの場合(新規ページや比較的新しい実装のものが該当します)
-
コンポーネントが大きく、責務が多いコンポーネントの場合(ローンチ時点から存在するコンポーネントが該当します)
-
新規開発時に切り出したコンポーネントのみに対してテストを書いてください
- 大きすぎるテストとなり、そもそも書くのがしんどい、何がしたいテストなのかわからなくなる、メンテコストがかかる・・・など、デメリットが大きいです
- 並行して、大きすぎるコンポーネントを少しずつ分割していくと、それぞれの責務ごとのテストができあがります
- テストがかけるような単位でコンポーネントが分割できたら結合テストも追加する
- ユースケースに合わせて、まだ切り出しができていないコンポーネントの組み合わせで結合テストを書くことで、実質的にページ全体のテストを書くこともできます
-
新規開発時に切り出したコンポーネントのみに対してテストを書いてください
実装の指針
DOM要素取得
-
React-testing-libraryにおけるDOM取得バリエーションは以下に記載されています
-
仮想DOMをuserが動かしているようにして取得してください
-
上記公式Docのタイトルの上から順番に推奨されているDOM要素取得方法となります
-
getByRole で取得できないなら、getByLabelText,それでも無理ならgetByPlaceholderTextを検討する・・・という流れです
-
基本的には以下のようにgetByRoleやgetByLabelTextにて要素を取得してください
const usernameField = screen.getByRole('textbox', {name: /username/i}) const passwordField = screen.getByLabelText('password') const submitButton = screen.getByRole('button', {name: /sign in/i})
- ユーザー側からみて、usernameという名前がついたtextboxを認識できるか、passwordというラベル名が認識できるかということも一緒にチェックできます
- getByRoleのRoleは以下一覧より確認してください
-
テキストやIDでも取得は可能ですが、ユーザーはIDを見ることができないことが通常です。また、テキスト取得にてテストが成功や失敗した時の可読性が今いちです。(Roleだと、textboxで失敗したのか、といったことが読み解きやすいです)
AAA
-
バックエンド側のテストを書いている人もいるので、知っているかと思いますが、AAAという指針があります。
参考:速習 AAA : Arrange-Act-Assert による読みやすいテスト
- 忙しい人向け要約:いきなりアサートしようとせず、まず対象を定数化やカプセル化し、それに対して行動やアサートを行うことで可読性を向上させる
no ネスト
- また、ネストしないようにテストを書くこともメンテしやすいテストにするコツです
- react-testing-libraryを作成した人によるナレッジ
- https://kentcdodds.com/blog/avoid-nesting-when-youre-testing
- 忙しい人向け要約:afterAllなどでネストすると可読性が低下するので、関数化して、どのタイミングで呼び出されているかを明示的にしよう
- react-testing-libraryを作成した人によるナレッジ
ベタ書き推奨
- 可能であればベタ書き推奨です
- https://speakerdeck.com/jnchito/number-vstat
- csvエキスポートページは本日の日付が鍵になるので、変数化せざるを得なくなりましたが、ベタ書きできるところはベタ書していきます
テストする前に要素の分解、境界値の設定をすること
- 以下が参考になるかも
- 何を主にテストするかを決める
- その上で他の要素をばらけさせる
- 数字が絡む場合は、境界値にてテストすること
- 期間の場合は、1日ずらすとか、同日にするとか
describeとitの関係
- 正常系と準正常系、異常系のまとまりをdescribeとし、その中で各テストをネストしてください
- describeが膨れ上がったら、そもそもコンポーネントの分割が必要ではないか確認してください
- どうしようにもなかったら、テスト観点ごとにdescribeを作成し、itをまとめてください
propsなんでもいいんだけどなぁというとき
- エラーページ表示テストなど、propsにこだわらないテストの場合、propsもモック化することができます
-
以下のようにpropsをモック化することで、型エラーを気にせず、propsを渡さなかったときのテストがかけます
render(<ComponentName setItems={jest.fn()}/>) // undefinedがモックプロパティで渡されます render(<ComponentName onSubmit={jest.fn().mockImplementation(() => "あいうえお"}/>) // 処理はモック化し、処理後に何かをreturnしてほしいときにも使えます
-
一部のpropsだけモック化したいときは以下のようにかきます
const mockProps={ newTaskTitle:'', description:'', valid:false, onSubmit:jest.fn(), // ボタンを押すテストはしないので、onSubmitは不要 todos:[] } render (<NewTaskForm {...mockProps} />) // もしかしたらESLintにprops一括渡しは怒られるかもしれないのですが、考え方は同じです // ESLintにprops一括渡しを怒られた場合 render ( <NewTaskForm newTaskTitle='' description='' valid=false onChangeTaskTitle={jest.fn()} todos=[] />)
レビューTips
-
チェックしていない分岐のテストを確認しましょう
-
npm run coverage
を実行すると、テストしていない行が赤色ハイライトされるようになります。テストをしていない行をすべてテストする必要はありませんが、そもそもテストしたかった分岐を取り逃がしていないかを確認することができます
-
-
極端な変数化がされていないかを確認しましょう
- ベタ書きできるところはベタ書きした方がメンテコストが低くなることが多いです
もっと知りたくなってきた場合
- 公式Doc
- エラー対応集も社内でドキュメント化しているので近々かくかm