あるSEのつぶやき・改

ITやシステム開発などの技術に関する話題を、取り上げたりしています。

Vite+Jest 逆引きリファレンス in TypeScript

はじめに

JavaScript や TypeScript のテストで、Jest を使う機会は多いかと思います。

この記事は、Jest の使い方を逆引きリファレンスの形式で掲載します。

今まで Jest でテストを行う際は webpack を使用することが多かったのですが、webpack はすでに開発が終了していてます。

この記事では webpack の代わりに、Vite(ヴィート) というフロントエンドツールを使用します。Vite は高速でビルドできるため、開発体験がすばらしく向上します。

Jest を理解するにあたり、下記書籍で勉強させていただきました。

Jest に関する日本語の書籍は、ほぼないに等しいので、とても参考になりました。

開発環境構築

Mac を対象とした開発環境構築方法をご紹介しますが、Windows でも大きな差はありません。

開発環境の IDE(統合開発環境) は、Visual Studio Code を使用します。

プラグインは、以下のものがあると便利です。

開発ツールとして、以下のライブラリを使用します。

Babel を使用する理由は、Jest は require でモジュールを読み込む CommonJS を採用していますが、Vite は import でモジュールを読み込む ESM (ECMAScript Modules) を採用しているため、差を吸収するため変換をかける必要があるためです。

Node.js のパッケージマネージャとして、npm を使用します。 yarn などの別のパッケージマネージャを使用する場合は、適宜読み替えてください。

Visual Studio Code のインストール

Visual Studio Code にアクセスし、ダウンロードボタンからダウンロードしてインストールします。

以下のように、拡張機能のインストール画面で拡張機能を検索して、ESlint を始めとした拡張機能をインストールします。似たような拡張機能がありますが、間違わないようにご注意ください。

Node.js のインストール

Node.js にアクセスして、Node.js の推奨版をダウンロードしてインストールします。

LTS というのは「Long Term Support(長期サポート)」のことで、サポート期間が長いバージョンになるため、基本的に LTS のバージョンを使用します。

Node.js のインストール後、anyenv + anyenv-update + nodenvか、asdf を使用すると、複数の Node.js を1台の開発パソコンに共存させることができます。

TypeScript のインストール

TypeScript がインストールされているか確認します。

$ tsc -v

# TypeScript がインストールされていない
zsh: command not found: tsc

# TypeScript がインストールされている
Version 5.1.6

TypeScript がインストールされていない場合は、インストールを行います。

$ npm install -g typescript

Vite

Vite のプロジェクトを作成する

下記コマンドで、Vite のプロジェクトを作成します。

$ npm create vite@latest typescript-vite-jest -- --template react-ts

typescript-vite-jest が作成するプロジェクト名で、 react-ts が使用するテンプレートになります。

react-ts を指定すると、React + TypeScript のテンプレートを指定したことになります。

テンプレートには、以下のものがあります。

Getting Started | Vite

テンプレートを直接指定する以外にも、対話形式でテンプレートを指定することもできます。

$ npm create vite@latest

✔ Project name: … typescript-vite-jest
? Select a framework: › - Use arrow-keys. Return to submit.
    Vanilla
    Vue
❯   React
    Preact
    Lit
    Svelte
    Solid
    Qwik
    Others
? Select a variant: › - Use arrow-keys. Return to submit.
❯   TypeScript
    TypeScript + SWC
    JavaScript
    JavaScript + SWC

✔ Project name: … typescript-vite-jest
✔ Select a framework: › React
✔ Select a variant: › TypeScript

Done. Now run:

  cd typescript-vite-jest
  npm install
  npm run dev

プロジェクトに移動して、Jest をインストールします。

# プロジェクトに移動
$ cd typescript-vite-jest

# プロジェクトに必要なライブラリのインストール
$ npm install

# Jest のインストール
$ npm install --save-dev ts-jest @jest/globals

ここでサラッと @jest/globals をインストールしましたが、これはテストプログラムの先頭で、以下のように describe などを明示的に import するためです。

import { describe, expect, test } from "@jest/globals";

参考: Globals

ts-jest の初期化を行います(jest.config.js が作成される)。

$ npx ts-jest config:init

package.json を修正します。

{
  "type": "module",  // <- 削除, エラーになるため
  "scripts": {
    "test": "jest",  // <- 追加
  }
}

tsconfig.json を編集します。

{
  "noUnusedLocals": false, // true -> false に変更, false だと使用していない変数がコンパイルエラーになるため
}

Babel をインストールします。

$ npm install --save-dev @babel/preset-typescript

