🫠

[DI] なぜ依存を注入するのか(テストコードつき)

2024/09/09に公開

依存注入(DI)は、テストのしやすさやコードの保守性を向上させる重要な設計パターンです。本記事では、依存注入の実践方法を紹介しテストや設計をより効果的にする方法について解説します。

※本記事では、Flutterのriverpodを使ってコードを書きますが、他言語でも読めるようにコメントアウトを多めにしました。

こんな人におすすめ

・依存注入(DI)のメリットを説明できない
・テスト・ダブルでモックに置き換える理由を説明できない
・コンストラクタ依存注入を説明できない
・そもそもDIを使ったことがない

⭐️ コード例を交えながら、DIについて学びテストコードまで確認できます。

参考

本記事では、以下の書籍を参考にしながら説明します。
また、参考になる部分がかなり多く、購入されることをお勧めします。

https://www.amazon.co.jp/%E5%8D%98%E4%BD%93%E3%83%86%E3%82%B9%E3%83%88%E3%81%AE%E8%80%83%E3%81%88%E6%96%B9-%E4%BD%BF%E3%81%84%E6%96%B9-Vladimir-Khorikov/dp/4839981728

依存注入とはなにか

ソフトウェア設計の原則やパターンを集めたものであり、疎結合になるコーディングを促すというものです。 その恩恵としてテストがしやすくなり、コードに対する保守性が高まります。
また、インターフェイスに対してプログラミングするということは、依存注入をする上での重要な考え方です。

その具体例を、以下で見ていこうと思います。

テスト・ダブルとは

インターフェイスの実装クラスで、実際に使われる具象クラスではなく、代替のクラスのことを
テスト・ダブルと呼びます。

また、テストコードについて詳しく知りたい方は、こちらにまとめておりますので
ぜひ、一読ください。

https://zenn.dev/renren0112/articles/88111b123029d7

⭐️ どんなときに有効か

実際に使われる依存がまだ存在していないケースやテスト時に実際の実装クラスを使うことが好ましくないケースに有効です。

テスト・ダブルに置き換えるべき対象

結論、揮発性依存に対して、テスト・ダブルで置き換えられるようにします。

感覚的には、返り値が予測できないものはテスト・ダブルに置き換えたほうがいいです。
なぜなら、こちらでテスト時に、返す値を制御して、予測可能にしたいからです。

依存の種類を、以下に示しました。

安定依存

安定依存とは、最初から存在、決定的なふるまいを必要としない依存のことです。これらの依存は、置き換えたり、内包したり、装飾したりする必要がなく、外部からの介入も不要です。

  • 数値の加算や乗算を行うシンプルな計算ロジック

  • yyyy-MM-dd形式に変換するフォーマッター

これらは、どんな値が返ってくるかが予測可能で、コード内で直接使用しても安全なため安定依存とみなされます。

揮発性依存

揮発性依存とは、アプリケーションを適切に動作させるために、実行環境の設定や調整が必要な依存のことです。これらの依存は、予測できない振る舞いをすることがあり、そのためにテストや運用環境での取り扱いが難しくなります。

  • データベース(DB)
    実行環境に依存し、テスト時に異なる結果を返す可能性がある。

  • 開発中の具象クラス
    完全に実装されていないため、予期しない動作をする可能性がある。

  • 高価なサードパーティ製のライブラリ
    その動作が環境やライセンスに依存する場合がある。

  • 非決定的な振る舞いを持つもの
    日付やランダム値など、実行時に異なる結果を返すもの。

例えば、日付は毎日異なる値が返ってきます。これでは、テストする日によって値が変わるため、テスト結果が安定しません。このような場合、テストを安定させるために、固定値を返すように制御する必要があります。

密結合について

以下では、直接インスタンスを作成しました。

class GrpcHandlerRepositoryImpl implements GrpcHandlerRepository {
  final ClientChannel Connectivity();  ←  直接インスタンス化

疎結合について

上記の密結合とは異なり、プロバイダー経由でコンストラクタ依存注入を行いました。

class GrpcHandlerRepositoryImpl implements GrpcHandlerRepository {
  final ClientChannel _channel;  ←  コンストラクタ注入

