コンテンツにスキップ

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";

// テスト対象の関数
export async function fetchUser(id: string) {
  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";

// axiosクライアントのモック
vi.mock("axios");

// テストスイート
describe("fetchUser", async () => {
  // リクエストのパラメーターに関するテストデータ
  const userId = "1";

  // 正常系テストケース
  test("success", async () => {
    // レスポンスに関するテストデータ
    const responseBody = {
      id: "1",
      name: "Taro",
    };

    // axiosクライアントを実行する場合、モックに差し替える
    // axiosクライアントのモックが一度だけデータを返却するように設定
    vi.mocked(axios, true).get.mockResolvedValueOnce({
      data: responseBody,
      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("foo failure", async () => {
    // axiosクライアントを実行する場合、モックに差し替える
    // 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");
  });
});


外部とのパブリッシュ/サブスクライブ

▼ テスト対象の関数

// テスト対象の関数
export async function publishMessage(url: string) {
  try {
    await publishMessageToEmqx(url, "$share/test-topic", "Hello EMQX");
    return "success";
  } catch (err) {
    throw new Error("failed to publish message");
  }
}

// テスト対象の関数
export async function subscribeMessage(url: string) {
  try {
    await subscribeMessageToEmqx(url, "$share/test-topic", "Hello EMQX");
    return "success";
  } catch (err) {
    throw new Error("failed to subscribe message");
  }
}

async function publishMessageToEmqx(
  url: string,
  topic: string,
  message: string,
): Promise<void> {
  // ...

  // ここでEMQXとの通信を実行するとする

  // ...

  console.log(`topic=${topic}, message=${message}`);
}

async function subscribeMessageToEmqx(
  url: string,
  topic: string,
  message: string,
): Promise<void> {
  // ...

  // ここでEMQXとの通信を実行するとする

  // ...

  console.log(`topic=${topic}, message=${message}`);
}

▼ テストコード

import {describe, test, expect, vi} from "vitest";
import {publishMessage, subscribeMessage} from "./emqx";
import {publishMessageToEmqx, subscribeMessageToEmqx} from "../emqx";

describe("publishMessage", () => {
  // 実際にパブリッシュを行わないように、関数をモック化
  vi.mock("../emqx", () => ({
    publishMessageToEmqx: vi.fn(),
  }));

  const url = "mqtt://localhost:1883";

  // 正常系テストケース
  test("should return success when message is published", async () => {
    vi.mocked(publishMessageToEmqx).mockResolvedValueOnce(undefined);
    const result = await publishMessage(url);

    // publishMessageによる送信処理が正常に完了したことを比較検証する
    expect(result).toBe("success");
    // 内部でpublishMessageToEmqxが実行されていることを比較検証する
    expect(publishMessageToEmqx).toHaveBeenCalledWith(
      url,
      "$share/test-topic",
      "Hello EMQX",
    );
  });

  // 異常系テストケース
  test("should throw error when publish is failed", async () => {
    vi.mocked(publishMessageToEmqx).mockRejectedValueOnce(
      new Error("network error"),
    );

    // publishMessageが例外をスローすることを比較検証する
    await expect(publishMessage(url)).rejects.toThrow("failed to send message");
    // 内部でpublishMessageToEmqxが実行されていることを比較検証する
    expect(publishMessageToEmqx).toHaveBeenCalledWith(
      url,
      "$share/test-topic",
      "Hello EMQX",
    );
  });
});

describe("subscribeMessage", () => {
  // 実際にサブスクライブを行わないように、関数をモック化
  vi.mock("../emqx", () => ({
    subscribeMessageToEmqx: vi.fn(),
  }));

  const url = "mqtt://localhost:1883";

  // 正常系テストケース
  test("should return success when message is subscribed", async () => {
    vi.mocked(subscribeMessageToEmqx).mockResolvedValueOnce(undefined);
    const result = await subscribeMessage(url);

    // subscribeMessageによる受信処理が正常に完了したことを比較検証する
    expect(result).toBe("success");
    // 内部でsubscribeMessageToEmqxが実行されていることを比較検証する
    expect(subscribeMessageToEmqx).toHaveBeenCalledWith(
      url,
      "$share/test-topic",
      "Hello EMQX",
    );
  });

  // 異常系テストケース
  test("should throw error when subscribe is failed", async () => {
    vi.mocked(subscribeMessageToEmqx).mockRejectedValueOnce(
      new Error("network error"),
    );

    // subscribeMessageが例外をスローすることを比較検証する
    await expect(subscribeMessage(url)).rejects.toThrow(
      "failed to subscribe message",
    );
    // 内部でsubscribeMessageToEmqxが実行されていることを比較検証する
    expect(subscribeMessageToEmqx).toHaveBeenCalledWith(
      url,
      "$share/test-topic",
      "Hello EMQX",
    );
  });
});


エラーの中身を詳細に検証

▼ テスト対象の関数

// 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";

// axiosクライアントのモック
vi.mock("axios");

describe("fetchUser", () => {
  // リクエストのパラメーターに関するテストデータ
  const userId = "1";

  // 異常系テストケース
  test("should throw FooError with correct name, message and code", async () => {
    try {
      // 内部で実行されるaxiosクライアントはモックであり、mockResolvedValueOnceで設定した値を返却する
      await fetchUser(userId);
      // fail関数を実行しないといけない
      expect.fail("should thrown an error");
    } catch (e) {
      const error = e as FooError;
      expect(error.name).toBe("FooError");
      expect(error.message).toMatch(/error/);
      expect(error.code).toBe(500);
    }
  });
});