0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Vitest】TypeScript / Expressにおけるドメイン駆動設計の各層別テスト実装 Part3: インフラ層

Posted at

はじめに

この記事は、以下の記事の続きです。

今回は、インフラ層のテストを実装します。

開発環境

開発環境は以下の通りです。

  • 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に保存
  }
}
  • メリット
    • データベース不要で高速
    • テストの独立性が保たれる
    • 環境構築が不要
  • 用途
    • リポジトリの実装をテストする
    • ユースケースの統合テストで使用

インメモリリポジトリの実装

テスト用のインメモリリポジトリを実装します。

tests/infrastructure/InMemoryUserRepository.test.ts
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;
}

テスト間でデータをクリアするためのメソッドです。

インメモリリポジトリのテスト

インメモリリポジトリが正しく動作するかテストします。

__tests__/infrastructure/InMemoryUserRepository.test.ts
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);
    });
  });
});

統合テストにおけるインメモリリポジトリの活用

インメモリリポジトリは、統合テストでも活用できます。

__tests__/integration/CreateUserIntegration.test.ts
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);
  });
});

インメモリリポジトリを使うことで、以下のメリットがあります。

  • 実際の動作を確認: モックではなく実際のリポジトリ実装を使用
  • 高速: データベース不要で高速
  • エンドツーエンド: ユースケースからリポジトリまでの一連の流れを確認

まとめ

今回は、インフラ層(リポジトリ)のテストを実装しました。

  • ポイント
    • インメモリリポジトリ: メモリ上でデータを管理する軽量な実装
    • 高速なテスト: データベース不要で高速に実行
    • 統合テストでの活用: ユースケースのエンドツーエンドテストにも使用可能
    • インターフェースの実装: 実際のリポジトリと同じように使える
  • インメモリリポジトリのメリット
    • データベース環境が不要
    • テストが高速に実行できる
    • テスト間の独立性が保たれる
    • ユースケースの統合テストにも使える

次回は、プレゼンテーション層(コントローラー)のテストを実装します。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?