はじめに
この記事は、以下の記事の続きです。
今回は、インフラ層のテストを実装します。
開発環境
開発環境は以下の通りです。
- Windows11
- Docker Engine 27.0.3
- Docker Compose 2
- PostgreSQL 18.1
- Node.js 24.11.0
- npm 11.6.2
- TypeScript 5.9.3
- Express 5.1.0
- Prisma 6.18.0
- Zod 4.1.12
- Vitest 4.0.14
インフラ層のテスト
インフラ層のテストでは、データの永続化処理が正しく動作するかテストします。
テスト時のデータベースは、実際のデータベースを使うか、インメモリDBを使います。
| アプローチ | 説明 | メリット | デメリット |
|---|---|---|---|
| 実際のDB | PostgreSQLなどの実DBを使用 | 本番環境に近い | 遅い、環境構築が必要 |
| インメモリ | メモリ上でデータを管理 | 高速、環境不要 | 本番環境と異なる |
今回はインメモリリポジトリを使ったテストを実装します。リポジトリのインターフェース (IUserRepository) で定められたすべてのデータ操作の契約が、インメモリ環境で正しく機能することを確認します。
インメモリリポジトリとは
インメモリリポジトリとは、データベースの代わりにメモリ(Mapや配列など)を使ってデータを管理するリポジトリの実装です。
// 実際のリポジトリ: データベースを使用
class PrismaUserRepository {
async save(user: User) {
await prisma.user.create({ ... }); // データベースに保存
}
}
// インメモリリポジトリ: メモリを使用
class InMemoryUserRepository {
private users = new Map<number, User>(); // メモリに保存
async save(user: User) {
this.users.set(id, user); // Mapに保存
}
}
- メリット
- データベース不要で高速
- テストの独立性が保たれる
- 環境構築が不要
- 用途
- リポジトリの実装をテストする
- ユースケースの統合テストで使用
インメモリリポジトリの実装
テスト用のインメモリリポジトリを実装します。
import { IUserRepository } from "../../src/domain/repositories/IUserRepository";
import { User } from "../../src/domain/entities/User";
import { UserId } from "../../src/domain/value-objects/UserId";
import { Email } from "../../src/domain/value-objects/Email";
export class InMemoryUserRepository implements IUserRepository {
private users: Map<number, User> = new Map();
private nextId = 1;
async save(user: User): Promise<User> {
// 新しいIDを割り当て
const id = new UserId(this.nextId++);
// IDを持つユーザーを再構築
const savedUser = User.reconstruct(id, user.getEmail(), user.getName());
// Mapに保存
this.users.set(id.getValue(), savedUser);
return savedUser;
}
async findById(id: UserId): Promise<User | null> {
// Mapから取得
return this.users.get(id.getValue()) || null;
}
async findAll(): Promise<User[]> {
// Mapの全ての値を配列で返す
return Array.from(this.users.values());
}
async update(user: User): Promise<User> {
const userId = user.getId();
if (!userId) {
throw new Error("Cannot update user without ID");
}
// Mapを更新
this.users.set(userId.getValue(), user);
return user;
}
async delete(id: UserId): Promise<void> {
// Mapから削除
this.users.delete(id.getValue());
}
async existsByEmail(email: Email): Promise<boolean> {
// 全ユーザーを走査してメールアドレスをチェック
for (const user of this.users.values()) {
if (user.getEmail().equals(email)) {
return true;
}
}
return false;
}
// テスト用のヘルパーメソッド
clear(): void {
this.users.clear();
this.nextId = 1;
}
// テスト用: 現在のユーザー数を取得
count(): number {
return this.users.size;
}
}
実装のポイント
1. Mapを使ったデータ管理
private users: Map<number, User> = new Map();
Mapを使うことで、IDをキーとしてユーザーを管理します。
Map vs Object:
-
Map: キーに任意の値を使える、順序が保証される -
Object: キーは文字列のみ、プロトタイプ汚染のリスク
2. IDの自動採番
private nextId = 1;
async save(user: User): Promise<User> {
const id = new UserId(this.nextId++);
// ...
}
データベースのAUTO_INCREMENTを模倣しています。
3. インターフェースの実装
export class InMemoryUserRepository implements IUserRepository {
// IUserRepositoryの全メソッドを実装
}
IUserRepositoryを実装することで、実際のリポジトリと同じように使えます。
4. テスト用ヘルパーメソッド
clear(): void {
this.users.clear();
this.nextId = 1;
}
テスト間でデータをクリアするためのメソッドです。
インメモリリポジトリのテスト
インメモリリポジトリが正しく動作するかテストします。
import { describe, it, expect, beforeEach } from "vitest";
import { InMemoryUserRepository } from "../mocks/InMemoryUserRepository";
import { User } from "../../src/domain/entities/User";
import { Email } from "../../src/domain/value-objects/Email";
import { UserName } from "../../src/domain/value-objects/UserName";
import { UserId } from "../../src/domain/value-objects/UserId";
describe("InMemoryUserRepository", () => {
let repository: InMemoryUserRepository;
beforeEach(() => {
// 各テストの前に新しいリポジトリを作成
repository = new InMemoryUserRepository();
});
describe("save", () => {
it("ユーザーを保存できる", async () => {
// Arrange
const user = User.create(
new Email("test@example.com"),
new UserName("Test User")
);
// Act
const savedUser = await repository.save(user);
// Assert
expect(savedUser.hasId()).toBe(true);
expect(savedUser.getId()?.getValue()).toBe(1);
expect(savedUser.getEmail().getValue()).toBe("test@example.com");
expect(savedUser.getName().getValue()).toBe("Test User");
});
it("複数のユーザーを保存すると異なるIDが割り当てられる", async () => {
// Arrange
const user1 = User.create(
new Email("user1@example.com"),
new UserName("User 1")
);
const user2 = User.create(
new Email("user2@example.com"),
new UserName("User 2")
);
// Act
const savedUser1 = await repository.save(user1);
const savedUser2 = await repository.save(user2);
// Assert
expect(savedUser1.getId()?.getValue()).toBe(1);
expect(savedUser2.getId()?.getValue()).toBe(2);
});
});
describe("findById", () => {
it("IDでユーザーを取得できる", async () => {
// Arrange
const user = User.create(
new Email("test@example.com"),
new UserName("Test User")
);
const savedUser = await repository.save(user);
const userId = savedUser.getId()!;
// Act
const foundUser = await repository.findById(userId);
// Assert
expect(foundUser).not.toBeNull();
expect(foundUser!.getId()!.getValue()).toBe(userId.getValue());
expect(foundUser!.getEmail().getValue()).toBe("test@example.com");
});
it("存在しないIDの場合はnullを返す", async () => {
// Arrange
const userId = new UserId(999);
// Act
const foundUser = await repository.findById(userId);
// Assert
expect(foundUser).toBeNull();
});
});
describe("findAll", () => {
it("全てのユーザーを取得できる", async () => {
// Arrange
const user1 = User.create(
new Email("user1@example.com"),
new UserName("User 1")
);
const user2 = User.create(
new Email("user2@example.com"),
new UserName("User 2")
);
await repository.save(user1);
await repository.save(user2);
// Act
const users = await repository.findAll();
// Assert
expect(users).toHaveLength(2);
expect(users[0].getEmail().getValue()).toBe("user1@example.com");
expect(users[1].getEmail().getValue()).toBe("user2@example.com");
});
it("ユーザーが存在しない場合は空配列を返す", async () => {
// Act
const users = await repository.findAll();
// Assert
expect(users).toHaveLength(0);
});
});
describe("update", () => {
it("ユーザー情報を更新できる", async () => {
// Arrange
const user = User.create(
new Email("old@example.com"),
new UserName("Old Name")
);
const savedUser = await repository.save(user);
// メールアドレスと名前を変更
savedUser.changeEmail(new Email("new@example.com"));
savedUser.changeName(new UserName("New Name"));
// Act
const updatedUser = await repository.update(savedUser);
// Assert
expect(updatedUser.getEmail().getValue()).toBe("new@example.com");
expect(updatedUser.getName().getValue()).toBe("New Name");
// 再取得して確認
const foundUser = await repository.findById(savedUser.getId()!);
expect(foundUser!.getEmail().getValue()).toBe("new@example.com");
expect(foundUser!.getName().getValue()).toBe("New Name");
});
it("IDがないユーザーの更新はエラーをスローする", async () => {
// Arrange
const user = User.create(
new Email("test@example.com"),
new UserName("Test User")
);
// Act & Assert
await expect(repository.update(user)).rejects.toThrow(
"Cannot update user without ID"
);
});
});
describe("delete", () => {
it("ユーザーを削除できる", async () => {
// Arrange
const user = User.create(
new Email("test@example.com"),
new UserName("Test User")
);
const savedUser = await repository.save(user);
const userId = savedUser.getId()!;
// Act
await repository.delete(userId);
// Assert
const foundUser = await repository.findById(userId);
expect(foundUser).toBeNull();
});
it("存在しないユーザーの削除でもエラーにならない", async () => {
// Arrange
const userId = new UserId(999);
// Act & Assert
await expect(repository.delete(userId)).resolves.not.toThrow();
});
});
describe("existsByEmail", () => {
it("メールアドレスが存在する場合はtrueを返す", async () => {
// Arrange
const user = User.create(
new Email("test@example.com"),
new UserName("Test User")
);
await repository.save(user);
// Act
const exists = await repository.existsByEmail(
new Email("test@example.com")
);
// Assert
expect(exists).toBe(true);
});
it("メールアドレスが存在しない場合はfalseを返す", async () => {
// Act
const exists = await repository.existsByEmail(
new Email("nonexistent@example.com")
);
// Assert
expect(exists).toBe(false);
});
it("複数のユーザーがいても正しく判定できる", async () => {
// Arrange
await repository.save(
User.create(
new Email("user1@example.com"),
new UserName("User 1")
)
);
await repository.save(
User.create(
new Email("user2@example.com"),
new UserName("User 2")
)
);
// Act
const exists1 = await repository.existsByEmail(
new Email("user1@example.com")
);
const exists2 = await repository.existsByEmail(
new Email("user2@example.com")
);
const exists3 = await repository.existsByEmail(
new Email("user3@example.com")
);
// Assert
expect(exists1).toBe(true);
expect(exists2).toBe(true);
expect(exists3).toBe(false);
});
});
describe("clear", () => {
it("全てのデータをクリアできる", async () => {
// Arrange
await repository.save(
User.create(
new Email("user1@example.com"),
new UserName("User 1")
)
);
await repository.save(
User.create(
new Email("user2@example.com"),
new UserName("User 2")
)
);
// Act
repository.clear();
// Assert
const users = await repository.findAll();
expect(users).toHaveLength(0);
expect(repository.count()).toBe(0);
// 次に保存するユーザーのIDは1から始まる
const newUser = await repository.save(
User.create(
new Email("new@example.com"),
new UserName("New User")
)
);
expect(newUser.getId()?.getValue()).toBe(1);
});
});
});
統合テストにおけるインメモリリポジトリの活用
インメモリリポジトリは、統合テストでも活用できます。
import { describe, it, expect, beforeEach } from "vitest";
import { CreateUserUseCase } from "../../src/application/usecases/CreateUserUseCase";
import { InMemoryUserRepository } from "../mocks/InMemoryUserRepository";
describe("CreateUserUseCase 統合テスト", () => {
let repository: InMemoryUserRepository;
let useCase: CreateUserUseCase;
beforeEach(() => {
repository = new InMemoryUserRepository();
useCase = new CreateUserUseCase(repository);
});
it("ユーザーを作成できる", async () => {
// Act
const result = await useCase.execute({
email: "test@example.com",
name: "Test User",
});
// Assert
expect(result.id).toBe(1);
expect(result.email).toBe("test@example.com");
expect(result.name).toBe("Test User");
// リポジトリに保存されているか確認
const users = await repository.findAll();
expect(users).toHaveLength(1);
});
it("重複したメールアドレスの場合はエラーになる", async () => {
// Arrange: 1人目を作成
await useCase.execute({
email: "test@example.com",
name: "User 1",
});
// Act & Assert: 2人目(同じメールアドレス)でエラー
await expect(
useCase.execute({
email: "test@example.com",
name: "User 2",
})
).rejects.toThrow("Email already exists");
// リポジトリには1人だけ
const users = await repository.findAll();
expect(users).toHaveLength(1);
});
});
インメモリリポジトリを使うことで、以下のメリットがあります。
- 実際の動作を確認: モックではなく実際のリポジトリ実装を使用
- 高速: データベース不要で高速
- エンドツーエンド: ユースケースからリポジトリまでの一連の流れを確認
まとめ
今回は、インフラ層(リポジトリ)のテストを実装しました。
- ポイント
- インメモリリポジトリ: メモリ上でデータを管理する軽量な実装
- 高速なテスト: データベース不要で高速に実行
- 統合テストでの活用: ユースケースのエンドツーエンドテストにも使用可能
- インターフェースの実装: 実際のリポジトリと同じように使える
- インメモリリポジトリのメリット
- データベース環境が不要
- テストが高速に実行できる
- テスト間の独立性が保たれる
- ユースケースの統合テストにも使える
次回は、プレゼンテーション層(コントローラー)のテストを実装します。