フロントエンドテストに入門する
Testing trophyと各テストで使用するツール
- 静的解析
- ESLint
- TypeScript
- 単体テスト
- Vitest
- 結合テスト
- Testing Library
- jest-dom
- msw
- E2Eテスト
- Playwright
VRTテスト
- reg-suit
- 画像の差分を比較するツール
- storycap
a11yテスト
- eslint-plugin-a11y-jsx/recommend
- Markuplint
- @axe-core/playwright
MSWの存在理由
フロントエンドのテストを書くときには API コールする処理を全てモックする必要があります。外部の API をコールする処理をテストに含めると API サーバーが落ちているなどの外部の要因によってテストが失敗してしまう可能性がありますし、テストを実行するたびに実際に API をコールしてしまうとサーバーに負荷がかかってしまうなど外部に対しても悪影響を与えてしまいます。
従来のモックする手段としては Jest のモックを利用して axios や fetch などのモジュールをモック化する手法がよく使われていた
MSWを動かす
その他参考にしたもの
テストを書く上で気を付けるべきこと
-
ByRole
,ByLabelText
,ByPlaceholderText
,ByText
,ByDisplayValue
など、なるべくユーザー視点でテストできるクエリを使用する
Arrange・Act・Assert (AAA) パターン
準備 (Arrange)・実行 (Act)・検証 (Assert) というプロセスで分けて書きます。
準備・実行・検証をそれぞれ分けて書いておくことで比較的読みやすいテストを書くことができます。
Testing Libraryは何が嬉しいのか
user-eventとfireEventのどちらを使うか
fireEventはDOMイベントを発火させるための関数です。
import { fireEvent } from '@testing-library/react'
// ボタンをクリックする
fireEvent.click(screen.getByRole('button'))
user-eventは、ユーザーが実際に行う操作をシミュレートするための関数です。
import userEvent from '@testing-library/user-event'
// ボタンをクリックする
userEvent.click(screen.getByRole('button'))
fireEventとuser-eventのどちらを使っても、DOMイベントを発火させることができます。しかし、user-eventを使った方が、よりユーザーが実際に行う操作に近いテストが書けます。
セットアップ
test関数をグルーピングしたい場合、describe関数でまとめる
test関数はネストできないが、describe関数はネストできる
JestやVitestで例外がスローされることを検証する際に、関数をアロー関数式でラップする必要がある理由は、toThrowマッチャーに渡す引数として、実行を遅延させるためです。
具体的には、以下のポイントが重要です。
即時実行の回避:
expect(add(2, 3)).toThrow()
という書き方は、add(2, 3)
を即座に実行し、その戻り値をexpectに渡しています。この場合、add
が実行され、その結果がすぐに評価されるため、例外が発生する前にテストが終了してしまいます。結果として、toThrow
は何も評価する対象を持たず、期待する結果が得られません。
遅延実行:
一方、expect(() => add(2, 3)).toThrow()
と書くことで、add(2, 3)
の関数呼び出しが遅延し、expect
が評価されるまで実行されません。このようにすることで、add
が呼び出された際に例外がスローされた場合、その例外をtoThrow
によって検証できるようになります。
このように、アロー関数や一般的な関数でラップすることで、テストフレームワークは関数を後で実行できる状態に保ち、正常に例外処理のテストができるようになります。
アロー関数を使用することで関数呼び出しが遅延する理由は、アロー関数自体が関数の参照を返すだけで、その関数が呼び出されるのは、expect()が評価される際に実行されるからです。具体的には、以下のように機能します。
即時実行と遅延実行の違い
即時実行:
expect(add(2, 3)).toThrow();
ここでは、add(2, 3)がテストが始まる前にすぐに実行されます。expectに渡されるのは add の戻り値であるため、もし add が例外をスローした場合でも、その情報はexpectに渡されず、例外が発生してテストが失敗します。
遅延実行(アロー関数を使用):
expect(() => add(2, 3)).toThrow();
ここでは、expectの引数としてアロー関数 () => add(2, 3) を渡しています。このアロー関数は、expectが評価されたときに初めて実行されます。つまり、expectが toThrow を呼び出すときに、アロー関数が実行され、その中で add(2, 3) が呼び出されます。このとき、add が例外をスローすれば、その例外が toThrow に渡され、正しくテストされることになります。
まとめ
アロー関数を使用することで、関数の呼び出しを「遅らせる」ことができ、expectがその関数を実行するタイミングを制御できます。これにより、例外が発生した場合にそれを適切に捕捉して検証できるようになるのです。
例外を補足できない理由
-
即時実行の結果:
expect(add(2, -3)).toThrow();
この場合、
add(2, -3)
はこのコードが実行される時点で即座に呼び出され、その実行中に例外がスローされる可能性があります。この場合、add
関数が例外をスローすると、その戻り値がexpect
に渡される前にプログラムが例外で中断され、expect
まで到達できなくなります。つまり、JestがtoThrow
で例外を検証するためのオブジェクトがそもそも渡されない状態になってしまいます。 -
例外処理の流れ:
- JavaScriptのエラーハンドリングは、実行しているコンテキストで直接的に例外がスローされると、通常はそれが捕捉されない限り、次の行のコードは実行されません。従って、その時点での処理が中断され、テストフレームワーク(Jestなど)が例外を補足することができません。
アロー関数を使った遅延実行
-
アロー関数を使用することによる遅延実行は、関数の呼び出しを実際に行うのではなく、その関数を"ラップ"することにより実現されます。
expect(() => add(2, -3)).toThrow();
ここでは、
add(2, -3)
がexpect
の評価時に直接実行されるのを防ぎます。Instead,expect
に渡すのは関数 (() => add(2, -3)
) であり、expect
がその関数を実行する時点で、add
が呼び出されることになります。この呼び出しの中で例外がスローされた場合、toThrow
はその例外を捕捉できるという仕組みです。
まとめ
-
例外を補足できない理由: 即時実行の関数が例外をスローすると、テストフレームワークの
expect
まで到達せず、例外が補足できないため。 -
アロー関数を使う理由: アロー関数を使うことで、関数の呼び出しを遅延させ、指定したタイミングで実行し、例外がスローされた場合にその例外を捕捉し、「期待される例外」であるかどうかをテストすることができる。
これが、テストで例外が発生することを期待する際にアロー関数を用いる理由です。
なるほど!
ラップしないとスローされたエラーをexpect
関数がキャッチできない、ということか
ラップしない場合、通常のエラーハンドリングのように、テスト自体が中断されてしまう
Error()
new Error(message, options)
throw
と return
は、JavaScriptにおいて異なる目的で使用されるキーワードです。それぞれの使い方や動作について以下に説明します。
throw
1. - 目的: エラーを発生させるために使用します。
-
動作:
throw
文が実行されると、現在の関数の実行を中断し、制御が呼び出し元のエラーハンドラーに移ります。エラーハンドリングのためにtry...catch
構文を使います。 -
使用例:
function checkValue(value) { if (value < 0) { throw new Error("値は0以上でなければなりません"); } return value; } try { checkValue(-1); } catch (e) { console.log(e.message); // "値は0以上でなければなりません" }
return
2. - 目的: 関数から値を返すために使用します。
-
動作:
return
文が実行されると、関数の実行が終了し、指定された値が呼び出し元に返されます。return
文がなければ、関数はundefined
を返します。 -
使用例:
function add(a, b) { return a + b; } const result = add(2, 3); console.log(result); // 5
まとめ
-
throw
はエラーを発生させて処理を中断するために使用され、エラーハンドリングを行う際に特に重要です。 -
return
は関数の処理結果を返すために使用され、一般的な関数の挙動を制御します。
このように、throw
と return
は異なる役割を持つため、状況に応じて使い分けることが重要です。
return new Error('error')
は可能です。これは、Error
オブジェクトを生成し、それを関数の戻り値として返すという意味です。以下に具体例を示します。
使用例
function createErrorMessage() {
return new Error('これはエラーメッセージです');
}
const error = createErrorMessage();
console.log(error); // Error: これはエラーメッセージです
この例では、createErrorMessage
関数が Error
オブジェクトを生成し、そのまま返しています。受け取った側では、error
という変数にエラーオブジェクトが格納され、エラーメッセージを確認することができます。
注意点
ただし、return new Error('error')
した場合、関数自体がエラーをスローするわけではないことに注意が必要です。この場合の戻り値は通常の値(エラーオブジェクト)となるため、エラーハンドリングが行われないという点で、throw
を使用する場合とは異なります。
エラーとして扱いたい場合は、throw
を使う方が適切です。例えば:
function throwError() {
throw new Error('これはエラーメッセージです');
}
try {
throwError();
} catch (e) {
console.log(e.message); // これはエラーメッセージです
}
このように、関数がエラーを発生させる場合は throw
を使用し、普通の値(この場合はエラーオブジェクト)を返したい場合は return new Error('error')
を使用します。どちらの使い方も可能ですが、意図する動作に応じて適切に使い分けることが大切です。
return new Error('error') した場合、関数自体がエラーをスローするわけではないことに注意が必要です。この場合の戻り値は通常の値(エラーオブジェクト)となるため、エラーハンドリングが行われないという点で、throw を使用する場合とは異なります
理解
JestにおけるtoBe
とtoEqual
は、両方ともアサーション(検証)のためのメソッドですが、使用する際の意味や動作が異なります。
toBe
-
基本比較:
toBe
は厳密な等価性をチェックします。具体的には、オブジェクトの参照が同一であるかどうか(=== 演算子による比較)を確認します。 - 使いどころ: プリミティブな値(文字列、数値、ブーリアンなど)や、同一の参照を持つオブジェクトに対して使用します。
例:
test('using toBe to compare primitives', () => {
expect(2 + 2).toBe(4); // 成功する
});
test('using toBe with objects', () => {
const obj1 = { name: 'Alice' };
const obj2 = obj1;
expect(obj1).toBe(obj2); // 成功する
});
toEqual
-
深い比較:
toEqual
はオブジェクトの内容を比較します。つまり、オブジェクトのプロパティが同じであれば等しいと見なされます。 - 使いどころ: オブジェクトや配列の内容を比較したい場合に使用します。
例:
test('using toEqual to compare objects', () => {
const obj1 = { name: 'Alice' };
const obj2 = { name: 'Alice' };
expect(obj1).toEqual(obj2); // 成功する
});
まとめ
-
toBe
: 厳密な等価性(同じ参照を持つか)をチェック。 -
toEqual
: 深い内容の等価性をチェック(プロパティが同じかどうか)。
適切なメソッドを選ぶことで、テストの意図を明確にし、より効果的なアサーションを行うことができます。
toBe
とtoEqual
で異なる結果になる例を示します。
プリミティブな値の場合
test('toBe and toEqual with primitive values', () => {
const number = 5;
// toBeを使った比較 - 成功する
expect(number).toBe(5);
// toEqualを使った比較 - 成功する
expect(number).toEqual(5);
});
どちらも等しいので、成功します。
オブジェクトの場合
test('toBe and toEqual with objects', () => {
const obj1 = { name: 'Alice' };
const obj2 = { name: 'Alice' };
// toBeを使った比較 - 失敗する
expect(obj1).toBe(obj2); // これは失敗する
// toEqualを使った比較 - 成功する
expect(obj1).toEqual(obj2); // これは成功する
});
詳細の解説:
-
expect(obj1).toBe(obj2)
は失敗します。なぜなら、obj1
とobj2
は異なるオブジェクトであり、異なるメモリアドレスを持つため、参照が等しくない(== 演算子による)のです。 - 一方、
expect(obj1).toEqual(obj2)
は成功します。toEqual
メソッドはオブジェクトのプロパティを比較するため、obj1
とobj2
の内容が同じ(name
が'Alice'
である)ので等しいと見なされます。
このように、toBe
とtoEqual
は異なる状況で異なる結果を返します。特にオブジェクトや配列の比較を行う際にはtoEqual
を使用し、参照の一致をチェックしたい場合にはtoBe
を使用します。
toContain
とtoContainEqual
は、コレクション(配列やセットなど)に特定の要素が含まれているかどうかを検証するために使用されます。しかし、2つのメソッドは比較の仕方が異なります。
toContain
-
基本的な比較:
toContain
は、配列やセットに指定されたアイテムが含まれているかどうかを、厳密な等価性(===)でチェックします。プリミティブな値に対して使用されることが一般的です。 - 使いどころ: 配列やセットに対してプリミティブな値(数値、文字列、ブーリアンなど)が含まれているかを検証したいときに利用します。
例:
test('toContain with primitive values', () => {
const arr = [1, 2, 3, 4, 5];
expect(arr).toContain(3); // 成功する
expect(arr).toContain(6); // 失敗する
});
toContainEqual
-
深い比較:
toContainEqual
は、オブジェクトや配列の内容を比較するために用いられます。これにより、プロパティやその値が同じであれば等しいとみなされます。 - 使いどころ: 配列の中に特定のオブジェクトが含まれているか、内容が等しいオブジェクトを比較したい場合に使用します。
例:
test('toContainEqual with objects', () => {
const arr = [{ name: 'Alice' }, { name: 'Bob' }];
expect(arr).toContainEqual({ name: 'Alice' }); // 成功する
expect(arr).toContainEqual({ name: 'Charlie' }); // 失敗する
});
詳細の解説:
-
expect(arr).toContain(3)
は、3
が配列に含まれているかをチェックし、成功します。 -
expect(arr).toContainEqual({ name: 'Alice' })
は、配列に含まれるオブジェクトの中にプロパティname
が'Alice'
であるオブジェクトがいるかを確認し、成功します。 - 一方、
expect(arr).toContainEqual({ name: 'Charlie' })
は失敗します。なぜなら、配列内にはname
が'Charlie'
であるオブジェクトは存在しないからです。
まとめ
-
toContain
: プリミティブな値の存在を厳密にチェック。 -
toContainEqual
: オブジェクトや配列の内容を比較し、等しい(プロパティが同じ)場合に存在をチェック。
このように、条件に応じて適切なメソッドを選ぶことで、テストの精度を高めることができます。
toContain
とtoContainEqual
を使用して、異なる結果になる例を示します。
toContain
vs toContainEqual
例: test('toContain and toContainEqual', () => {
const arr = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
// toContainを使用した場合
expect(arr).toContain({ id: 1, name: 'Alice' }); // 失敗する
expect(arr).toContain(arr[0]); // 成功する
// toContainEqualを使用した場合
expect(arr).toContainEqual({ id: 1, name: 'Alice' }); // 成功する
expect(arr).toContainEqual({ id: 3, name: 'Charlie' }); // 失敗する
});
詳細な解説
-
toContain
に関するテスト:-
expect(arr).toContain({ id: 1, name: 'Alice' });
は失敗します。これは、toContain
が参照の一致をチェックするため、配列内のオブジェクトと異なる新しいオブジェクトを渡しているためです。オブジェクトの内容が同じでも、異なるメモリの参照を持つため、toContain
の比較に失敗します。 -
expect(arr).toContain(arr[0]);
は成功します。ここでは、arr[0]
は配列内のオブジェクトと同じ参照を指しているため、成功します。
-
-
toContainEqual
に関するテスト:-
expect(arr).toContainEqual({ id: 1, name: 'Alice' });
は成功します。toContainEqual
はオブジェクトの内容を比較するため、配列内にid
が1
でname
が'Alice'
のオブジェクトが含まれていると見なされます。 -
expect(arr).toContainEqual({ id: 3, name: 'Charlie' });
は失敗します。このオブジェクトは配列内に存在しないため、アサーションは失敗します。
-
まとめ
-
toContain
はオブジェクトの参照が同一であるかをチェックします。したがって、配列内の要素と同じ内容の新しいオブジェクトを渡すと失敗します。 -
toContainEqual
はオブジェクトの内容を比較するため、同様のプロパティを持つオブジェクトを渡すと成功します。
Equal
がつくと純粋に内容が等しいかどうかを調査する、という理解をしておく
Promise
resolve
resolve
は、引数を受け取ることができ、次に呼ばれるthen
の第一引数に渡してあげることができます。
const promise = new Promise((resolve) => {
// 引数に文字列を渡す
resolve("resolveしたよ");
}).then((val) => {
// 第一引数にて、resolve関数で渡した文字列を受け取ることができる
console.log(val);
});
// resolveしたよ
reject
thenの処理は実行されず、catchの処理が実行されることがわかりますね。
const promise = new Promise((resolve, reject) => {
reject();
})
.then(() => {
console.log("resolveしたよ");
})
.catch(() => {
console.log("rejectしたよ");
});
// rejectしたよ
非同期処理テストの代表的な書き方
function wait(duration: number){
return new Promise((resolve)=>{
setTimeout(()=>{
resolve("resolved!")
}, duration)
})
}
test("指定した経過時間後、resolveされる", async()=>{
expect(await wait(50)).toBe("resolved!")
})
function timeout(duration: number){
return new Promise((_, reject)=>{
setTimeout(()=>{
reject("rejected!")
}, duration)
})
}
test("指定した経過時間後、rejectされる", async() => {
expect.assertions(1) // アサーションが一度実行されるのを期待する
try{
await timeout(50)
}catch(error){
expect(error).toBe("rejected!")
}
})
モック
都合の悪いモジュールをモックする
// テストする上で都合の悪い関数
export function sayGoodBye(name: string) {
throw new Error("未実装");
}
jest.mock("./greet", () => ({
sayGoodBye: (name: string) => `Good bye, ${name}.`,
}));
test("さよならを返す(本来の実装ではない)", () => {
const message = `${sayGoodBye("Taro")} See you.`;
expect(message).toBe("Good bye, Taro. See you.");
});