プロジェクトのルート(typescript-vite-jest の直下)に babel.config.js を作成します。

module.exports = {
  presets: [
    ['@babel/preset-env', {targets: {node: 'current'}}],
    '@babel/preset-typescript',
  ],
};

実際に Jest を動作させるために、 src/sum.ts ファイルを作成します。

export const sum = (a: number, b: number): number =>  {
  return a + b;
}

プロジェクトのルート(今回は typescript-vite-jest の直下) に test ディレクトリを作成後、 test/sum.test.ts を作成します。

import {describe, expect, test} from '@jest/globals';
import {sum} from '../src/sum';

describe('sum module', () => {
  test('adds 1 + 2 to equal 3', () => {
    expect(sum(1, 2)).toBe(3);
  });
});

テストを実行します。

$ npm run test

 PASS  test/sum.test.ts
  sum module
    ✓ adds 1 + 2 to equal 3 (1 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.391 s
Ran all test suites.

無事にテストが実行できるようになったので、開発環境構築は完了です。

なお、 .vscode/settings.json を以下の内容で作成して、Visual Studio Code を再起動すると、ファイル保存時に自動的にテストが実行されるようになります。

{
  "jest.jestCommandLine": "npm run test --",
  "jest.autoRun": { "watch": true }
}

また、拡張機能により、テストの管理が非常に簡単になります。

Jest

Jest の基本

テスト対象ファイル名

テスト対象の関数を含んだファイルの拡張子は、.ts または .tsx である必要があります。sum.tssum.tsx といった具合です。

通常は .ts で問題ないのですが、React などで描画を行う場合は .tsx を使用することがあります。

テスト対象関数

テスト対象の関数は、export を行う必要があります。

export を行わないと、テストからテスト対象関数を参照できません。

src/sum.ts

export const sum = (a: number, b: number): number => {
  return a + b;
};

上記はアロー関数ですが、通常の関数でも同様です。

export function sum(a: number, b: number): number {
  return a + b;
}

テストファイル名

プロジェクトの設定にもよりますが、テストファイルは <テスト対象ファイル>.test.ts または <テスト対象ファイル>.test.tsx にします。

テストを作成する

import 文で Jest とテスト対象の関数を読み込みます。

テスト対象が src/sum.tssum 関数の場合は、以下のようになります。

// Jest の読み込み
import { describe, expect, test } from "@jest/globals";

// テスト対象関数の読み込み
import { sum } from "../src/sum";

そして、以下のようにテストを記述します。

test("1と2と引数に渡すと3が返ってくること", () => {
  expect(sum(1, 2)).toBe(3);
});

なお、testit という記述方法がありますが、どちらも同じ内容です。

test の場合

import { expect, test } from "@jest/globals";
import { sum } from "../src/sum";

test("1と2と引数に渡すと3が返ってくること", () => {
  expect(sum(1, 2)).toBe(3);
});

it の場合

import { expect, it } from "@jest/globals";
import { sum } from "../src/sum";

it("1と2と引数に渡すと3が返ってくること", () => {
  expect(sum(1, 2)).toBe(3);
});

テスト結果を検証する

テスト結果は、 expect を使用して 必ず 検証を行います。

 expect(sum(1, 2)).toBe(3);

検証を行わないと、テスト結果が間違っていてもテストが通ってしまいます。

テストをグループ化する

describe を使用してテストをグループ化することができます。

import { describe, test, expect } from '@jest/globals';

describe("グループ1", () => {
  test("テスト1", () => {
    expect(true).toBeTruthy();
  });

  test("テスト2", () => {
    expect(true).toBeTruthy();
  });
});

describe("グループ2", () => {
  test("テスト1", () => {
    expect(true).toBeTruthy();
  });
});

describe はネストすることもできます。

import { describe, test, expect } from '@jest/globals';

describe("グループ1", () => {
  test("テスト1", () => {
    expect(true).toBeTruthy();
  });

  describe("グループ2", () => {
    test("テスト1", () => {
      expect(true).toBeTruthy();
    });
  });
});

テストの前後に処理を行う

以下の機能を使用することで、テストの前後に処理を入れることができます。

説明
beforeAll すべてのテストの前に処理を行う。
beforeEach 各テストの前に処理を行う。
afterAll すべてのテストの後に処理を行う。
afterEach 各テストの後に処理を行う。

これにより、モックのクリアなどを効率よく行うことができます。

実際のテストの順番を確認します。

import {
  describe,
  test,
  expect,
  beforeAll,
  beforeEach,
  afterAll,
  afterEach,
} from '@jest/globals';

describe('テストの順番を確認する', () => {
  beforeAll(() => {
    console.log('beforeAll');
  });

  beforeEach(() => {
    console.log('beforeEach');
  });

  afterAll(() => {
    console.log('afterAll');
  });

  afterEach(() => {
    console.log('afterEach');
  });

  test('テスト1', () => {
    console.log('テスト1');
    expect(true).toBeTruthy();
  });

  test('テスト2', () => {
    console.log('テスト2');
    expect(true).toBeTruthy();
  });
});

全体の処理の前後に *All が、各テストの前後に *Each が実行されていることが分かります。

  beforeAll

  beforeEach
  テスト1
  afterEach

  beforeEach
  テスト2
  afterEach
   
  afterAll

なお、 describe をネストした場合も問題なく動作します。

テストを実行する

全体のテストを行うには、以下のコマンドを実行します。

$ npm run test

package.jsontest が設定されている必要があります。

{
  "scripts": {
    "test": "jest",
  }
}

特定のテストを実行するには、 npm run test の後にテストファイル名を指定します。

$ npm run test sum.test.ts

Visual Studio Code の設定が完了していれば、アイコンをクリックするだけでテストを実行することができます(以下の場合は緑色のアイコン)。

テストカバレッジを取得する

Jest でもテストコードのカバレッジを取得することができます。

$ npm run coverage

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |      80 |      100 |      50 |      75 |                   
 sum.ts   |      80 |      100 |      50 |      75 | 6                 
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        1.458 s
Ran all test suites.

package.jsontestcoverage が設定されている必要があります。

{
  "scripts": {
    "test": "jest",
    "coverage": "jest --coverage",
  }
}

特定のテストのカバレッジも取得可能です。

$ npm run coverage sum.test.ts

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |      80 |      100 |      50 |      75 |                   
 sum.ts   |      80 |      100 |      50 |      75 | 6                 
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        0.764 s, estimated 2 s

テスト結果の検証

Jest では expect でテスト結果を検証できますが、テスト結果の型によって異なる検証を行うことができます。

プリミティブ型

JavaScript/TypeScript で組み込みで定義されている以下のデータ型を「プリミティブ型」と呼びます。

  • 文字列(String)
  • 数値(Number, BigInt)
  • 真偽値(Boolean)
  • undefined
  • null
  • Symbol

Jest のテスト結果がプリミティブ型の場合は、以下のように toBe で検証を行います。

import { test, expect } from '@jest/globals';

test('プリミティブ型の検証', () => {
  // 準備
  const expected = 3;

  // 実行
  const actual = sum(1, 2);

  // 検証
  expect(actual).toBe(expected);
});

プリミティブ型ではないテスト結果の場合は、 toEqual を使用します。

厳密な検証を行いたい場合は、toStrictEqual を使用します。

文字列

文字列のテスト結果の検証では基本的に toEqual を使用しますが、特定の文字列を含む場合や、正規表現による検証を行うこともできます。

また、 not を使用することで否定の検証を行うことができます。

import { describe, test, expect } from '@jest/globals';

describe('文字列のテスト結果を検証する', () => {
  const log1 =
    '10.144.33.211 - - [01/Aug/2023:12:34:56 +0900] "GET /index.html HTTP/1.1" 200 4523';
  const log2 =
    '10.52.36.125 - - [01/Aug/2023:12:35:01 +0900] "GET /images/logo.png HTTP/1.1" 200 12345';
  const log3 =
    '172.20.45.78 - - [01/Aug/2023:12:35:05 +0900] "POST /login.php HTTP/1.1" 302 5';

  test('文字列が一致する場合は true とする', () => {
    expect(log1).toEqual(log1);
  });

  test('文字列を含む場合は true とする', () => {
    expect(log1).toEqual(expect.stringContaining('10.144.33.211'));
    expect(log2).toContain('10.52.36.125');
  });

  test('文字列を含まない場合は true とする', () => {
    expect(log1).toEqual(expect.not.stringContaining('10.144.33.xxx'));
    expect(log2).not.toContain('10.52.36.xxx');
  });

  test('正規表現に一致する場合は true とする', () => {
    const regex = /10\.\d{1,3}\.\d{1,3}\.\d{1,3}/;
    expect(log1).toMatch(regex);
  });

  test('正規表現に一致しない場合は true とする', () => {
    const regex = /10\.\d{1,3}\.\d{1,3}\.\d{1,3}/;
    expect(log3).not.toMatch(regex);
  });
});

配列

配列のテスト結果の検証では基本的に toEqual を使用しますが、特定の配列要素を含む場合の検証を行うこともできます。

また、 not を使用することで否定の検証を行うことができます。

import { describe, test, expect } from '@jest/globals';

describe('配列のテスト結果を検証する', () => {
  const animals = ['dog', 'cat', 'mouse'];
  const objectAnimals = [
    { name: 'dog', age: 1 },
    { name: 'cat', age: 2 },
    { name: 'mouse', age: 3 },
  ];

  test('配列が一致する場合は true とする', () => {
    expect(animals).toEqual(animals);
  });

  test('配列を含む場合は true とする', () => {
    expect(animals).toEqual(expect.arrayContaining(['dog', 'cat']));
    expect(animals).toContain('mouse');
  });

  test('配列を含まない場合は true とする', () => {
    expect(animals).not.toEqual(expect.arrayContaining(['snake', 'pig']));
    expect(animals).not.toContain('snake');
  });

  test('オブジェクトの配列を含む場合は true とする', () => {
    expect(objectAnimals).toEqual(
      expect.arrayContaining([
        { name: 'dog', age: 1 },
        { name: 'cat', age: 2 },
      ])
    );
    expect(objectAnimals).toContainEqual({ name: 'dog', age: 1 });
  });

  test('オブジェクトの配列を含まない場合は true とする', () => {
    expect(objectAnimals).toEqual(
      expect.not.arrayContaining([
        { name: 'snake', age: 1 },
        { name: 'pig', age: 2 },
      ])
    );
    expect(objectAnimals).not.toContainEqual({ name: 'snake', age: 3 });
  });
});

オブジェクト

オブジェクトのテスト結果の検証では基本的に toEqual を使用しますが、特定のプロパティを含む場合の検証を行うこともできます。

また、 not を使用することで否定の検証を行うことができます。

import { describe, test, expect } from '@jest/globals';

describe('オブジェクトのテスト結果を検証する', () => {
  const user = {
    id: 1,
    name: 'Taro',
    email: 'email@example.com',
    address: {
      prefecture: 'Tokyo',
      city: 'Shinjuku',
    },
  };

  test('オブジェクトが一致する場合は true とする', () => {
    expect(user).toEqual(user);
  });

  test('プロパティが一致するか検証する', () => {
    expect(user).toHaveProperty('id', 1);
  });

  test('プロパティが一致しない検証する', () => {
    expect(user).not.toHaveProperty('id', 2);
  });

  test('ネストしたプロパティが一致するか検証する', () => {
    expect(user).toHaveProperty('address.prefecture', 'Tokyo');
  });

  test('ネストしたプロパティが一致しないか検証する', () => {
    expect(user).not.toHaveProperty('address.prefecture', 'Chiba');
  });

  test('複数プロパティが一致するか検証する', () => {
    expect(user).toEqual(
      expect.objectContaining({
        id: 1,
        address: {
          prefecture: 'Tokyo',
          city: 'Shinjuku',
        },
      })
    );
  });
});

エラー

エラーをスローする関数をテストすることも可能です。

注意点としては、実行時の記述方法がアロー関数になっていることです(厳密には実行は検証時に行われていると思われます)。

src/error.ts

export const sum = (a: number, b: number) => {
  if (a < 0 || b < 0) {
    throw new Error('引数は整数で指定してください');
  }
  return a + b;
};

test/error.test.ts

import { describe, test, expect } from '@jest/globals';
import { sum } from '../../../src/error';

describe('エラーのテスト', () => {
  test('引数が負の数の場合、エラーが発生すること', () => {
    // 準備
    const expected = new Error('引数は整数で指定してください');

    // 実行
    const actual = () => sum(-1, 2); // アロー関数になっている

    // 検証
    expect(actual).toThrowError(); // エラーがスローされることの検証
    expect(actual).toThrowError(Error); // エラーの型の検証
    expect(actual).toThrowError(expected); // エラーメッセージの検証
  });
});

コールバック処理

コールバック処理のテストを行うことも可能です。

ポイントは、test の第2引数に done を渡していて、コールバック関数の中で done() を実行することで同期を取っていることです。

src/callback.ts

export const timeoutCallback = (callback: any) => {
  setTimeout(callback, 1000, 'Hello, World!');
};

test/callback.test.ts

import { describe, test, expect } from '@jest/globals';
import { timeoutCallback } from '../../../src/callback';

describe('コールバックのテスト', () => {
  test('コールバックが呼び出されること', (done) => {
    // 準備
    const expected = 'Hello, World!';
    const callback = (actual: string) => {
      // 検証
      expect(actual).toBe(expected);
      done();
    };

    // 実行
    timeoutCallback(callback);
  });
});

非同期処理

非同期処理のテストを行うこともできます。

テストは、Promise によるテストと await よるテストがありますが、 Promise の場合は return必ず 必要なことにご注意ください。

src/async.ts

export const promiseFunc = (shouldResolve: boolean) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (shouldResolve) {
        resolve('Hello, World!');
      } else {
        reject('Error!');
      }
    }, 1000);
  });
};

