1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

テストにおいてインターフェースはいつ必要か

Posted at

これは

「単体テストの考え方/使い方」 という技術書を読んでいて、インターフェースを導入するべきタイミングと、不要なケースについて学んだので、具体的なコード例とともに整理します。

インターフェースを作成するべきとき

1. 複数の実装があるとき

複数の処理パターンを切り替えるなど、抽象化そのものがドメイン上の意味を持つ場合はインターフェースを定義します。

2. 外部依存性を持つクラスをテストするとき

外部システムやI/O(DB・SMTP・APIクライアントなど)を扱うクラスは、モックを使うためにインターフェースを作成します。

インターフェースがなくてもモックは可能ですが、以下の欠点があります

  • 具象クラスの中身を直接 mock で置き換えるため、実装とテストが密結合し壊れやすい
  • テストコード内で具象クラスを new して上書きするため、テストの意図が分かりづらい
  • TypeScriptでは Property 'xxx' is not assignable... のような型エラーが起きやすい

インターフェースを作るべきでないとき

単一の実装しかない場合(特にドメインクラスなど)は不要。
ストラテジーパターンなど複数実装が生じた時点で導入すれば十分

実装例

テスト対象クラス(SUT)

class UserNotifier {
  constructor(private emailService: any) {}

  notifyUser(email: string) {
    this.emailService.send(email, "Welcome!", "Thanks for joining us!");
  }
}

class EmailService {
  send(to: string, subject: string, body: string) {
    console.log(`Send to ${to}: ${subject}`);
  }
}

Before:インターフェースがない場合

import { vi, describe, it, expect } from "vitest";

describe("UserNotifier without interface", () => {
  it("calls EmailService.send()", () => {
    // EmailServiceの実体を生成
    const emailService = new EmailService();

    // sendメソッドを上書きしてモック化(型エラーになりやすい)
    emailService.send = vi.fn();

    const notifier = new UserNotifier(emailService);
    notifier.notifyUser("test@example.com");

    expect(emailService.send).toHaveBeenCalledWith(
      "test@example.com",
      "Welcome!",
      "Thanks for joining us!"
    );
  });
});

問題点まとめ

項目 内容
型安全性 emailService.send = vi.fn()Property 'send' is not assignable... のような型エラーを誘発しやすい
実装依存 EmailService のメソッド構造(send()の存在)に依存しており、メソッド名変更などの実装変更で壊れる
テスト意図の曖昧さ 実体を new しつつモック化しているため、「UserNotifier単体をテストしているのか」「EmailServiceも含めているのか」が曖昧

After:インターフェースを定義した場合

// ---- interface.ts ----
export interface IEmailService {
  send(to: string, subject: string, body: string): void;
}

// ---- notifier.ts ----
export class UserNotifier {
  constructor(private emailService: IEmailService) {}

  notifyUser(email: string) {
    this.emailService.send(email, "Welcome!", "Thanks for joining us!");
  }
}

// ---- notifier.test.ts ----
import { describe, it, expect, vi } from "vitest";
import { IEmailService, UserNotifier } from "./notifier";

describe("UserNotifier with interface", () => {
  it("calls IEmailService.send()", () => {
    // インターフェースに沿ったモックを直接定義
    const mockEmailService: IEmailService = {
      send: vi.fn(),
    };

    const notifier = new UserNotifier(mockEmailService);
    notifier.notifyUser("test@example.com");

    expect(mockEmailService.send).toHaveBeenCalledWith(
      "test@example.com",
      "Welcome!",
      "Thanks for joining us!"
    );
  });
});

変更点(Before → After)

観点 Before(インターフェースなし) After(インターフェースあり)
モック作成 実体を new してメソッド上書き オブジェクトリテラル { send: vi.fn() } でOK
型安全性 メソッド上書きで型エラー IEmailService が型保証してくれる
依存関係 具象クラスに直接依存 抽象契約(インターフェース)に依存
テスト意図 どこまでがSUTか曖昧 「UserNotifier単体」を明確にテストできる

判断基準まとめ

条件 結論
複数実装を切り替える可能性がある ✅ インターフェースを定義
外部システム/APIをモック化したい ✅ インターフェースを定義
単一の内部クラスで完結(純粋なドメインロジック) ❌ インターフェース不要

まとめ

  • インターフェースは「疎結合化のための道具」ではなく、「契約を明示する仕組み」
  • その契約がテストの対象(外部依存・複数実装)になるなら導入すべき
  • 単一実装の内部ロジックに付けるのは YAGNI(You Aren’t Gonna Need It)違反
  • インターフェースは「とりあえず作るもの」ではなく、テスト戦略や設計意図を明確にし、必要なときに、意図を持って導入するべき。

参考文献

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?