TypeScript テスト完全ガイド
Vitest, Jest, 型テスト(tsd / expect-type)を使い、ランタイムの振る舞いと型の正しさの両方を検証する
TypeScript テスト完全ガイド
Vitest, Jest, 型テスト(tsd / expect-type)を使い、ランタイムの振る舞いと型の正しさの両方を検証する
この章で学ぶこと
- Vitest によるテスト -- Vite エコシステムと統合した高速テストランナーのセットアップと活用
- Jest + TypeScript -- ts-jest / @swc/jest の設定と、既存プロジェクトでの Jest 運用
- 型テスト --
expectTypeOf(Vitest 組込み)や tsd を使い、ライブラリの型定義が正しいことを検証する技法 - テスト設計パターン -- AAA パターン、テストダブル、統合テスト、E2E テストの構成方法
- テストのパフォーマンスと保守性 -- 大規模プロジェクトでのテスト戦略と最適化手法
前提知識
このガイドを読む前に、以下の知識があると理解が深まります:
- 基本的なプログラミングの知識
- 関連する基礎概念の理解
- TypeScript ビルドツール完全ガイド の内容を理解していること
1. Vitest
1-1. セットアップ
Vitest のアーキテクチャ:
vite.config.ts
|
v
+----------+ +---------+ +----------+
| テスト | --> | Vite | --> | esbuild |
| ファイル | | (変換) | | (高速TS) |
+----------+ +---------+ +----------+
|
v
+----------+
| テスト |
| 実行結果 |
+----------+
Vitest の特徴:
- Vite と設定を共有(resolve.alias, plugins など)
- esbuild によるトランスパイルで高速
- Jest 互換の API
- 型テスト(expectTypeOf)が組込み
- HMR 対応のウォッチモード
- ブラウザモード(Playwright / WebDriverIO)
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true, // describe, it, expect をグローバルに
environment: "node", // or "jsdom", "happy-dom"
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["node_modules", "dist", "e2e/**"],
// カバレッジ設定
coverage: {
provider: "v8", // or "istanbul"
reporter: ["text", "html", "lcov", "json-summary"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.d.ts",
"src/**/index.ts", // re-export のみのファイル
"src/types/**",
],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
// 型テストの設定
typecheck: {
enabled: true, // 型テストを有効化
tsconfig: "./tsconfig.test.json",
include: ["src/**/*.typetest.ts"],
},
// テストのタイムアウト
testTimeout: 10000,
hookTimeout: 10000,
// セットアップファイル
setupFiles: ["./tests/setup.ts"],
// グローバルセットアップ(テストスイート全体で1回)
globalSetup: ["./tests/global-setup.ts"],
// スナップショット
snapshotFormat: {
printBasicPrototype: false,
},
// モック自動クリーンアップ
restoreMocks: true,
clearMocks: true,
mockReset: true,
// 並列実行の設定
pool: "threads", // or "forks", "vmThreads"
poolOptions: {
threads: {
minThreads: 1,
maxThreads: 4,
},
},
},
resolve: {
alias: {
"@": "/src",
},
},
});// package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:watch": "vitest --watch",
"test:related": "vitest related",
"test:changed": "vitest --changed"
}
}// tsconfig.test.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["src/**/*", "tests/**/*"]
}1-2. テストの書き方
// src/user-service.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { UserService } from "./user-service";
import type { IUserRepository, IEmailService } from "./interfaces";
// モック作成(型安全)
function createMockUserRepo(): IUserRepository {
return {
findById: vi.fn(),
findByEmail: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
findMany: vi.fn(),
};
}
function createMockEmailService(): IEmailService {
return {
send: vi.fn(),
};
}
describe("UserService", () => {
let service: UserService;
let mockRepo: ReturnType<typeof createMockUserRepo>;
let mockEmail: ReturnType<typeof createMockEmailService>;
beforeEach(() => {
mockRepo = createMockUserRepo();
mockEmail = createMockEmailService();
service = new UserService(mockRepo, mockEmail);
});
describe("createUser", () => {
it("should save user and send welcome email", async () => {
// Arrange
mockRepo.save.mockResolvedValue(undefined);
mockEmail.send.mockResolvedValue(undefined);
// Act
const user = await service.createUser({
name: "Alice",
email: "alice@example.com",
});
// Assert
expect(user.name).toBe("Alice");
expect(mockRepo.save).toHaveBeenCalledOnce();
expect(mockEmail.send).toHaveBeenCalledWith(
"alice@example.com",
expect.stringContaining("Welcome"),
expect.any(String)
);
});
it("should return error for invalid email", async () => {
const result = await service.createUser({
name: "Bob",
email: "invalid",
});
expect(result).toMatchObject({
_tag: "Err",
error: { code: "VALIDATION_ERROR" },
});
expect(mockRepo.save).not.toHaveBeenCalled();
});
it("should handle database errors gracefully", async () => {
mockRepo.save.mockRejectedValue(new Error("Connection refused"));
const result = await service.createUser({
name: "Charlie",
email: "charlie@example.com",
});
expect(result).toMatchObject({
_tag: "Err",
error: { code: "DATABASE_ERROR" },
});
});
});
describe("deleteUser", () => {
it("should delete existing user", async () => {
mockRepo.findById.mockResolvedValue({
id: "user-1",
name: "Alice",
email: "alice@example.com",
});
mockRepo.delete.mockResolvedValue(undefined);
await service.deleteUser("user-1");
expect(mockRepo.delete).toHaveBeenCalledWith("user-1");
});
it("should throw when user not found", async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.deleteUser("nonexistent")).rejects.toThrow(
"User not found"
);
});
});
});1-3. vi.mock によるモジュールモック
// モジュール全体をモック
vi.mock("./database", () => ({
db: {
query: vi.fn(),
transaction: vi.fn(),
},
}));
// 部分モック(実装の一部だけ置き換え)
vi.mock("./utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("./utils")>();
return {
...actual,
generateId: vi.fn(() => "fixed-id"),
getCurrentTimestamp: vi.fn(() => new Date("2024-01-01T00:00:00Z")),
};
});
// スパイ
import * as mathUtils from "./math-utils";
vi.spyOn(mathUtils, "calculate").mockReturnValue(42);
// 環境変数のモック
vi.stubEnv("NODE_ENV", "test");
vi.stubEnv("API_KEY", "test-key");
// タイマーのモック
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-06-15T12:00:00Z"));
// テスト後にリストア
afterEach(() => {
vi.useRealTimers();
});
// fetch のモック
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ data: "test" }),
});1-4. スナップショットテスト
import { describe, it, expect } from "vitest";
describe("Snapshot tests", () => {
it("should match user object snapshot", () => {
const user = createUser({ name: "Alice", email: "alice@test.com" });
expect(user).toMatchSnapshot();
// 初回実行時に __snapshots__ にスナップショットが保存される
});
it("should match inline snapshot", () => {
const result = formatCurrency(1234.56, "JPY");
expect(result).toMatchInlineSnapshot(`"¥1,235"`);
// スナップショットがテストファイル内に埋め込まれる
});
it("should match serialized output", () => {
const html = renderToString(<UserCard user={mockUser} />);
expect(html).toMatchSnapshot();
// スナップショットの更新: vitest --update
});
});1-5. パラメタライズドテスト
import { describe, it, expect } from "vitest";
describe("calculateTax", () => {
it.each([
{ price: 1000, rate: 0.1, expected: 1100 },
{ price: 2000, rate: 0.1, expected: 2200 },
{ price: 500, rate: 0.08, expected: 540 },
{ price: 0, rate: 0.1, expected: 0 },
{ price: 99.99, rate: 0.1, expected: 109.989 },
])(
"should calculate $price with $rate% tax = $expected",
({ price, rate, expected }) => {
expect(calculateTax(price, rate)).toBeCloseTo(expected);
}
);
});
// テーブル形式
describe("validateEmail", () => {
it.each`
email | valid | reason
${"user@example.com"} | ${true} | ${"valid email"}
${"user@test.co.jp"} | ${true} | ${"country TLD"}
${"invalid"} | ${false} | ${"no @ symbol"}
${"@example.com"} | ${false} | ${"no local part"}
${"user@"} | ${false} | ${"no domain"}
`("$email should be valid=$valid ($reason)", ({ email, valid }) => {
expect(isValidEmail(email)).toBe(valid);
});
});2. Jest + TypeScript
2-1. ts-jest セットアップ
// jest.config.ts
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/src"],
testMatch: ["**/*.test.ts"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"^@components/(.*)$": "<rootDir>/src/components/$1",
"^@utils/(.*)$": "<rootDir>/src/utils/$1",
},
collectCoverageFrom: [
"src/**/*.ts",
"!src/**/*.d.ts",
"!src/**/*.test.ts",
"!src/**/index.ts",
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
// セットアップファイル
setupFilesAfterSetup: ["<rootDir>/tests/setup.ts"],
// タイムアウト
testTimeout: 10000,
};
export default config;2-2. @swc/jest(高速版)
// jest.config.ts -- SWC を使って高速化
import type { Config } from "jest";
const config: Config = {
testEnvironment: "node",
roots: ["<rootDir>/src"],
testMatch: ["**/*.test.ts"],
transform: {
"^.+\\.tsx?$": [
"@swc/jest",
{
jsc: {
parser: {
syntax: "typescript",
tsx: true,
decorators: true,
},
transform: {
decoratorVersion: "2022-03",
react: {
runtime: "automatic",
},
},
},
},
],
},
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
};
export default config;2-3. Jest から Vitest への移行
// Jest のコードはほぼそのまま Vitest で動作する
// 主な変更点:
// 1. jest.fn() → vi.fn()
// 2. jest.mock() → vi.mock()
// 3. jest.spyOn() → vi.spyOn()
// 4. jest.config.ts → vitest.config.ts
// 5. @types/jest → vitest/globals
// 6. 非互換な機能:
// - jest.requireActual() → await importOriginal()
// - jest.useFakeTimers("modern") → vi.useFakeTimers()
// - jest.runAllTimers() → vi.runAllTimers()
// 移行スクリプト(sed で一括置換)
// sed -i 's/jest\.fn/vi.fn/g' **/*.test.ts
// sed -i 's/jest\.mock/vi.mock/g' **/*.test.ts
// sed -i 's/jest\.spyOn/vi.spyOn/g' **/*.test.ts
// codemod ツールも利用可能:
// npx @vitest/codemod migrate-jest3. 型テスト
3-1. Vitest の expectTypeOf
型テストの目的:
ライブラリのパブリック API
|
v
+------------------+
| 型の正しさを検証 |
| |
| - 推論結果 |
| - 代入可能性 |
| - エラーになること |
+------------------+
|
v
型の回帰テスト(リファクタリングで型が壊れない保証)
// src/result.typetest.ts
import { describe, it, expectTypeOf } from "vitest";
import { Ok, Err, type Result, map, isOk, flatMap } from "./result";
describe("Result type tests", () => {
it("Ok should infer the correct type", () => {
const ok = Ok(42);
expectTypeOf(ok).toEqualTypeOf<{ _tag: "Ok"; value: number }>();
});
it("Err should infer the correct type", () => {
const err = Err("not found");
expectTypeOf(err).toEqualTypeOf<{ _tag: "Err"; error: string }>();
});
it("Result should be a union", () => {
type R = Result<number, string>;
expectTypeOf<R>().toEqualTypeOf<
{ _tag: "Ok"; value: number } | { _tag: "Err"; error: string }
>();
});
it("map should transform the success type", () => {
const result: Result<number, string> = Ok(42);
const mapped = map(result, (n) => String(n));
expectTypeOf(mapped).toEqualTypeOf<Result<string, string>>();
});
it("flatMap should compose Results", () => {
const result: Result<number, string> = Ok(42);
const composed = flatMap(result, (n) =>
n > 0 ? Ok(String(n)) : Err("negative")
);
expectTypeOf(composed).toEqualTypeOf<Result<string, string>>();
});
it("isOk should narrow the type", () => {
const result: Result<number, string> = Ok(42);
if (isOk(result)) {
expectTypeOf(result).toEqualTypeOf<{ _tag: "Ok"; value: number }>();
}
});
it("should not accept wrong types", () => {
// @ts-expect-error -- number は string に代入不可
const bad: Result<string, string> = Ok(42);
});
// expectTypeOf の豊富なアサーション
it("should demonstrate various type assertions", () => {
// 型が一致するか
expectTypeOf<string>().toEqualTypeOf<string>();
// 代入可能か
expectTypeOf<string>().toMatchTypeOf<string | number>();
// 関数の引数の型
function greet(name: string, age: number): string {
return `${name} (${age})`;
}
expectTypeOf(greet).parameter(0).toBeString();
expectTypeOf(greet).parameter(1).toBeNumber();
expectTypeOf(greet).returns.toBeString();
// 配列の要素型
expectTypeOf<string[]>().items.toBeString();
// オブジェクトのプロパティ
interface User {
name: string;
age: number;
}
expectTypeOf<User>().toHaveProperty("name");
expectTypeOf<User>().toHaveProperty("age");
});
});3-2. tsd を使った型テスト
// test-d/index.test-d.ts(tsd 用)
import { expectType, expectError, expectAssignable, expectNotType } from "tsd";
import { createStore, type Store } from "../src";
// 型が正しく推論されること
const store = createStore({ count: 0, name: "test" });
expectType<Store<{ count: number; name: string }>>(store);
// get が正しい型を返すこと
const count = store.get("count");
expectType<number>(count);
// 存在しないキーでエラーになること
expectError(store.get("nonexistent"));
// 代入可能性のテスト
expectAssignable<{ count: number }>(store.getState());
// 型が一致しないこと
expectNotType<string>(store.get("count"));// package.json に tsd の設定
{
"scripts": {
"test:types": "tsd"
},
"tsd": {
"directory": "test-d"
}
}3-3. @ts-expect-error による型テスト
// コンパイルエラーになることを検証
describe("type safety", () => {
it("should reject wrong argument types", () => {
function add(a: number, b: number): number {
return a + b;
}
// @ts-expect-error -- string は number に代入不可
add("1", "2");
// @ts-expect-error -- 引数が足りない
add(1);
// @ts-expect-error -- 引数が多すぎる
add(1, 2, 3);
});
it("branded types should not be interchangeable", () => {
type UserId = string & { __brand: "UserId" };
type OrderId = string & { __brand: "OrderId" };
function getUser(id: UserId): void {}
const orderId = "order-1" as OrderId;
// @ts-expect-error -- OrderId は UserId に代入不可
getUser(orderId);
});
it("readonly properties should not be writable", () => {
interface Config {
readonly apiUrl: string;
readonly port: number;
}
const config: Config = { apiUrl: "https://api.example.com", port: 3000 };
// @ts-expect-error -- readonly プロパティに代入不可
config.apiUrl = "https://other.com";
});
it("discriminated unions should be exhaustive", () => {
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default:
// never 型で網羅性チェック
const _exhaustive: never = shape;
return _exhaustive;
}
}
});
});4. テスト設計パターン
4-1. AAA パターン(Arrange-Act-Assert)
it("should calculate total with tax", () => {
// Arrange(準備)
const items = [
{ price: 1000, quantity: 2 },
{ price: 500, quantity: 3 },
];
const taxRate = 0.1;
// Act(実行)
const total = calculateTotal(items, taxRate);
// Assert(検証)
expect(total).toBe(3850); // (1000*2 + 500*3) * 1.1
});4-2. テストデータビルダー
// テストデータビルダーパターン
class UserBuilder {
private data: Partial<User> = {
id: "default-id",
name: "Default User",
email: "default@test.com",
role: "USER",
createdAt: new Date("2024-01-01"),
};
static create(): UserBuilder {
return new UserBuilder();
}
withId(id: string): this {
this.data.id = id;
return this;
}
withName(name: string): this {
this.data.name = name;
return this;
}
withEmail(email: string): this {
this.data.email = email;
return this;
}
withRole(role: "USER" | "ADMIN"): this {
this.data.role = role;
return this;
}
build(): User {
return this.data as User;
}
}
// 使用例
it("admin should have special permissions", () => {
const admin = UserBuilder.create()
.withName("Admin Alice")
.withRole("ADMIN")
.build();
expect(hasPermission(admin, "delete_users")).toBe(true);
});4-3. テストコンテキストファクトリー
// テストヘルパー
function createTestContext() {
const userRepo = createMockUserRepo();
const emailService = createMockEmailService();
const logger = createMockLogger();
const eventBus = createMockEventBus();
const service = new UserService(userRepo, emailService, logger, eventBus);
return { service, userRepo, emailService, logger, eventBus };
}
describe("UserService", () => {
it("should handle concurrent creates", async () => {
const { service, userRepo } = createTestContext();
userRepo.save.mockResolvedValue(undefined);
const results = await Promise.all([
service.createUser({ name: "A", email: "a@test.com" }),
service.createUser({ name: "B", email: "b@test.com" }),
]);
expect(userRepo.save).toHaveBeenCalledTimes(2);
results.forEach((r) => expect(r).toMatchObject({ _tag: "Ok" }));
});
it("should emit UserCreated event", async () => {
const { service, userRepo, eventBus } = createTestContext();
userRepo.save.mockResolvedValue(undefined);
await service.createUser({ name: "Alice", email: "alice@test.com" });
expect(eventBus.emit).toHaveBeenCalledWith(
"UserCreated",
expect.objectContaining({ email: "alice@test.com" })
);
});
});4-4. HTTP モック(msw)
// tests/mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("https://api.example.com/users", () => {
return HttpResponse.json([
{ id: "1", name: "Alice", email: "alice@example.com" },
{ id: "2", name: "Bob", email: "bob@example.com" },
]);
}),
http.get("https://api.example.com/users/:id", ({ params }) => {
const { id } = params;
if (id === "404") {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json({
id,
name: "Alice",
email: "alice@example.com",
});
}),
http.post("https://api.example.com/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: "new-id", ...body },
{ status: 201 }
);
}),
];
// tests/setup.ts
import { setupServer } from "msw/node";
import { handlers } from "./mocks/handlers";
const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// テストでの使用
describe("UserApiClient", () => {
it("should fetch users", async () => {
const client = new UserApiClient("https://api.example.com");
const users = await client.getUsers();
expect(users).toHaveLength(2);
expect(users[0].name).toBe("Alice");
});
it("should handle 404 errors", async () => {
const client = new UserApiClient("https://api.example.com");
await expect(client.getUser("404")).rejects.toThrow("User not found");
});
// テストごとにハンドラーを上書き
it("should handle server errors", async () => {
server.use(
http.get("https://api.example.com/users", () => {
return new HttpResponse(null, { status: 500 });
})
);
const client = new UserApiClient("https://api.example.com");
await expect(client.getUsers()).rejects.toThrow("Server error");
});
});4-5. データベーステスト(Prisma + テストコンテナ)
// tests/helpers/database.ts
import { PrismaClient } from "@prisma/client";
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { execSync } from "child_process";
let container: StartedPostgreSqlContainer;
let prisma: PrismaClient;
export async function setupTestDatabase(): Promise<PrismaClient> {
// Docker コンテナでテスト用 PostgreSQL を起動
container = await new PostgreSqlContainer("postgres:16")
.withDatabase("testdb")
.start();
const databaseUrl = container.getConnectionUri();
// マイグレーションを適用
execSync(`DATABASE_URL="${databaseUrl}" npx prisma migrate deploy`, {
stdio: "pipe",
});
prisma = new PrismaClient({
datasources: { db: { url: databaseUrl } },
});
return prisma;
}
export async function teardownTestDatabase(): Promise<void> {
await prisma.$disconnect();
await container.stop();
}
export async function cleanDatabase(): Promise<void> {
// トランザクションで全テーブルをクリア
const tablenames = await prisma.$queryRaw<
{ tablename: string }[]
>`SELECT tablename FROM pg_tables WHERE schemaname = 'public'`;
for (const { tablename } of tablenames) {
if (tablename !== "_prisma_migrations") {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${tablename}" CASCADE;`);
}
}
}
// テストでの使用
describe("UserRepository (integration)", () => {
let prisma: PrismaClient;
let repo: UserRepository;
beforeAll(async () => {
prisma = await setupTestDatabase();
repo = new PrismaUserRepository(prisma);
});
afterAll(async () => {
await teardownTestDatabase();
});
afterEach(async () => {
await cleanDatabase();
});
it("should create and find user", async () => {
const created = await repo.create({
name: "Alice",
email: "alice@test.com",
role: "USER",
});
const found = await repo.findById(created.id);
expect(found).toMatchObject({
name: "Alice",
email: "alice@test.com",
});
});
it("should return null for non-existent user", async () => {
const found = await repo.findById("non-existent-id");
expect(found).toBeNull();
});
});5. テストの構成と命名規則
5-1. ファイル構成パターン
パターン A: コロケーション(同一ディレクトリ)
src/
├── user/
│ ├── user-service.ts
│ ├── user-service.test.ts ← テストをソースの横に
│ ├── user-repository.ts
│ └── user-repository.test.ts
└── order/
├── order-service.ts
└── order-service.test.ts
パターン B: 分離ディレクトリ
src/
├── user/
│ ├── user-service.ts
│ └── user-repository.ts
tests/
├── unit/
│ ├── user-service.test.ts
│ └── user-repository.test.ts
├── integration/
│ └── user-flow.test.ts
└── e2e/
└── api.test.ts
パターン C: ハイブリッド(推奨)
src/
├── user/
│ ├── user-service.ts
│ ├── user-service.test.ts ← ユニットテスト
│ └── user-repository.ts
tests/
├── integration/ ← 統合テスト
│ └── user-creation.test.ts
├── e2e/ ← E2E テスト
│ └── user-api.test.ts
├── helpers/ ← テストヘルパー
│ ├── database.ts
│ ├── builders.ts
│ └── mocks.ts
└── setup.ts ← グローバルセットアップ
5-2. テスト命名規則
// 命名パターン: should + 期待される振る舞い + when + 条件
describe("UserService.createUser", () => {
// 正常系
it("should create user and return Ok when valid data is provided", async () => {
// ...
});
it("should send welcome email when user is created successfully", async () => {
// ...
});
// 異常系
it("should return ValidationError when email is invalid", async () => {
// ...
});
it("should return DuplicateError when email already exists", async () => {
// ...
});
// 境界値
it("should accept name with exactly 100 characters", async () => {
// ...
});
it("should reject name with 101 characters", async () => {
// ...
});
});6. テストピラミッドとカバレッジ戦略
テストピラミッド:
/\
/ \ E2E テスト(少数)
/ \ - Playwright / Cypress
/------\ - ユーザーシナリオ全体
/ \ - 実行時間: 長い
/ 統合 \
/ テスト \ 統合テスト(中程度)
/--------------\ - DB / API の結合
/ \ - テストコンテナ
/ ユニットテスト \
+-------------------+ ユニットテスト(多数)
- 純粋な関数
- モック使用
- 実行時間: 短い
目安:
ユニットテスト: 70%
統合テスト: 20%
E2E テスト: 10%
// カバレッジの除外設定
// vitest.config.ts
{
test: {
coverage: {
exclude: [
// テストファイル自体
"**/*.test.ts",
"**/*.spec.ts",
// 型定義
"**/*.d.ts",
// 設定ファイル
"*.config.ts",
// re-export のみのファイル
"**/index.ts",
// 生成コード
"src/generated/**",
// テストヘルパー
"tests/**",
],
},
},
}比較表
テストランナー比較
| 特性 | Vitest | Jest | Node.js test runner |
|---|---|---|---|
| 速度 | 非常に速い | 普通 | 速い |
| TypeScript | Vite で変換 | ts-jest / @swc/jest | --loader |
| 型テスト | expectTypeOf 組込み | tsd 別途 | なし |
| HMR | あり | なし | なし |
| UI | vitest --ui | jest-stare 等 | なし |
| Watch | 最適化済み | あり | あり |
| Coverage | v8 / istanbul | istanbul | v8 |
| エコシステム | 成長中 | 最大 | 最小 |
| ブラウザテスト | Playwright/WebDriverIO | jsdom のみ | なし |
| セットアップ | 簡単 | 中程度 | 最小 |
| スナップショット | あり | あり | あり |
モック手法の比較
| 手法 | 用途 | 型安全性 | 柔軟性 | 推奨場面 |
|---|---|---|---|---|
| vi.fn() / jest.fn() | 関数モック | 中 | 高 | コールバック |
| vi.mock() / jest.mock() | モジュールモック | 低 | 最高 | 外部依存 |
| vi.spyOn() / jest.spyOn() | スパイ | 高 | 中 | 既存関数の監視 |
| 手動モック | DI ベース | 最高 | 中 | サービス層 |
| msw | HTTP モック | 高 | 高 | API クライアント |
| testcontainers | 実 DB テスト | 最高 | 最高 | リポジトリ層 |
テスト環境の比較
| 環境 | 用途 | DOM | パフォーマンス |
|---|---|---|---|
| node | バックエンド | なし | 最速 |
| jsdom | フロントエンド | シミュレート | 速い |
| happy-dom | フロントエンド | シミュレート | 速い |
| playwright | E2E / ブラウザ | 実ブラウザ | 遅い |
アンチパターン
AP-1: テストで any を使う
// NG: any で型安全性を破壊
it("should process data", () => {
const mockData: any = { foo: "bar" };
const result = processUser(mockData); // 型チェックが効かない
expect(result).toBeDefined();
});
// OK: 正しい型でテストデータを作成
it("should process data", () => {
const user = UserBuilder.create()
.withName("Alice")
.withEmail("alice@test.com")
.build();
const result = processUser(user); // 型チェックが効く
expect(result.name).toBe("Alice");
});AP-2: 実装の詳細をテストする
// NG: 内部実装に依存したテスト(壊れやすい)
it("should call repository save then email send", async () => {
// save が email より先に呼ばれることをテスト
const callOrder: string[] = [];
mockRepo.save.mockImplementation(() => {
callOrder.push("save");
return Promise.resolve();
});
mockEmail.send.mockImplementation(() => {
callOrder.push("email");
return Promise.resolve();
});
await service.createUser(data);
expect(callOrder).toEqual(["save", "email"]); // 内部実装に依存
});
// OK: 振る舞い(結果)をテスト
it("should create user and send welcome email", async () => {
const result = await service.createUser(data);
expect(result).toMatchObject({ _tag: "Ok" });
expect(mockEmail.send).toHaveBeenCalledWith(
data.email,
expect.any(String),
expect.any(String)
);
});AP-3: テスト間の依存関係
// NG: テスト間で状態を共有
let userId: string;
it("should create user", async () => {
const user = await service.createUser(data);
userId = user.id; // 次のテストで使う ← 危険!
expect(user).toBeDefined();
});
it("should get user", async () => {
const user = await service.getUser(userId); // 前のテストに依存
expect(user.name).toBe("Alice");
});
// OK: 各テストが独立
it("should get created user", async () => {
// Arrange: テスト内で完結
const created = await service.createUser(data);
// Act
const found = await service.getUser(created.id);
// Assert
expect(found.name).toBe(data.name);
});AP-4: 過度なモック
// NG: 全てをモックして何も検証していない
it("should work", async () => {
mockRepo.findById.mockResolvedValue(mockUser);
mockRepo.save.mockResolvedValue(undefined);
mockEmail.send.mockResolvedValue(undefined);
mockLogger.info.mockReturnValue(undefined);
mockCache.get.mockResolvedValue(null);
mockCache.set.mockResolvedValue(undefined);
const result = await service.updateUser("id", { name: "New" });
expect(result).toBeDefined(); // 何を検証してるのか不明
});
// OK: 本当に重要な振る舞いに焦点を当てる
it("should update user name and invalidate cache", async () => {
mockRepo.findById.mockResolvedValue(existingUser);
mockRepo.save.mockResolvedValue(undefined);
const result = await service.updateUser("user-1", { name: "New Name" });
expect(result.name).toBe("New Name");
expect(mockRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ id: "user-1", name: "New Name" })
);
expect(mockCache.delete).toHaveBeenCalledWith("user:user-1");
});実践演習
演習1: 基本的な実装
以下の要件を満たすコードを実装してください。
要件:
- 入力データの検証を行うこと
- エラーハンドリングを適切に実装すること
- テストコードも作成すること
# 演習1: 基本実装のテンプレート
class Exercise1:
"""基本的な実装パターンの演習"""
def __init__(self):
self.data = []
def validate_input(self, value):
"""入力値の検証"""
if value is None:
raise ValueError("入力値がNoneです")
return True
def process(self, value):
"""データ処理のメインロジック"""
self.validate_input(value)
self.data.append(value)
return self.data
def get_results(self):
"""処理結果の取得"""
return {
'count': len(self.data),
'data': self.data
}
# テスト
def test_exercise1():
ex = Exercise1()
assert ex.process(1) == [1]
assert ex.process(2) == [1, 2]
assert ex.get_results()['count'] == 2
try:
ex.process(None)
assert False, "例外が発生するべき"
except ValueError:
pass
print("全テスト合格!")
test_exercise1()演習2: 応用パターン
基本実装を拡張して、以下の機能を追加してください。
# 演習2: 応用パターン
from typing import List, Dict, Optional
from datetime import datetime
class AdvancedExercise:
"""応用パターンの演習"""
def __init__(self, max_size: int = 100):
self._items: List[Dict] = []
self._max_size = max_size
self._created_at = datetime.now()
def add(self, key: str, value: any) -> bool:
"""アイテムの追加(サイズ制限付き)"""
if len(self._items) >= self._max_size:
return False
self._items.append({
'key': key,
'value': value,
'timestamp': datetime.now().isoformat()
})
return True
def find(self, key: str) -> Optional[Dict]:
"""キーによる検索"""
for item in reversed(self._items):
if item['key'] == key:
return item
return None
def remove(self, key: str) -> bool:
"""キーによる削除"""
for i, item in enumerate(self._items):
if item['key'] == key:
self._items.pop(i)
return True
return False
def stats(self) -> Dict:
"""統計情報"""
return {
'total_items': len(self._items),
'max_size': self._max_size,
'usage_percent': len(self._items) / self._max_size * 100,
'uptime': str(datetime.now() - self._created_at)
}
# テスト
def test_advanced():
ex = AdvancedExercise(max_size=3)
assert ex.add("a", 1) == True
assert ex.add("b", 2) == True
assert ex.add("c", 3) == True
assert ex.add("d", 4) == False # サイズ制限
assert ex.find("b")['value'] == 2
assert ex.remove("b") == True
assert ex.find("b") is None
stats = ex.stats()
assert stats['total_items'] == 2
print("応用テスト全合格!")
test_advanced()演習3: パフォーマンス最適化
以下のコードのパフォーマンスを改善してください。
# 演習3: パフォーマンス最適化
import time
from functools import lru_cache
# 最適化前(O(n^2))
def slow_search(data: list, target: int) -> int:
"""非効率な検索"""
for i in range(len(data)):
for j in range(i + 1, len(data)):
if data[i] + data[j] == target:
return (i, j)
return (-1, -1)
# 最適化後(O(n))
def fast_search(data: list, target: int) -> tuple:
"""ハッシュマップを使った効率的な検索"""
seen = {}
for i, num in enumerate(data):
complement = target - num
if complement in seen:
return (seen[complement], i)
seen[num] = i
return (-1, -1)
# ベンチマーク
def benchmark():
import random
data = list(range(5000))
random.shuffle(data)
target = data[100] + data[4000]
start = time.time()
result1 = slow_search(data, target)
slow_time = time.time() - start
start = time.time()
result2 = fast_search(data, target)
fast_time = time.time() - start
print(f"非効率版: {slow_time:.4f}秒")
print(f"効率版: {fast_time:.6f}秒")
print(f"高速化率: {slow_time/fast_time:.0f}倍")
benchmark()ポイント:
- アルゴリズムの計算量を意識する
- 適切なデータ構造を選択する
- ベンチマークで効果を測定する
トラブルシューティング
よくあるエラーと解決策
| エラー | 原因 | 解決策 |
|---|---|---|
| 初期化エラー | 設定ファイルの不備 | 設定ファイルのパスと形式を確認 |
| タイムアウト | ネットワーク遅延/リソース不足 | タイムアウト値の調整、リトライ処理の追加 |
| メモリ不足 | データ量の増大 | バッチ処理の導入、ページネーションの実装 |
| 権限エラー | アクセス権限の不足 | 実行ユーザーの権限確認、設定の見直し |
| データ不整合 | 並行処理の競合 | ロック機構の導入、トランザクション管理 |
デバッグの手順
- エラーメッセージの確認: スタックトレースを読み、発生箇所を特定する
- 再現手順の確立: 最小限のコードでエラーを再現する
- 仮説の立案: 考えられる原因をリストアップする
- 段階的な検証: ログ出力やデバッガを使って仮説を検証する
- 修正と回帰テスト: 修正後、関連する箇所のテストも実行する
# デバッグ用ユーティリティ
import logging
import traceback
from functools import wraps
# ロガーの設定
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s'
)
logger = logging.getLogger(__name__)
def debug_decorator(func):
"""関数の入出力をログ出力するデコレータ"""
@wraps(func)
def wrapper(*args, **kwargs):
logger.debug(f"呼び出し: {func.__name__}(args={args}, kwargs={kwargs})")
try:
result = func(*args, **kwargs)
logger.debug(f"戻り値: {func.__name__} -> {result}")
return result
except Exception as e:
logger.error(f"例外発生: {func.__name__}: {e}")
logger.error(traceback.format_exc())
raise
return wrapper
@debug_decorator
def process_data(items):
"""データ処理(デバッグ対象)"""
if not items:
raise ValueError("空のデータ")
return [item * 2 for item in items]パフォーマンス問題の診断
パフォーマンス問題が発生した場合の診断手順:
- ボトルネックの特定: プロファイリングツールで計測
- メモリ使用量の確認: メモリリークの有無をチェック
- I/O待ちの確認: ディスクやネットワークI/Oの状況を確認
- 同時接続数の確認: コネクションプールの状態を確認
| 問題の種類 | 診断ツール | 対策 |
|---|---|---|
| CPU負荷 | cProfile, py-spy | アルゴリズム改善、並列化 |
| メモリリーク | tracemalloc, objgraph | 参照の適切な解放 |
| I/Oボトルネック | strace, iostat | 非同期I/O、キャッシュ |
| DB遅延 | EXPLAIN, slow query log | インデックス、クエリ最適化 |
FAQ
Q1: Vitest と Jest のどちらを選ぶべきですか?
新規プロジェクトでは Vitest を推奨します。Vite エコシステムとの統合、型テストの組込みサポート、高速な実行が利点です。既存の Jest プロジェクトを無理に移行する必要はありませんが、Vitest は Jest 互換の API を提供しているため、移行は比較的容易です。@vitest/codemod で自動移行も可能です。
Q2: 型テストはどのくらい書くべきですか?
ライブラリやユーティリティ型を公開する場合は必須です。アプリケーションコードでは、複雑なジェネリクス関数やユーティリティ型に対して書くと効果的です。全ての関数に型テストを書く必要はなく、「型の推論結果が重要な部分」に集中してください。
Q3: テストカバレッジの目標は何%が適切ですか?
80% が一般的な目標です。ただし、カバレッジ% だけを追うのではなく、「クリティカルパス(正常系・異常系の主要フロー)がカバーされているか」を重視してください。100% を目指すとテストの保守コストが膨大になります。
Q4: 統合テストで本物の DB を使うべきですか?
可能であれば testcontainers で本物の DB を使うことを推奨します。モックでは検出できない SQL の問題やデータの整合性エラーを発見できます。CI 環境では Docker が必要になりますが、GitHub Actions 等では容易に設定できます。
Q5: テストが遅い場合の対処法は?
vitest --pool=threadsで並列実行vitest --changedで変更ファイルに関連するテストのみ実行- モジュールモックの最小化(モックが多いほどオーバーヘッドが大きい)
- 統合テストとユニットテストの分離(
vitest --project unit) - CI では
--shardで並列ジョブに分割
まとめ表
| 概念 | 要点 |
|---|---|
| Vitest | Vite ベース、高速、型テスト組込み |
| Jest | 最大のエコシステム、@swc/jest で高速化可能 |
| 型テスト | expectTypeOf / tsd でライブラリの型を検証 |
| @ts-expect-error | コンパイルエラーになることを検証 |
| AAA パターン | Arrange-Act-Assert で構造化 |
| DI + モック | インターフェースベースで差し替え容易に |
| msw | HTTP リクエストのモック(サービスワーカー) |
| テストピラミッド | ユニット 70%、統合 20%、E2E 10% |
7. React コンポーネントのテスト
7-1. Testing Library + Vitest
// src/components/UserCard.test.tsx
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { UserCard } from "./UserCard";
describe("UserCard", () => {
const mockUser = {
id: "1",
name: "Alice",
email: "alice@example.com",
role: "admin" as const,
};
it("should render user information", () => {
render(<UserCard user={mockUser} />);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
expect(screen.getByRole("badge")).toHaveTextContent("admin");
});
it("should call onEdit when edit button is clicked", async () => {
const onEdit = vi.fn();
render(<UserCard user={mockUser} onEdit={onEdit} />);
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(onEdit).toHaveBeenCalledWith(mockUser.id);
});
it("should show delete confirmation dialog", async () => {
const onDelete = vi.fn();
render(<UserCard user={mockUser} onDelete={onDelete} />);
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(screen.getByText("本当に削除しますか?")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /confirm/i }));
expect(onDelete).toHaveBeenCalledWith(mockUser.id);
});
});7-2. カスタムフックのテスト
// src/hooks/useUsers.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useUsers } from "./useUsers";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
}
describe("useUsers", () => {
it("should fetch and return users", async () => {
const { result } = renderHook(() => useUsers(), {
wrapper: createWrapper(),
});
// 初期状態
expect(result.current.isLoading).toBe(true);
// データ取得完了
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0].name).toBe("Alice");
});
it("should handle error state", async () => {
// msw でエラーレスポンスを返す設定に上書き
server.use(
http.get("https://api.example.com/users", () => {
return new HttpResponse(null, { status: 500 });
})
);
const { result } = renderHook(() => useUsers(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error?.message).toContain("Server error");
});
});8. CI/CD でのテスト設定
8-1. GitHub Actions
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4] # テストを4分割
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Run tests
run: vitest run --shard=${{ matrix.shard }} --reporter=junit --outputFile=test-results.xml
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-${{ matrix.shard }}
path: test-results.xml
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: vitest run --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: coverage/lcov.info8-2. テストのパフォーマンス最適化
// vitest.config.ts でパフォーマンスを最適化
export default defineConfig({
test: {
// スレッドプールで並列実行
pool: "threads",
poolOptions: {
threads: {
// CPU コア数に合わせる
minThreads: 2,
maxThreads: 8,
},
},
// 失敗したテストを先に実行(フィードバック高速化)
sequence: {
shuffle: false,
},
// テスト分離(メモリリーク防止)
isolate: true,
// ファイルごとのタイムアウト
testTimeout: 10000,
// 重いテストの特定
slowTestThreshold: 1000,
// レポーター設定
reporters: ["default", "junit"],
outputFile: {
junit: "test-results/junit.xml",
},
},
});まとめ
このガイドでは以下の重要なポイントを学びました:
- 基本概念と原則の理解
- 実践的な実装パターン
- ベストプラクティスと注意点
- 実務での活用方法
次に読むべきガイド
- ビルドツール -- Vitest と Vite の連携設定
- DI パターン -- テスタブルな設計と DI
- ESLint + TypeScript -- テストコードの lint ルール
参考文献
-
Vitest -- Next Generation Testing Framework https://vitest.dev/
-
Jest -- Delightful JavaScript Testing https://jestjs.io/
-
tsd -- Check TypeScript type definitions https://github.com/tsdjs/tsd
-
msw -- Mock Service Worker https://mswjs.io/
-
testcontainers -- Integration testing with real services https://testcontainers.com/