test/async.test.ts

import { describe, test, expect } from '@jest/globals';
import { promiseFunc } from '../src/async';

describe('非同期処理のテスト', () => {
  test('Promise の処理で resolve されること', () => {
    // 準備
    const expected = 'Hello, World!';

    // 実行&検証, return で返すことで、テストが終了するまで待つ
    return promiseFunc(true).then((actual) => {
      expect(actual).toBe(expected);
    });
  });

  test('Promise の処理で reject されること', () => {
    // 準備
    const expected = 'Error!';

    // 実行&検証, return で返すことで、テストが終了するまで待つ
    return promiseFunc(false).catch((actual) => {
      expect(actual).toBe(expected);
    });
  });

  test('await で resolve されること', async () => {
    // 準備
    const expected = 'Hello, World!';

    // 実行&検証
    await expect(promiseFunc(true)).resolves.toBe(expected);
  });

  test('await で reject されること', async () => {
    // 準備
    const expected = 'Error!';

    // 実行&検証
    await expect(promiseFunc(false)).rejects.toBe(expected);
  });
});

モックテストを行う

Jest のモックは、正直とても分かりにくいです。そのために、Jestではじめるテスト入門(PEAKS) を手に取ったと言っても過言ではありません。

内部モジュールと外部モジュールのモック化の違い

