Open27

フロントエンドテストに入門する

kagomekagome

Testing trophyと各テストで使用するツール

  • 静的解析
    • ESLint
    • TypeScript
  • 単体テスト
    • Vitest
  • 結合テスト
    • Testing Library
    • jest-dom
    • msw
  • E2Eテスト
    • Playwright

https://speakerdeck.com/cybozuinsideout/web_frontend_testing_and_automation-2023

kagomekagome

MSWの存在理由

フロントエンドのテストを書くときには API コールする処理を全てモックする必要があります。外部の API をコールする処理をテストに含めると API サーバーが落ちているなどの外部の要因によってテストが失敗してしまう可能性がありますし、テストを実行するたびに実際に API をコールしてしまうとサーバーに負荷がかかってしまうなど外部に対しても悪影響を与えてしまいます。

従来のモックする手段としては Jest のモックを利用して axios や fetch などのモジュールをモック化する手法がよく使われていた

https://zenn.dev/azukiazusa/articles/using-msw-to-mock-frontend-tests

kagomekagome

テストを書く上で気を付けるべきこと

  • ByRole, ByLabelText, ByPlaceholderText, ByText, ByDisplayValueなど、なるべくユーザー視点でテストできるクエリを使用する

https://speakerdeck.com/cybozuinsideout/web_frontend_testing_and_automation-2023?slide=55

kagomekagome

Testing Libraryは何が嬉しいのか

https://zenn.dev/crsc1206/articles/8bf487be129eed

kagomekagome

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を使った方が、よりユーザーが実際に行う操作に近いテストが書けます。

https://zenn.dev/k_log24/articles/4c1cd37ff0ca50

kagomekagome

test関数をグルーピングしたい場合、describe関数でまとめる
test関数はネストできないが、describe関数はネストできる

kagomekagome

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によって検証できるようになります。
このように、アロー関数や一般的な関数でラップすることで、テストフレームワークは関数を後で実行できる状態に保ち、正常に例外処理のテストができるようになります。

kagomekagome

アロー関数を使用することで関数呼び出しが遅延する理由は、アロー関数自体が関数の参照を返すだけで、その関数が呼び出されるのは、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がその関数を実行するタイミングを制御できます。これにより、例外が発生した場合にそれを適切に捕捉して検証できるようになるのです。

kagomekagome

例外を補足できない理由

  1. 即時実行の結果:

    expect(add(2, -3)).toThrow();
    

    この場合、add(2, -3) はこのコードが実行される時点で即座に呼び出され、その実行中に例外がスローされる可能性があります。この場合、add 関数が例外をスローすると、その戻り値が expect に渡される前にプログラムが例外で中断され、expect まで到達できなくなります。つまり、Jestが toThrow で例外を検証するためのオブジェクトがそもそも渡されない状態になってしまいます。

  2. 例外処理の流れ:

    • JavaScriptのエラーハンドリングは、実行しているコンテキストで直接的に例外がスローされると、通常はそれが捕捉されない限り、次の行のコードは実行されません。従って、その時点での処理が中断され、テストフレームワーク(Jestなど)が例外を補足することができません。

アロー関数を使った遅延実行

  • アロー関数を使用することによる遅延実行は、関数の呼び出しを実際に行うのではなく、その関数を"ラップ"することにより実現されます。

    expect(() => add(2, -3)).toThrow();
    

    ここでは、add(2, -3)expect の評価時に直接実行されるのを防ぎます。Instead, expectに渡すのは関数 (() => add(2, -3)) であり、expect がその関数を実行する時点で、add が呼び出されることになります。この呼び出しの中で例外がスローされた場合、toThrow はその例外を捕捉できるという仕組みです。

まとめ

  • 例外を補足できない理由: 即時実行の関数が例外をスローすると、テストフレームワークの expect まで到達せず、例外が補足できないため。

  • アロー関数を使う理由: アロー関数を使うことで、関数の呼び出しを遅延させ、指定したタイミングで実行し、例外がスローされた場合にその例外を捕捉し、「期待される例外」であるかどうかをテストすることができる。

これが、テストで例外が発生することを期待する際にアロー関数を用いる理由です。

kagomekagome

なるほど!
ラップしないとスローされたエラーをexpect関数がキャッチできない、ということか
ラップしない場合、通常のエラーハンドリングのように、テスト自体が中断されてしまう

kagomekagome

Error()

new Error(message, options)
kagomekagome

throwreturn は、JavaScriptにおいて異なる目的で使用されるキーワードです。それぞれの使い方や動作について以下に説明します。

1. throw

  • 目的: エラーを発生させるために使用します。
  • 動作: 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以上でなければなりません"
    }
    

