Vitest@JavaScriptユニットテスト¶
はじめに¶
本サイトにつきまして、以下をご認識のほど宜しくお願いいたします。
01. Vitestとは¶
ユニットテストと機能テストの実施に必要な機能を提供し、加えてテストを実施する。
02. ユニットテストの設計¶
テストスイートの設計¶
describe 関数を使用して、テストスイートを定義できる。
テストスイートは階層的に定義でき、最大2階層まではあってもよい。
describe("<パブリックな関数名>", async () => {
describe("<テストスイートの種別>", async () => {
test("<テストケース名>", async () => {
}
}
テストケースの設計¶
▼ 正常系、異常系、境界値¶
正常系、異常系、境界値のテストケースをテストスイートの種別におく。
describe("<パブリックな関数名>", async () => {
describe("<テストスイートの種別>", async () => {
// 正常系
test("<テストケース名>", async () => {
}
// 異常系
test("<テストケース名>", async () => {
}
// 境界値
test("<テストケース名>", async () => {
}
}
}
describe("<パブリックな関数名>", async () => {
describe("<テストスイートの種別>", async () => {
test("正常系:<テストケース名>", async () => {
}
test("異常系:<テストケース名>", async () => {
}
test("境界値:<テストケース名>", async () => {
}
}
}
▼ 変数名¶
例えば、baseFoo 変数を定義し、これのプロパティを各テストケースで継承する。
import axios from "axios";
import {describe, test, expect, vi} from "vitest";
describe("<パブリックな関数名>", () => {
vi.mock("axios");
const baseFoo = {
url: "/users",
};
describe("<テストスイートの種別>", () => {
// 正常系
test("ユーザー一覧を取得できる", async () => {
vi.mocked(axios, true).get.mockResolvedValueOnce({
data: {
name: "Taro",
},
status: 200,
});
const res = await axios.request({
// プロパティを継承する
...baseFoo,
params: {
limit: 10,
},
});
expect(res.status).toBe(200);
expect(res.data[0].name).toBe("Taro");
});
});
});
▼ テストケースの命名¶
test 関数と it 関数は、振る舞いの主語(濁した主語)としての it または test として見ることができる。
つぎのように命名すると、it または test がテストケース名の主語になって、振る舞いに着目してる感がでる。
例えば、<期待される動作> when <入力内容> で命名する。
describe("<テストスイート>", () => {
// テストケース
test("should return user when id is valid", () => {});
});
▼ 事前処理と事後処理¶
afterEach 関数はモックの削除のために必須である。
beforeEach 関数はユニットテストの外に影響がある処理(例:DB接続、ファイル操作、DOM操作、グローバル変数の変更)が必要な場合に使用する。
import {describe, it, expect, beforeEach, afterEach} from "vitest";
import {prisma} from "~/database/prisma.server";
// ユニットテストの事前処理
beforeEach(async () => {
// ユニットテストの外に影響がある処理
// 例:DB接続、ファイル操作、DOM操作、グローバル変数の変更など
});
// ユニットテストの事後処理
afterEach(async () => {
// モックに関するすべての設定を削除する
vi.clearAllMocks();
});
テストケースの処理¶
▼ Arrange-Act-Assertパターン¶
Arrange-Act-Assertパターンを採用するとよい。
import {test, expect, vi} from "vitest";
import axios from "axios";
import {fetchUser} from "./fetchUser";
// ユニットテストの事後処理
afterEach(async () => {
// モックに関するすべての設定を削除する
vi.resetAllMocks();
});
// テストスイート
describe("fetchUser", async () => {
vi.mock("axios");
const userId = "1";
// 正常系テストケース
test("should return id and name when success", async () => {
// Arrange
// テストを準備する
vi.mocked(axios, true).get.mockResolvedValueOnce({
data: {
id: "1",
name: "Taro",
executionTime: 123,
startAtTimestamp: "2024-06-01T12:00:00.000Z",
},
status: 200,
});
// Act
// 処理を実行する
const user = await fetchUser(userId);
// Assert
// 結果を評価する
expect(user.getId()).toBe("1");
expect(user.getName()).toBe("Taro");
expect(user.executionTime).toBeGreaterThan(0);
expect(new Date(user.startAtTimestamp).toISOString()).toBe(
user.startAtTimestamp,
);
});
// 異常系テストケース
test("should throw error when failure", async () => {
// Arrange
// テストを準備する
vi.mocked(axios, true).get.mockRejectedValueOnce(
new Error("Network Error"),
);
// Act
// 処理を実行する
const result = fetchUser(userId);
// Assert
// 結果を評価する
await expect(result).rejects.toBeInstanceOf(Error);
await expect(result).rejects.toThrow("Network Error");
});
});
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. テストコード例¶
ユニットテストとしてDBへのCRUDを検証する¶
事前処理としてDBデータを挿入し、事後処理としてDBデータを掃除する。
import {describe, it, expect, beforeEach, afterEach} from "vitest";
import {prisma} from "~/database/prisma.server";
// ユニットテストの事前処理
beforeEach(async () => {
await prisma.foo
.create
// fooテーブルにDBデータを挿入
();
await prisma.bar
.create
// fooテーブルの子にあたるbarテーブルにDBデータを挿入
();
});
// ユニットテストの事後処理
afterEach(async () => {
// 逆順でDBデータを掃除する
await prisma.bar.deleteMany();
await prisma.foo.deleteMany();
});
// ここでCRUDに関するユニットテスト
入力値に対して結果が正しいかを検証する¶
▼ テスト対象の関数¶
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";
// ユニットテストの事後処理
afterEach(async () => {
// モックに関するすべての設定を削除する
vi.resetAllMocks();
});
// テストスイート
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");
});
});
バリデーションの結果が正しいかを検証する¶
▼ テスト対象の関数¶
import { withZod } from "@remix-validated-form/with-zod";
import { validationError } from "remix-validated-form";
import { z } from "zod";
import { getUser} from "~/models/user.server";
import type { AuthUser } from "~/auth";
export const validateForm = async ({
formData,
}: {
formData: FormData;
authUser: Pick<AuthUser, "accountId">;
}>;
}) => {
const accountId = formData.accountId!;
const sameAcccountIdUser = await getUser({
accountId: accountId
});
// 作成したいアカウントIDがすでに存在する場合、バリデーションエラーとする
if (sameAcccountIdUser) {
return {
error: validationError({
formId: formData.formId,
fieldErrors: {
organizationRegionName: "A account id is already exist",
},
}),
};
}
// 作成したいアカウントIDが存在しない場合、フォーム入力値を返却する
return { formData: formData };
};
▼ テストコード¶
import {describe, expect} from "vitest";
import {validateForm} from "~/components/validators/UserValidator";
import {getUser} from "~/models/user.server";
describe("OrganizationRegionFormValidator", () => {
// 正常系
test("作成したいアカウントIDと同じアカウントIDが存在しない場合、バリデーション済みのフォーム入力値を返却するはず", async () => {
// Arrange
// 依存先の関数をモックとし、モックが返却するデータを設定する
vi.mocked(getUser).mockResolvedValueOnce(null);
const formData = new FormData();
formData.set("accountId", "12345");
// Act
const result = await validateForm({
formData,
});
// Assert
expect(result).toHaveProperty("formData");
expect(result.formData).toBeDefined();
expect(result.formData).toMatchObject({
accountId: "12345",
});
});
// 異常系
test("作成したいアカウントIDと同じアカウントIDが存在する場合、バリデーションエラーを含む結果を返却するはず", async () => {
// Arrange
// 依存先の関数をモックとし、モックが返却するデータを設定する
vi.mocked(getUser).mockResolvedValueOnce(null);
const formData = new FormData();
formData.set("accountId", "12345");
// Act
const result = await validateForm({
formData,
});
// Assert
if (!(result.error instanceof Response)) {
// UserValidatorがResponse型以外を返却する場合、想定外なのでテストを失敗させる
expect.fail("should return result of Response");
}
expect(result).toHaveProperty("error");
expect(result.error).toBeDefined();
const body = await result.error.json();
expect(body).toMatchObject({
fieldErrors: {
accountId: "A accound id is already exist",
},
});
});
});
エラーの中身が正しいかを検証する¶
▼ テスト対象の関数¶
// 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";
// ユニットテストの事後処理
afterEach(async () => {
// モックに関するすべての設定を削除する
vi.resetAllMocks();
});
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 (!(error instanceof FooError)) {
// FooErrorではない場合、想定外なのでテストを失敗させる
expect.fail("should throw FooError");
}
expect(error.message).toMatch(/error/);
expect(error.code).toBe(500);
}
});
});
nullを持つオプショナル型が正しいかを検証する¶
▼ テスト対象のコード¶
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";
// ユニットテストの事後処理
afterEach(async () => {
// モックに関するすべての設定を削除する
vi.resetAllMocks();
});
describe("User optional property behavior", () => {
// 依存先であるaxiosクライアントのモック
vi.mock("axios");
// リクエストのパラメーターに関する初期データ
const userId = "1";
test("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);
});
test("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");
});
});
unknown型の値が正しいかを検証する¶
▼ テスト対象のコード¶
import axios from "axios";
export type User = {
name: string;
// 外部サービス(Twitter / GitHub / Googleなど)によって構造が異なるため unknown 型とする
social?: unknown;
};
// テスト対象の関数
export async function fetchUser(id: string): Promise<User> {
const res = await axios.get(`/api/users/${id}`);
return res.data;
}
▼ テストコード¶
import {describe, it, expect, vi} from "vitest";
import axios from "axios";
import {fetchUser} from "./fetchUser";
// ユニットテストの事後処理
afterEach(async () => {
// モックに関するすべての設定を削除する
vi.resetAllMocks();
});
describe("User.social unknown property behavior", () => {
// 依存先であるaxiosクライアントのモック
vi.mock("axios");
// リクエストのパラメーターに関する初期データ
const userId = "1";
test("should allow validation when unknown property (object) is defined", async () => {
// axiosの型をモックに認識させる。オブジェクト全体をモックにする場合、trueにする。
// 依存先であるaxiosクライアントのモックが一度だけデータを返却するように設定
vi.mocked(axios, true).get.mockResolvedValueOnce({
data: {
name: "Alice",
social: {twitter: "@alice_dev", github: "alice"},
},
status: 200,
});
// 関数をテスト
// 内部で実行されるaxiosクライアントはモックであり、mockResolvedValueOnceで設定した値を返却する
const user = await fetchUser(userId);
// unknown型の値を検証する
expect(user).toHaveProperty(
"social.twitter",
expect.stringMatching(/^@[\w_]+$/),
);
expect(user).toHaveProperty(
"social.github",
expect.stringMatching(/^[a-zA-Z0-9_-]+$/),
);
// 値を検証する
expect(user.name).toBe("Alice");
});
test("should allow validation when unknown 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);
// 値が存在しない場合で、unknown型の値を検証する
expect(user).not.toHaveProperty("social");
// 値を検証する
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}`;
}
▼ テストコード¶
プライベート関数に仮の返却値を返却させるために、spyOn 関数を使用している。
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";
// ユニットテストの事後処理
afterEach(async () => {
// モックに関するすべての設定を削除する
vi.resetAllMocks();
});
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"});
});
});