Jest では、自分で作成した内部モジュールと、インストールした外部モジュールでは、モックの仕方が異なります。

// 内部モジュールのモック化
jest.mock('../src/mock');

// 外部モジュールのモック化
jest.mock('axios');

上記のように、内部モジュールの場合はモジュールファイルの パス を指定してモック化します。

モジュールファイルに複数の関数が存在してる場合、すべてモック化されることに注意してください。1つの関数だけモック化したい場合は、Spy を使用します。

外部モジュールの場合は、 モジュール名 を指定してモック化します。

なお、jest.mock の時点で該当モジュールはモック化されることにもご注意ください。

モックテストを作成する

まずは内部モジュールのモックテストを作成します。

rand() 関数は、生成する乱数による結果が異なるため、 今回は rand() 関数をモック化します。

src/mock.ts

export const rand = () => {
  return Math.floor(Math.random() * 10);
};

モックでは振る舞いを定義しますが、2通りの振る舞いの定義方法があります。

mockReturnValue で値を返す方法と、 mockImplementation の中で返す値を定義する方法です。

また、 rand()jest.Mock にキャストしてから振る舞いを定義していることにご注意ください。

キャストしなくてもモックの振る舞いを定義できることもあるようですが、モックの振る舞いを定義できない場合は jest.Mock にキャストする必要があります。

