保守性とテストしやすさのための依存性注入

保守性とテストしやすさのための依存性注入

密結合なTypeScriptクラスを、モックでテストできるクラスへとリファクタリングする。給与計算機、システムクロック、SESを対象に依存性注入を適用する。

Takahiro Iwasa
6 min read

依存性注入(Dependency Injection)は、コードを疎結合に保ちテストしやすくするための一つの方法です。小さなTypeScriptの例で見ていきます。

密結合の例

以下の例は、TypeScriptで書かれた従業員管理機能を示しています。単純化のため、この例ではモックを動的に設定できないものとします。

export class Salary {
readonly employeeId: number;
constructor(employeeId: number) {
this.employeeId = employeeId;
}
calculate(): number {
let salary = 0;
// ...
salary = 200000;
return salary;
}
}
export class Employee {
private employeeId: number;
private name: string;
private salary: Salary;
constructor(employeeId: number, name: string) {
this.employeeId = employeeId;
this.name = name;
this.salary = new Salary(this.employeeId);
}
// Send an email by Amazon SES. Message text depends on time.
notify(): void {
const hour = (new Date()).getHours();
let title = `Hi ${this.name}`;
const body = `Current Salary: ${this.salary.calculate()}`;
if (6 <= hour && hour <= 9) {
title = `Good morning ${this.name}`;
} else if (10 <= hour && hour <= 18) {
title = `How's it going, ${this.name}?`;
}
(new SES()).sendEmail({title: title, body: body});
}
}

問題点は次のとおりです。

  • Salaryクラスとの密結合:
    • this.salary = new Salary(this.employeeId);の行が、EmployeeクラスとSalaryクラスを直接結合させています。
    • Employee#notifyのテストは、実際のSalary#calculateメソッドに依存するため難しくなります。異なる給与計算をシミュレートしたり、テスト時にエッジケースを扱ったりするのが困難になります。
  • システムクロックとの密結合:
    • const hour = (new Date()).getHours();の行が、Employeeクラスをシステムクロックと結合させています。
    • 特定の時刻に対する条件分岐のテストが難しくなります。
  • AWS SESとの密結合:
    • (new SES()).sendEmail(...)の行が、EmployeeをAWS SESサービスと直接結合させています。
    • notifyメソッドをテストすると実際にメールが送信されてしまい、開発環境では現実的ではない場合があります。

依存性注入によるリファクタリング

**依存性注入(DI)**を使うことで、コードをよりモジュール化しテストしやすくできます。

export interface ISalary {
readonly employeeId: number;
calculate(): number;
}
export interface ISystemDate {
now(): Date;
}
export interface IMailer {
send(config: any): void;
}
export class Salary implements ISalary {
readonly employeeId: number;
constructor(employeeId: number) {
this.employeeId = employeeId;
}
calculate(): number {
let salary = 0;
// ...
salary = 200000;
return salary;
}
}
export class SystemDate implements ISystemDate {
now(): Date {
return new Date();
}
}
export class EmployeeSes implements IMailer {
send(config: any): void {
(new SES()).sendEmail(config);
}
}
export class Employee {
private employeeId: number;
private name: string;
private salary: ISalary;
constructor(employeeId: number, name: string, salary: ISalary) {
this.employeeId = employeeId;
this.name = name;
this.salary = salary;
}
// Send an email by Amazon SES. Message text depends on time.
notify(systemDate: ISystemDate, mailer: IMailer): void {
const hour = systemDate.now().getHours();
let title = `Hi ${this.name}`;
const body = `Current Salary: ${this.salary.calculate()}`;
if (6 <= hour && hour <= 9) {
title = `Good morning ${this.name}`;
} else if (10 <= hour && hour <= 18) {
title = `How's it going, ${this.name}?`;
}
mailer.send({title: title, body: body});
}
}

主な改善点は次のとおりです。

  • 結合度の低減:
    • SalarySystemDateEmployeeSesといったコンポーネントが、インターフェース経由で注入されるようになりました。
    • Employeeクラスは、もはや特定の実装に直接依存していません。
  • テストの容易化:
    • ISalaryISystemDateIMailerのモック実装をテストに使用できます。
    • クロックやSESサービスといったシステム依存が分離されました。
  • 依存性逆転の原則:
    • 上位モジュール(Employee)が、下位モジュール(SalaryDateSES)に依存しなくなります。

まとめ

Employeeクラスをリファクタリングし、コンストラクタとメソッドでISalaryISystemDateIMailerを受け取るようにしたことで、Salary、システムクロック、SESに密結合していたクラスが、この3つすべてをモックでテストできるクラスに変わりました。new Date()のようなありふれたものに対してまでISystemDateを導入するのは、一見やりすぎに見えるかもしれません。しかし、時刻に依存するロジックのテストを信頼できないものにしているのは、まさにこうした見落としがちな小さな依存です。これがなければ、「Good morning」の分岐を検証するテストは、たまたま朝に実行されたときにしか通りません。このパターンは、注入する対象がクラスであれ、システム依存であれ、SESのような外部サービスであれ、同じように応用できます。使用箇所でインターフェースを定義し、具体的な実装は内部で生成するのではなく、呼び出し側から差し替えられるようにする、という点が要です。

About the author

Takahiro Iwasa

Takahiro Iwasa

Software Developer

This blog shares technical notes from hands-on projects—architecture, implementation, and AWS service integrations.