コンテンツにスキップ

Vitest@JavaScriptユニットテスト

はじめに

本サイトにつきまして、以下をご認識のほど宜しくお願いいたします。


01. Vitestとは

ユニットテストと機能テストの実施に必要な機能を提供し、加えてテストを実施する。


02. セットアップ

plugin

import {defineConfig} from "vitest/config";

export default defineConfig({
  plugins: [],
});


test

▼ env

import {defineConfig} from "vitest/config";
import * as dotenv from "dotenv";

export default defineConfig({
  test: {
    // .env.testファイルを読み込む
    env: dotenv.config({path: ".env.test"}).parsed,
  },
});

▼ exclude

import {defineConfig} from "vitest/config";

export default defineConfig({
  test: {
    exclude: [],
  },
});

▼ globals

import {defineConfig} from "vitest/config";

export default defineConfig({
  test: {
    globals: true,
  },
});

▼ include

import {defineConfig} from "vitest/config";

export default defineConfig({
  test: {
    include: ["./src/**/*.spec.ts"],
    globals: true,
    setupFiles: ["./vitest.setup.ts"],
  },
});

▼ setupFiles

import {defineConfig} from "vitest/config";

export default defineConfig({
  test: {
    setupFiles: ["./vitest.setup.ts"],
  },
});


03. 対応言語

TypeScript

▼ 注意点

VitestはTypeScriptを直接トランスパイルするため、型検証を実施しない。

例えば、TypeScriptのテストコードで、関数に渡す型がまちがってるのにエラーにならない。

Vitestの思想では、テストコードの型検証はエディタやビルド時に実施するべきであり、テストコードの実行時には型検証は済んでいるものという考えがある。


04. テストコード例

入力値に対して結果が正しいかを検証する

▼ テスト対象の関数

import axios from "axios";

type User = {
  id: string;
  name: string;
  executionTime: number;
  startAtTimestamp: string;
};

// テスト対象の関数
export async function fetchUser(id: string): Promise<User> {
  const res = await axios.get(`/api/users/${id}`);
  return res.data;
}

▼ テストコード

import {test, expect, vi} from "vitest";
import axios from "axios";
import {fetchUser} from "./fetchUser";

// テストスイート
describe("fetchUser", async () => {
  // axiosクライアントのモック
  vi.mock("axios");

  // リクエストのパラメーターに関するテストデータ
  const userId = "1";

  // 正常系テストケース
  test("should return id and name when success", async () => {
    // axiosの型をモックに認識させる。オブジェクト全体をモックにする場合、trueにする。
    // axiosクライアントのモックが一度だけデータを返却するように設定
    vi.mocked(axios, true).get.mockResolvedValueOnce({
      data: {
        id: "1",
        name: "Taro",
        executionTime: 123,
        startAtTimestamp: "2024-06-01T12:00:00.000Z",
      },
      status: 200,
    });

    // 関数をテスト
    // 内部で実行されるaxiosクライアントはモックであり、mockResolvedValueOnceで設定した値を返却する
    const user = await fetchUser(userId);

    // 実際値と期待値を比較検証
    // toBe関数などでオブジェクトのフィールドを1つずつ照合する (toStrictEqual関数などでオブジェクトをひとまとめに照合しない)
    expect(user.getId()).toBe("1");
    expect(user.getName()).toBe("Taro");
    // 実行時間は0秒より大きくなる
    expect(user.executionTime).toBeGreaterThan(0);
    // 処理実行の開始時刻も返却できるとする
    // 実際値をData形式に一度変換し、再び元のISO形式に戻しても元の値と一致することを比較検証する
    // 期待値は固定値じゃないのが不思議であるが、これが適切なテスト方法である
    expect(new Date(user.startAtTimestamp).toISOString()).toBe(
      user.startAtTimestamp,
    );
  });

  // 異常系テストケース
  test("should throw error when failure", async () => {
    // axiosの型をモックに認識させる。オブジェクト全体をモックにする場合、trueにする。
    // axiosクライアントのモックがエラーを一度だけ返すように設定
    vi.mocked(axios, true).get.mockRejectedValueOnce(
      new Error("Network Error"),
    );

    // Error型を比較検証
    // 内部で実行されるaxiosクライアントはモックであり、mockResolvedValueOnceで設定した値を返却する
    await expect(fetchUser(userId)).rejects.toBeInstanceOf(Error);

    // await宣言で完了を待つようにしないと、そのままテスト処理が終わってしまう
    // 実際値と期待値を比較検証
    await expect(
      // 関数をテスト
      // 関数の結果をVitestに直接渡さないと、テストコードが例外で停止してしまう
      // rejects.toThrow関数で照合する
      fetchUser(userId),
    ).rejects.toThrow("Network Error");
  });
});



エラーの中身が正しいかを検証する

▼ テスト対象の関数

// Errorオブジェクトを継承した独自のErrorオブジェクト
class FooError extends Error {
  private _name: string;
  private _code: number;

  constructor(message: string, code: number) {
    // message変数はErrorオブジェクトに渡す
    super(message);
    this._name = "FooError";
    this._code = code;
  }
}

▼ テストコード

エラーの中身を詳細に検証したい場合、rejects.toThrow("エラー文")だけでは比較検証できることが少ない。

import {test, expect, vi} from "vitest";
import axios from "axios";
import {fetchUser} from "./fetchUser";