test/mock.test.ts

import { jest, describe, test, expect } from '@jest/globals';
import { rand } from '../src/mock';

// 内部モジュールのモック化
jest.mock('../src/mock');

describe('内部モジュールのモックテストを行う', () => {
  test('モックの戻り値を設定する, パターン1', () => {
    // 準備
    const expected = 1;
    (rand as jest.Mock).mockReturnValue(expected);

    // 実行
    const actual = rand();

    // 検証
    expect(actual).toBe(expected);
  });

  test('モックの戻り値を設定する, パターン2', () => {
    // 準備
    const expected = 1;
    (rand as jest.Mock).mockImplementation(() => expected);

    // 実行
    const actual = rand();

    // 検証
    expect(actual).toBe(expected);
  });
});

外部モジュールのモックテストを作成します。

以下は axios という外部モジュールを使用して、外部サーバーからユーザー情報を取得する関数になります。

src/outerMock.ts

import axios from 'axios';

export const getUser = async (url: string) => {
  const response = await axios.get(url);
  return response.data;
};

axios のモックの振る舞いは、 jest.Mocked にキャストしてから定義していることにご注意ください。

非同期の場合は、 mockImplementation による振る舞いの定義はうまく行かないことがあります。

また、getUser は非同期関数のため、 resolves で処理を完了させていることと、 expect の前に await が付いていることにご注意ください。

test/outerMock.test.ts

import { jest, describe, test, expect } from '@jest/globals';
import { getUser } from '../src/outerMock';
import axios from 'axios';