2. return

  • 目的: 関数から値を返すために使用します。
  • 動作: return 文が実行されると、関数の実行が終了し、指定された値が呼び出し元に返されます。return 文がなければ、関数は undefined を返します。
  • 使用例:
    function add(a, b) {
        return a + b;
    }
    
    const result = add(2, 3);
    console.log(result); // 5
    

まとめ

  • throw はエラーを発生させて処理を中断するために使用され、エラーハンドリングを行う際に特に重要です。
  • return は関数の処理結果を返すために使用され、一般的な関数の挙動を制御します。

このように、throwreturn は異なる役割を持つため、状況に応じて使い分けることが重要です。

kagomekagome

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') を使用します。どちらの使い方も可能ですが、意図する動作に応じて適切に使い分けることが大切です。

kagomekagome

return new Error('error') した場合、関数自体がエラーをスローするわけではないことに注意が必要です。この場合の戻り値は通常の値(エラーオブジェクト)となるため、エラーハンドリングが行われないという点で、throw を使用する場合とは異なります

理解

kagomekagome

JestにおけるtoBetoEqualは、両方ともアサーション(検証)のためのメソッドですが、使用する際の意味や動作が異なります。

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: 深い内容の等価性をチェック(プロパティが同じかどうか)。

適切なメソッドを選ぶことで、テストの意図を明確にし、より効果的なアサーションを行うことができます。

kagomekagome

toBetoEqualで異なる結果になる例を示します。

プリミティブな値の場合

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)は失敗します。なぜなら、obj1obj2は異なるオブジェクトであり、異なるメモリアドレスを持つため、参照が等しくない(== 演算子による)のです。
  • 一方、expect(obj1).toEqual(obj2)は成功します。toEqualメソッドはオブジェクトのプロパティを比較するため、obj1obj2の内容が同じ(name'Alice'である)ので等しいと見なされます。

このように、toBetoEqualは異なる状況で異なる結果を返します。特にオブジェクトや配列の比較を行う際にはtoEqualを使用し、参照の一致をチェックしたい場合にはtoBeを使用します。

kagomekagome

toContaintoContainEqualは、コレクション(配列やセットなど)に特定の要素が含まれているかどうかを検証するために使用されます。しかし、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: オブジェクトや配列の内容を比較し、等しい(プロパティが同じ)場合に存在をチェック。

このように、条件に応じて適切なメソッドを選ぶことで、テストの精度を高めることができます。

kagomekagome

toContaintoContainEqualを使用して、異なる結果になる例を示します。

例: 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' }); // 失敗する
});

詳細な解説

  1. toContainに関するテスト:

    • expect(arr).toContain({ id: 1, name: 'Alice' });は失敗します。これは、toContainが参照の一致をチェックするため、配列内のオブジェクトと異なる新しいオブジェクトを渡しているためです。オブジェクトの内容が同じでも、異なるメモリの参照を持つため、toContainの比較に失敗します。
    • expect(arr).toContain(arr[0]);は成功します。ここでは、arr[0]は配列内のオブジェクトと同じ参照を指しているため、成功します。
  2. toContainEqualに関するテスト:

    • expect(arr).toContainEqual({ id: 1, name: 'Alice' });は成功します。toContainEqualはオブジェクトの内容を比較するため、配列内にid1name'Alice'のオブジェクトが含まれていると見なされます。
    • expect(arr).toContainEqual({ id: 3, name: 'Charlie' });は失敗します。このオブジェクトは配列内に存在しないため、アサーションは失敗します。

まとめ

  • toContainはオブジェクトの参照が同一であるかをチェックします。したがって、配列内の要素と同じ内容の新しいオブジェクトを渡すと失敗します。
  • toContainEqualはオブジェクトの内容を比較するため、同様のプロパティを持つオブジェクトを渡すと成功します。
kagomekagome

Equalがつくと純粋に内容が等しいかどうかを調査する、という理解をしておく

kagomekagome

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したよ

https://qiita.com/cheez921/items/41b744e4e002b966391a#resolveさせよう

kagomekagome

非同期処理テストの代表的な書き方

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!")    
    }
})
kagomekagome

モック

都合の悪いモジュールをモックする

greet.ts
// テストする上で都合の悪い関数
export function sayGoodBye(name: string) {
  throw new Error("未実装");
}
greet.test.ts
jest.mock("./greet", () => ({
  sayGoodBye: (name: string) => `Good bye, ${name}.`,
}));

test("さよならを返す(本来の実装ではない)", () => {
  const message = `${sayGoodBye("Taro")} See you.`;
  expect(message).toBe("Good bye, Taro. See you.");
});