describe("fetchUser", () => {
  // axiosクライアントのモック
  vi.mock("axios");

  // リクエストのパラメーターに関するテストデータ
  const userId = "1";

  // 異常系テストケース
  test("should throw FooError with correct name, message and code", async () => {
    try {
      // 内部で実行されるaxiosクライアントはモックであり、mockResolvedValueOnceで設定した値を返却する
      await fetchUser(userId);
      // Errorを投げない場合、想定外なのでテストを失敗させる
      expect.fail("should thrown an error");
    } catch (error) {
      if (!(e instanceof FooError)) {
        // FooErrorではない場合、想定外なのでテストを失敗させる
        expect.fail("should throw FooError");
      }
      expect(error.message).toMatch(/error/);
      expect(error.code).toBe(500);
    }
  });
});


オプショナル型が正しいかを検証する

▼ テスト対象のコード

import axios from "axios";

type User = {
  name: string;
  age?: number;
};

// テスト対象の関数
export async function fetchUser(id: string): Promise<User> {
  const res = await axios.get(`/api/users/${id}`);
  return res.data;
}

▼ テストコード

toBeDefined関数とtoBeUndefined関数を使用し、オプショナル型を事前に検証した上で、値を検証するとよい。

また、プロパティがある場合をテストする時には、非nullアサーションが必要である。

import {describe, it, expect} from "vitest";
import axios from "axios";
import {fetchUser} from "./fetchUser";

describe("User optional property behavior", () => {
  // axiosクライアントのモック
  vi.mock("axios");

  // リクエストのパラメーターに関するテストデータ
  const userId = "1";

  it("should allow validation when optional property is defined", async () => {
    // axiosの型をモックに認識させる。オブジェクト全体をモックにする場合、trueにする。
    // axiosクライアントのモックが一度だけデータを返却するように設定
    vi.mocked(axios, true).get.mockResolvedValueOnce({
      data: {name: "Alice", age: 25},
      status: 200,
    });

    // 関数をテスト
    // 内部で実行されるaxiosクライアントはモックであり、mockResolvedValueOnceで設定した値を返却する
    const user = await fetchUser(userId);

    // オプショナル型を検証する
    expect(user.age).toBeDefined();

    // 値を検証する
    expect(user.name).toBe("Alice");
    // 非nullアサーションで明示しつつ、値を検証する
    expect(user.age!).toBe(25);
  });

  it("should allow validation when optional property is undefined", async () => {
    // axiosの型をモックに認識させる。オブジェクト全体をモックにする場合、trueにする。
    // axiosクライアントのモックが一度だけデータを返却するように設定
    vi.mocked(axios, true).get.mockResolvedValueOnce({
      data: {name: "Bob"},
      status: 200,
    });

    // 関数をテスト
    // 内部で実行されるaxiosクライアントはモックであり、mockResolvedValueOnceで設定した値を返却する
    const user = await fetchUser(userId);

    // オプショナル型を検証する
    expect(user.age).toBeUndefined();
    // 値を検証する
    expect(user.name).toBe("Bob");
  });
});


異なるファイルにある関数同士で、内部で関数が呼ばれたかを検証する

▼ テスト対象のコード

これらの関数は別のファイルにある前提である。

// utils.ts
export function doInternalWork(value: number): number {
  return value * 2;
}
// task.ts
export function runTask(num: number): string {
  const result = doInternalWork(num);
  return `result=${result}`;
}

▼ テストコード

import {describe, test, expect, vi} from "vitest";
import {runTask} from "./task";
import * as utils from "./utils";

describe("runTask", () => {
  test("should call doInternalWork internally", () => {
    // spyOn関数を使用し、内部関数名を指定する
    const spy = vi.spyOn(utils, "doInternalWork").mockReturnValueOnce(999);

    // runTask関数を実行する
    const output = runTask(123);

    // utilsの内部でdoInternalWorkが1回呼ばれたかを検証する
    expect(spy).toHaveBeenCalled();
    // utilsの内部でdoInternalWorkに渡された引数を検証する
    expect(spy).toHaveBeenCalledWith(123);

    expect(output).toBe("result=999");
  });
});


独自のクライアントクラスをモック化する

▼ テスト対象の関数

axios関数のようなクライアントではなく、独自のクライアントクラスがあるとする。

// httpClient.ts
export class HttpClient {
  constructor(private baseUrl: string) {}

  async get(path: string): Promise<any> {
    return fetch(`${this.baseUrl}${path}`).then((res) => res.json());
  }
}
// userService.ts
import {HttpClient} from "./httpClient";

export async function fetchUser() {
  const client = new HttpClient("https://api.example.com");
  const data = await client.get("/user");
  return data;
}

▼ テストコード

import {describe, test, expect, vi} from "vitest";
import {fetchUser} from "./userService";
import {HttpClient} from "./httpClient";

describe("fetchUser", () => {
  // クライアントクラス全体をモック化する
  vi.mock("./httpClient");

  test("should mock HttpClient and return its instance", async () => {
    // モッククラスのインスタンスを定義する
    const mockInstance = {
      get: vi.fn().mockResolvedValueOnce({id: 1, name: "Alice"}),
    };

    // モッククラスがモックインスタンスを返却する
    vi.mocked(HttpClient).mockReturnValueOnce(mockInstance as any);

    const result = await fetchUser();

    expect(mockInstance.get).toHaveBeenCalledWith("/user");

    expect(result).toEqual({id: 1, name: "Alice"});
  });
});