// 外部モジュールのモック化
jest.mock('axios');


describe('外部モジュールのモックテストを行う', () => {
  test('モックの戻り値を設定する', async () => {
    // 準備
    (axios as jest.Mocked<typeof axios>).get.mockResolvedValue({
      data: {
        name: 'Taro',
      },
    });

    // 検証
    await expect(getUser('https://example.com')).resolves.toEqual({
      name: 'Taro',
    });
  });
});

モックでエラーをスローする

モックテストでは、エラーをスローすることもできます。

mockImplementation 内で、エラーを生成しスローすることで実装できます。

test/mock.test.ts

import { jest, describe, test, expect } from '@jest/globals';
import { rand } from '../src/mock';

// 内部モジュールのモック化
jest.mock('../src/mock');

describe('内部モジュールのモックテストを行う', () => {
  test('モックでエラーをスローする', () => {
    // 準備
    const expected = 'error';
    (rand as jest.Mock).mockImplementation(() => {
      throw new Error('error');
    });

    // 実行
    const actual = () => rand();

    // 検証
    expect(actual).toThrowError(expected);
  });
});

getUser は非同期関数のため、 rejects で処理を失敗させていることと、 expect の前に await が付いていることにご注意ください。

test/outerMock.test.ts

import { jest, describe, test, expect } from '@jest/globals';
import { getUser } from '../src/outerMock';
import axios from 'axios';


// 外部モジュールのモック化
jest.mock('axios');


describe('外部モジュールのモックテストを行う', () => {
  test('モックでエラーをスローする', async () => {
    // 準備
    (axios as jest.Mocked<typeof axios>).get.mockRejectedValue({
      message: 'error',
    });

    // 検証
    await expect(getUser('https://example.com')).rejects.toEqual({
      message: 'error',
    });
  });
});

モックの引数と呼び出し回数を検証する

Jest のモック機能は、他言語の xUnit と比べると分かりづらい部分がありますが、モックの呼び出し検証もその1つかと思います。

一応、以下のように呼び出し検証は行うことはできます。

基本的にはこれで OK だとは思いますが、複数回モックが呼び出された場合に、1回目の引数の検証などには不十分です。

src/mock.ts

export const sum = (a: number, b: number): number => {
  return a + b;
};

test/mock.test.ts

import { jest, describe, test, expect } from '@jest/globals';
import { sum } from '../src/mock';

// 内部モジュールのモック化
jest.mock('../src/mock');

describe('内部モジュールのモックテストを行う', () => {
  test('モックの引数と呼び出し回数を検証する', () => {
    // 準備
    const expected = 3;
    (sum as jest.Mock).mockImplementation(() => expected);

    // 実行
    const actual = sum(1, 2);

    // 検証
    expect(actual).toBe(expected);
    expect(sum).toHaveBeenCalledWith(1, 2); // この引数でモックが呼び出されたか検証
    expect(sum).toHaveBeenCalledTimes(1); // モック呼び出し回数の検証
  });
});

但し、検証自体は可能です。

分かりづらくはありますが、 mock.calls[0] で1回目に呼び出した引数を検証することができます。2回目なら mock.calls[1] と言った具合に検証できます。

test/mock.test.ts

import { jest, describe, test, expect } from '@jest/globals';
import { sum } from '../src/mock';

// 内部モジュールのモック化
jest.mock('../src/mock');

describe('内部モジュールのモックテストを行う', () => {
  test('モックの引数と呼び出し回数を検証する', () => {
    // 準備
    const expected = 3;
    (sum as jest.Mock).mockImplementation(() => expected);

    // 実行
    const actual = sum(1, 2);

    // 検証
    expect(actual).toBe(expected);
    expect(sum).toHaveBeenCalledWith(1, 2); // この引数でモックが呼び出されたか検証
    expect(sum).toHaveBeenCalledTimes(1); // モック呼び出し回数の検証
    expect((sum as jest.Mock).mock.calls[0]).toEqual([1, 2]); // 1回目のモック呼び出し時の引数の検証
    expect((sum as jest.Mock).mock.results).toHaveLength(1); // 全体のモック呼び出し回数の検証
  });
});

Spy テストを行う

Spy とは、基本的には通常の関数の通りに動かすけれども、特定の機能だけモック化することができる機能のことです。

jest.spyOn にてモジュールと関数名を Spy の対象として宣言し、後ほど振る舞いを定義しています。

src/spy.ts

export const spyRand = () => {
  return Math.floor(Math.random() * 10);
};

test/spy.test.ts

