javascript-testing-patterns について
JavaScript と TypeScript のテストスイートを導入・改善する開発者向けのガイドです。ユニット、インテグレーション、エンドツーエンドテストに加え、モックやフロントエンドのテスト例を扱います。
主な機能
- 01Jest と Vitest の設定パターン
- 02ユニットテストと非同期関数のテスト
- 03モジュールと依存関係のモック
- 04API とデータベースの統合テスト
- 05フロントエンドのコンポーネントとフックのテスト
- 06フィクスチャ、ファクトリー、テストの実践
活用シーン
- 01新規プロジェクトのテスト整備
- 02API とサービスのテスト
- 03フロントエンドのコンポーネントとフックの検証
- 04モックを使った分離テストの作成
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wshobson/ agents --skill javascript-testing-patternsインストール後、Claude Code で/javascript-testing-patternsを入力して読み込まれたか確認します。
同梱ファイルreferences/advanced-testing-patterns.md
詳細
JavaScript テスティングパターン
モダンなテストフレームワークとベストプラクティスを用いて、JavaScript/TypeScript アプリケーションで堅牢なテスト戦略を実装するための包括的なガイドです。
このスキルを使用する場面
- 新しいプロジェクトのテスト基盤をセットアップするとき
- 関数やクラスの単体テストを書くとき
- API やサービスの統合テストを作成するとき
- ユーザーフローのエンドツーエンドテストを実装するとき
- 外部依存関係や API をモックするとき
- React、Vue、その他のフロントエンドコンポーネントをテストするとき
- テスト駆動開発(TDD)を実践するとき
- CI/CD パイプラインで継続的テストをセットアップするとき
テストフレームワーク
Jest - フル機能のテストフレームワーク
セットアップ:
// jest.config.ts
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/src"],
testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"],
collectCoverageFrom: [
"src/**/*.ts",
"!src/**/*.d.ts",
"!src/**/*.interface.ts",
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
setupFilesAfterEnv: ["<rootDir>/src/test/setup.ts"],
};
export default config;
Vitest - 高速で Vite ネイティブなテスト
セットアップ:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["**/*.d.ts", "**/*.config.ts", "**/dist/**"],
},
setupFiles: ["./src/test/setup.ts"],
},
});
単体テストのパターン
パターン 1: 純粋関数のテスト
// utils/calculator.ts
export function add(a: number, b: number): number {
return a + b;
}
export function divide(a: number, b: number): number {
if (b === 0) {
throw new Error("Division by zero");
}
return a / b;
}
// utils/calculator.test.ts
import { describe, it, expect } from "vitest";
import { add, divide } from "./calculator";
describe("Calculator", () => {
describe("add", () => {
it("should add two positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
it("should add negative numbers", () => {
expect(add(-2, -3)).toBe(-5);
});
it("should handle zero", () => {
expect(add(0, 5)).toBe(5);
expect(add(5, 0)).toBe(5);
});
});
describe("divide", () => {
it("should divide two numbers", () => {
expect(divide(10, 2)).toBe(5);
});
it("should handle decimal results", () => {
expect(divide(5, 2)).toBe(2.5);
});
it("should throw error when dividing by zero", () => {
expect(() => divide(10, 0)).toThrow("Division by zero");
});
});
});
パターン 2: クラスのテスト
// services/user.service.ts
export class UserService {
private users: Map<string, User> = new Map();
create(user: User): User {
if (this.users.has(user.id)) {
throw new Error("User already exists");
}
this.users.set(user.id, user);
return user;
}
findById(id: string): User | undefined {
return this.users.get(id);
}
update(id: string, updates: Partial<User>): User {
const user = this.users.get(id);
if (!user) {
throw new Error("User not found");
}
const updated = { ...user, ...updates };
this.users.set(id, updated);
return updated;
}
delete(id: string): boolean {
return this.users.delete(id);
}
}
// services/user.service.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { UserService } from "./user.service";
describe("UserService", () => {
let service: UserService;
beforeEach(() => {
service = new UserService();
});
describe("create", () => {
it("should create a new user", () => {
const user = { id: "1", name: "John", email: "[email protected]" };
const created = service.create(user);
expect(created).toEqual(user);
expect(service.findById("1")).toEqual(user);
});
it("should throw error if user already exists", () => {
const user = { id: "1", name: "John", email: "[email protected]" };
service.create(user);
expect(() => service.create(user)).toThrow("User already exists");
});
});
describe("update", () => {
it("should update existing user", () => {
const user = { id: "1", name: "John", email: "[email protected]" };
service.create(user);
const updated = service.update("1", { name: "Jane" });
expect(updated.name).toBe("Jane");
expect(updated.email).toBe("[email protected]");
});
it("should throw error if user not found", () => {
expect(() => service.update("999", { name: "Jane" })).toThrow(
"User not found",
);
});
});
});
パターン 3: 非同期関数のテスト
// services/api.service.ts
export class ApiService {
async fetchUser(id: string): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new Error("User not found");
}
return response.json();
}
async createUser(user: CreateUserDTO): Promise<User> {
const response = await fetch("https://api.example.com/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(user),
});
return response.json();
}
}
// services/api.service.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ApiService } from "./api.service";
// Mock fetch globally
global.fetch = vi.fn();
describe("ApiService", () => {
let service: ApiService;
beforeEach(() => {
service = new ApiService();
vi.clearAllMocks();
});
describe("fetchUser", () => {
it("should fetch user successfully", async () => {
const mockUser = { id: "1", name: "John", email: "[email protected]" };
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const user = await service.fetchUser("1");
expect(user).toEqual(mockUser);
expect(fetch).toHaveBeenCalledWith("https://api.example.com/users/1");
});
it("should throw error if user not found", async () => {
(fetch as any).mockResolvedValueOnce({
ok: false,
});
await expect(service.fetchUser("999")).rejects.toThrow("User not found");
});
});
describe("createUser", () => {
it("should create user successfully", async () => {
const newUser = { name: "John", email: "[email protected]" };
const createdUser = { id: "1", ...newUser };
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => createdUser,
});
const user = await service.createUser(newUser);
expect(user).toEqual(createdUser);
expect(fetch).toHaveBeenCalledWith(
"https://api.example.com/users",
expect.objectContaining({
method: "POST",
body: JSON.stringify(newUser),
}),
);
});
});
});
モックのパターン
パターン 1: モジュールのモック
// services/email.service.ts
import nodemailer from "nodemailer";
export class EmailService {
private transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
async sendEmail(to: string, subject: string, html: string) {
await this.transporter.sendMail({
from: process.env.EMAIL_FROM,
to,
subject,
html,
});
}
}
// services/email.service.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EmailService } from "./email.service";
vi.mock("nodemailer", () => ({
default: {
createTransport: vi.fn(() => ({
sendMail: vi.fn().mockResolvedValue({ messageId: "123" }),
})),
},
}));
describe("EmailService", () => {
let service: EmailService;
beforeEach(() => {
service = new EmailService();
});
it("should send email successfully", async () => {
await service.sendEmail(
"[email protected]",
"Test Subject",
"<p>Test Body</p>",
);
expect(service["transporter"].sendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: "[email protected]",
subject: "Test Subject",
}),
);
});
});
パターン 2: テストのための依存性注入
// services/user.service.ts
export interface IUserRepository {
findById(id: string): Promise<User | null>;
create(user: User): Promise<User>;
}
export class UserService {
constructor(private userRepository: IUserRepository) {}
async getUser(id: string): Promise<User> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new Error("User not found");
}
return user;
}
async createUser(userData: CreateUserDTO): Promise<User> {
// Business logic here
const user = { id: generateId(), ...userData };
return this.userRepository.create(user);
}
}
// services/user.service.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { UserService, IUserRepository } from "./user.service";
describe("UserService", () => {
let service: UserService;
let mockRepository: IUserRepository;
beforeEach(() => {
mockRepository = {
findById: vi.fn(),
create: vi.fn(),
};
service = new UserService(mockRepository);
});
describe("getUser", () => {
it("should return user if found", async () => {
const mockUser = { id: "1", name: "John", email: "[email protected]" };
vi.mocked(mockRepository.findById).mockResolvedValue(mockUser);
const user = await service.getUser("1");
expect(user).toEqual(mockUser);
expect(mockRepository.findById).toHaveBeenCalledWith("1");
});
it("should throw error if user not found", async () => {
vi.mocked(mockRepository.findById).mockResolvedValue(null);
await expect(service.getUser("999")).rejects.toThrow("User not found");
});
});
describe("createUser", () => {
it("should create user successfully", async () => {
const userData = { name: "John", email: "[email protected]" };
const createdUser = { id: "1", ...userData };
vi.mocked(mockRepository.create).mockResolvedValue(createdUser);
const user = await service.createUser(userData);
expect(user).toEqual(createdUser);
expect(mockRepository.create).toHaveBeenCalled();
});
});
});
パターン 3: 関数のスパイ
// utils/logger.ts
export const logger = {
info: (message: string) => console.log(`INFO: ${message}`),
error: (message: string) => console.error(`ERROR: ${message}`),
};
// services/order.service.ts
import { logger } from "../utils/logger";
export class OrderService {
async processOrder(orderId: string): Promise<void> {
logger.info(`Processing order ${orderId}`);
// Process order logic
logger.info(`Order ${orderId} processed successfully`);
}
}
// services/order.service.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { OrderService } from "./order.service";
import { logger } from "../utils/logger";
describe("OrderService", () => {
let service: OrderService;
let loggerSpy: any;
beforeEach(() => {
service = new OrderService();
loggerSpy = vi.spyOn(logger, "info");
});
afterEach(() => {
loggerSpy.mockRestore();
});
it("should log order processing", async () => {
await service.processOrder("123");
expect(loggerSpy).toHaveBeenCalledWith("Processing order 123");
expect(loggerSpy).toHaveBeenCalledWith("Order 123 processed successfully");
expect(loggerSpy).toHaveBeenCalledTimes(2);
});
});
統合テスト
統合テストは、supertest とテスト用データベースインスタンスを使用して、実際のデータベース操作と HTTP エンドポイントを検証します。常に beforeEach でテーブルを truncate し、afterAll で tear down してください。
完全な API 統合テストの例(supertest + PostgreSQL)とデータベースリポジトリの統合テストについては、references/advanced-testing-patterns.md を参照してください。
Testing Library によるフロントエンドテスト
React コンポーネントをレンダリングし、role、placeholder、または test ID でクエリすることでテストします。フックは renderHook + act でテストします。data-testid よりもセマンティックなクエリ(getByRole、getByPlaceholderText)を優先してください。
完全な React コンポーネントテストの例(UserForm、renderHook/act を使ったフック)については、references/advanced-testing-patterns.md を参照してください。
テストフィクスチャとファクトリ
@faker-js/faker を使用して、リアルなテストデータファクトリを生成します。ファクトリは任意の overrides を受け付けるため、テストは関心のあるフィールドだけを設定できます:
// tests/fixtures/user.fixture.ts
import { faker } from "@faker-js/faker";
export function createUserFixture(overrides?: Partial<User>): User {
return {
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
createdAt: faker.date.past(),
...overrides,
};
}
スナップショットテスト、カバレッジ設定、テスト構成のパターン、Promise のテスト、タイマーのモックについては、references/advanced-testing-patterns.md を参照してください。
ベストプラクティス
- AAA パターンに従う: Arrange(準備)、Act(実行)、Assert(検証)
- テストごとに 1 つのアサーション: または論理的に関連するアサーション
- 説明的なテスト名: 何をテストしているかを説明すること
- beforeEach/afterEach を使用する: セットアップと tear down のために
- 外部依存関係をモックする: テストを分離された状態に保つ
- エッジケースをテストする: ハッピーパスだけでなく
- 実装の詳細を避ける: 実装ではなく振る舞いをテストする
- テストファクトリを使用する: 一貫したテストデータのために
- テストを高速に保つ: 遅い操作はモックする
- テストを先に書く(TDD): 可能な場合は
- テストカバレッジを維持する: 80% 以上のカバレッジを目指す
- TypeScript を使用する: 型安全なテストのために
- エラーハンドリングをテストする: 成功ケースだけでなく
- data-testid は控えめに使用する: セマンティックなクエリを優先する
- テスト後にクリーンアップする: テスト汚染を防ぐ