  GrpcHandlerRepositoryImpl(this._connectivity) {

テスト・ダブルがテストコードで活躍する事例

ここで疑問になるのが、テストでどうやって活躍するのか? だと思います。
以下のコードで理解できるはずです。

本記事は AAAパターン を使ったテスト手法で書きます。
モックやスタブの使い方が、曖昧な方はこちらをご参照ください。

① 疎結合の場合、本物のConnectivity の代わりにテスト対象のクラスにMockを注入する。

mockConnectivity = MockConnectivity();

② テストで、Connectivityを arrange フェーズで制御する

// mockConnectivity が正常にインターネットにつながっている設定にする。
test('[正常系] callGrpcMethod', () async {
      // arrange
      when(mockConnectivity.checkConnectivity())
          .thenAnswer((_) async => ConnectivityResult.wifi);


// mockConnectivity がインターネットにつながっていない設定にする。
test('[異常系] callGrpcMethod 電波がないときはnetworkErrorを吐くこと', () async {
      // arrange
      when(mockConnectivity.checkConnectivity())
          .thenAnswer((_) async => ConnectivityResult.none);

上記のように、モックを注入することで、正常系と異常系のテストが容易に書けるようになりました。
密結合の場合は、モックを注入できないのでテストがかけません。

これは コードの品質や信頼に大きな差がでますよね⭐️

依存注入の種類について(超簡単な例)

コンストラクタ経由での注入(Constructor Injection)

最もよく使う依存注入の方法だと思います。
最初に検討するようにしましょう。

class TaskListViewModel {
  final TaskUsecase taskUsecase;
  ///  コンストラクタで依存関係を注入
  TaskListViewModel(this.taskUsecase);
  Future<void> loadTasks() async {
    final tasks = await taskUsecase.getTasks();
    // その他の処理
  }
}

// 使用例
void main() {
  final usecase = TaskUsecase();
  final viewModel = TaskListViewModel(usecase);  ← コンストラクタで渡す。
  viewModel.loadTasks();
}

メソッド経由での注入(Method Injection)

依存の注入が合成基点(main関数)で行うことが出来ないようなメソッドの呼び出し時に、動的に行う必要がある場合に有効です。

依存が本来向いてはならない方向へ向いてしまう可能性があります。
たとえば、ドメイン層にUI層への依存が発生してしまったりするので、注意しながら使用しましょう。

class TaskListViewModel {
  Future<void> loadTasks(TaskUsecase taskUsecase) async {
    final tasks = await taskUsecase.getTasks();
  }
}

// 使用例
void main() {
  final usecase = TaskUsecase();
  final viewModel = TaskListViewModel();
  viewModel.loadTasks(usecase); ← 引数で渡す。
}

プロパティ経由での注入(Property Injection)

基本的にはあまり使わないパターンです。
依存を注入することが任意の場合には有効な手段になり得えます。

class TaskListViewModel {
  // プロパティとして依存関係を持つ
  late TaskUsecase taskUsecase;
  Future<void> loadTasks() async {
    final tasks = await taskUsecase.getTasks();
  }
}

// 使用例
void main() {
  final usecase = TaskUsecase();
  final viewModel = TaskListViewModel();
  viewModel.taskUsecase = usecase; ←  プロパティに依存関係を注入
  viewModel.loadTasks();
}

オブジェクトの生存戦略について知ろう!

名前 概要
シングルトン生存戦略 1つのインスタンスが何度も利用される
短命な生存戦略 新しいインスタンスが常に提供される
スコープ指定生存戦略 暗黙的、もしくは、明示的に定義されたスコープごとに、各型のインスタンスが最大1つ提供される

riverpodを使う方は、追跡が可能なので、ぜひこちらもご覧ください!

https://zenn.dev/renren0112/articles/d26faed3bcdee9

生存戦略によるバグ回避

捕らわれた依存

シングルトンのライフサイクルを持つオブジェクトが、短命なオブジェクト(たとえば、リクエストごとに生成されるオブジェクトや画面ごとのオブジェクト)に依存する場合です。

💡解決法

スコープ指定生存戦略をとったオブジェクトを作成して、短命な生存戦略をとったオブジェクトが注入されるようにする。

漏洩する抽象

依存の生成を遅らせようとした場合。例えば、Lazy<T>を依存注入して、その生成の責務を注入されるクラスに押し付ける(Tクラスが初めて値を渡すまでオブジェクト生成を延期する)。

💡解決法

ProxyパターンやCompositeパターンを用いて、オブジェクトの生成を遅延させる仕組みを隠すことで、呼び出し元のコードがその遅延に影響されないようにする。たとえば、UserManagerクラスがUserProfileを使用する場合、UserProfileの生成が遅延されることをUserManagerが意識する必要がないようにします。これにより、UserProfileの生成タイミングなどの実装の詳細がUserManagerに漏れることを防ぎ、コードの設計をシンプルで保守しやすいものにする。

スレッドごとの生存戦略

対応中のリクエストの処理が途中から別のスレッド上で行われることになった場合に、リクエストを処理しているオブジェクトが同じ依存を参照していても、その依存が元のスレッドに結びついたままになってしまう。

💡解決法

リクエストや処理に対して生存期間のスコープを定める。依存の生存期間をスレッドの生存期間に結び付けるのではなく、リクエストの生存期間に結びつける。

DIを使いこなして、クリーンなアーキテクチャ構成にしよう!

https://www.nuits.jp/entry/easiest-clean-architecture-2019-09

上記の記事をかなり参考にしながら、私のプロジェクトのアーキテクチャーを組みました。
特に、依存逆転でusecaseに安定を求めるという箇所は、gRPC通信のモジュールでもかなり有効でした。

DIを行いながら、保守性の高いコードを書くTipsになるとおもいますのでぜひ⭐️

まとめ

DIは、疎結合コーディングを可能にし、その副作用としてテストが書きやすくなります。
そうなることで、プロジェクトのコードの品質が大きく変わってきます。

本を読む前では、気づけなかったアンチパターンが、複数ありました。
読み進めながら、リファクタリングすることで大きな気づきを得ました。
上記で、紹介した本はかなりおすすめです。

また、Flutterではプロバイダパターンをよく利用したりとまだまだ奥が深いので、どんどんレベルアップしていきたいですね!

Discussion