import { jest, describe, test, expect, afterEach } from '@jest/globals';
import { SpiedFunction } from 'jest-mock';

// Spy を使用するためにモジュールを読み込む
import * as spyModule from '../src/spy';
jest.spyOn(spyModule, 'spyRand');


describe('Spy のテストを行う', () => {
  let spy: SpiedFunction<() => number>;

  afterEach(() => {
    spy.mockRestore(); // モック関数のプロパティと関数をクリアし、Spy の場合はオリジナル関数へ戻す
    // spy.mockClear(); // モック関数のプロパティをクリアする
    // spy.mockReset(); // モック関数のプロパティと関数をクリアする。オリジナル関数には戻らない。
  });

  test('Spy に振る舞いを定義する', () => {
    // 準備
    const expected = 1;
    spy = jest.spyOn(spyModule, 'spyRand').mockReturnValue(expected);

    // 実行
    const actual = spyModule.spyRand();

    // 検証
    expect(actual).toBe(expected);
    expect(spy).toHaveBeenCalled();
  });
});

パラメータ化テスト(Parameterized Test)

xUnit では、プログラム言語によって「パラメータ化テスト(Parameterized Test)」という機能があります。

「パラメータ化テスト」は、複数パターンの検証を行わなければならないけれども、入力値が異なるだけでテストプログラムを書くのは無駄な時などに活躍します。

Java の JUnit には、かなり柔軟に入力値を制御できる「パラメータ化テスト」の機能がありますが、Jest にも一部分ですがその機能があります。

Jest の「パラメータ化テスト」は以下のように使用します。

import { describe, test, expect } from '@jest/globals';

describe('Parameterized テストを実行する', () => {
  const animals = [
    { name: 'dog', age: 1, expected: 1 },
    { name: 'cat', age: 2, expected: 2 },
    { name: 'mouse', age: 3, expected: 3 },
  ];

  test.each(animals)('animal の年齢を比較する', ({ age, expected }) => {
    expect(age).toBe(expected);
  });
});

UI テストを行う

JavaScript のクリックイベントを検証する

UI テスト用のライブラリをインストールします。

$ npm install --save-dev jest-environment-jsdom

tsconfig.jsonesModuleInteroptrue に設定します。

tsconfig.json

{
  "compilerOptions": {
    "esModuleInterop": true
  }
}

JavaScript の処理を定義した HTML を作成します。

src/jsdom.html

<!DOCTYPE html>
<html>
  <head>
    <title>JS DOM Test</title>
    <meta charset="utf-8" />
  </head>
  <body>
    <h1>JS DOM Test</h1>
    <button id="button">Click Me</button>
    <div id="message"></div>
    <script>
      const button = document.getElementById('button');
      const message = document.getElementById('message');

      button.addEventListener('click', function () {
        message.textContent = 'Hello, world!';
      });
    </script>
  </body>
</html>

以下のように、テストを作成します。

import { describe, test, expect, beforeEach } from '@jest/globals';
import { JSDOM, DOMWindow } from 'jsdom';
import fs from 'fs';
import path from 'path';

const html = fs.readFileSync(
  path.resolve(__dirname, '../src/jsdom.html'),
  'utf8'
);

describe('JSDOM を使って HTML を読み込む', () => {
  let window: DOMWindow;
  let document: Document;
  let element: HTMLElement;
  let button: HTMLElement;

  beforeEach(() => {
    window = new JSDOM(html, { runScripts: 'dangerously' }).window;
    document = window.document;
    element = document.querySelector('#message') as HTMLElement;
    button = document.querySelector('#button') as HTMLElement;
  });

  test('初期表示状態のメッセージを検証する', () => {
    expect(element.textContent).toEqual('');
  });

  test('ボタンクリック後のメッセージを検証する', () => {
    button.click();
    expect(element.textContent).toEqual('Hello, world!');
  });
});

React のクリックイベントを検証する

React のテスト用ライブラリをインストールします。

$ npm install --save-dev @testing-library/react
$ npm install --save-dev react-test-renderer @types/react-test-renderer

Button コンポーネントを作成します。クリックすると、カウントが加算されます。

src/Button.tsx

import { useState } from 'react';

export const Button = () => {
  const [count, setCount] = useState(0);

  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
};

以下のように、テストを作成します。

@jest-environment jsdom はコメントですが、実行環境を定義しているので指定が必要です。

test/Button.test.tsx

/**
 * @jest-environment jsdom
 */
import { describe, test, expect } from '@jest/globals';

import { render, fireEvent } from '@testing-library/react';
import { Button } from '../src/Button';
import React from 'react';

