これは
「単体テストの考え方/使い方」 という技術書を読んでいて、インターフェースを導入するべきタイミングと、不要なケースについて学んだので、具体的なコード例とともに整理します。
インターフェースを作成するべきとき
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)違反
- インターフェースは「とりあえず作るもの」ではなく、テスト戦略や設計意図を明確にし、必要なときに、意図を持って導入するべき。
参考文献