describe('Button のイベントを検証する', () => {
  test('Button を1回クリックした場合の検証', () => {
    // 準備
    const button = render(<Button />);

    // 実行
    fireEvent.click(button.getByText('Count: 0'));

    // 確認
    expect(button.getByText('Count: 1')).toBeTruthy;
  });

  test('Button を2回クリックした場合の検証', () => {
    // 準備
    const button = render(<Button />);

    // 実行
    fireEvent.click(button.getByText('Count: 0'));
    fireEvent.click(button.getByText('Count: 1'));

    // 確認
    expect(button.getByText('Count: 2')).toBeTruthy;
  });
});

React Hooks(useEffect) を検証する

React のテスト用ライブラリをインストールします。

$ npm install --save-dev @testing-library/react
$ npm install --save-dev react-test-renderer @types/react-test-renderer

Hooks コンポーネントを作成します。読み込み時に1回メッセージを書き換えます。

src/Hooks.tsx

import { useState, useEffect } from 'react';

export const Hooks = () => {
  const [message, setMessage] = useState('');

  useEffect(() => {
    setMessage('Hello, world!');
  }, []);

  return <div>{message}</div>;
};

以下のように、テストを作成します。

@jest-environment jsdom はコメントですが、実行環境を定義しているので指定が必要です。

test/Hooks.test.tsx

/**
 * @jest-environment jsdom
 */
import { describe, test, expect } from '@jest/globals';
import { render } from '@testing-library/react';
import { Hooks } from '../src/Hooks';
import React from 'react';

describe('React Hooks を検証する', () => {
  test('useEffect が実行されているか検証する', () => {
    // 準備&実行
    const hooks = render(<Hooks />);

    // 確認
    expect(hooks.getByText('Hello, world!')).toBeTruthy;
  });
});

Tips

AAA パターンを使用する

AAA パターンとは、以下の3段階に分けてテストを実装するパターンのことです。

  • 準備(Arrange) フェーズ
  • 実行(Act) フェーズ
  • 確認(Assert) フェーズ

AAA パターンに従うことで、テストコードの構造が統一されてテストコードが読みやすくなり、テストコードの維持管理工数の削減が期待できます。

具体的には、以下のようになります。

import { jest, describe, test, expect, afterEach } from '@jest/globals';
import { rand } from '../src/mock';

test('モックの戻り値を設定する', () => {
  // 準備
  const expected = 1;
  (rand as jest.Mock).mockReturnValue(expected);

  // 実行
  const actual = rand();

  // 確認
  expect(actual).toBe(expected);
});

プロジェクト内でテストの用語や基準を明確にする

実際に開発プロジェクトでテストコードを書いていると、人により用語がバラバラだったり、考え方が異なることでテストコードの開発や維持に支障が出ることがあります。

細かいところで言えば、テストの実行結果は actual なのか result なのか、テストの期待値は expected なのか expect なのかなどがあります。

大きなところで言えば、テストコードのカバレッジはどこまで求めるかなどがあります。

実際には通ることのない組み合わせのテストを、カバレッジを上げるためだけにすべて実装するのは非現実的です。ですが、実際にはよくある光景だと思います。

AAA パターン もそうですが、ある程度妥当なレベルでテストについて用語や基準を明確にすると、みんな幸せなのではないでしょうか。

コードカバレッジ 100% を求めない

コードカバレッジ 100% というのはとてもすばらしいと感じるかもしれませんが、実際には労力が高くなりすぎて得られる効果が少なくなります。

Martin Fowler 氏は、以下のように述べています。

コードカバレッジは、コードのテストされていない部分を発見するための有用なツールである。ただテスト自体がどれだけ良いかという指標としては、テストカバレッジはほとんど役に立たない。

思慮深くテストを実施すれば、テストカバレッジはおそらく80%台後半か90%台になるだろう。100%は信用ならない。カバレッジの数字ばっかり気にして、自分が何をやっているかわかっていない人間のいる臭いがする。

テストカバレッジ - Martin Fowler's Bliki (ja)

当たり前のことですが、テストコードをいくら大量に書いてもビジネスに貢献することはできません。テストコードがお金になる訳ではないからです。

品質を担保するために十分なテストが書かれていれば、コードカバレッジ 100% を求める理由はなさそうです。

おわりに

「Vite+Jest 逆引きリファレンス in TypeScript」ということで、情報をまとめてみました。

気が向いたら、内容を追記するかもしれません。

サンプルコードは、GitHub のリポジトリに掲載してあります。

typescript-vite-jest

参考文献