Tabelog Tech Blog

食べログの開発者による技術ブログです

食べログiOSアプリでユニットテストを書けるようにしていく話

この記事は 食べログアドベントカレンダー2023 の22日目の記事です🎅🎄

こんにちは。食べログシステム本部アプリ開発部の大澤です。
今年のiOSDC Japan 2023の参加レポートに続き、2回目の投稿となります。

今回は、所属している基盤チームにて普段取り組んでいる活動についてお話しします。
その中でも、チーム内ではテスタブル化と呼んでおり、プロダクトコードをテストしやすくするための取り組みについて取り上げます。

目次

背景

食べログのiOSアプリの歴史は長く、積極的な開発によってコードベースは年々大きくなってきています。
一方で案件優先の体制なためユニットテストは少なく、一部のモデルやユーティリティに対してのみテストが書かれているアンバランスな状態になっていました。
やはりユニットテストがないと基盤チームでは保守作業として実施する開発環境やライブラリのアップデートなどに不安があり、また、リファクタリングや新技術の導入などの大きな構成変更も意図しない箇所での影響が懸念されなかなか進められない状態でした。

そのため、まずはユニットテストを書き始めようとしました。
しかし、現状のコードでは以下のような問題があり、テストが書きづらい状態でした。

  • メソッド内での多数のインスタンス生成
  • シングルトンパターンのインスタンスやstaticアクセスの多用
  • Apple標準ライブラリを含む3rd partyライブラリへの強い依存
  • Service Locatorパターンとの混在

今回のお話では、これらの問題を解決するため具体的にどのような対応をしているのかをサンプルコードを交えてお話ししていきます。

課題例

まずはイメージしやすいように、以下のコードを例に実際の食べログのコードでも遭遇した問題についてお話しします。

final class SampleUseCase {
  // 「1」
  func getHoge() -> String {
    let hogeRepository = HogeRepository()
    return hogeRepository.read()
  }

  // 「2」
  func getFuga() -> String {
    FugaManager.shared.read()
  }

  // 「3」
  func getPiyo() -> String {
    PiyoLibrary.read()
  }
}

上記のコードでは3つのメソッドがありますが、どれもこのままではテストが難しい状態です。

  • 「1」については、メソッド内でインスタンスを生成しているため、テスト時にモックへ差し替えることができません。
    単純なクラスであればそのままでも問題ないですが、複雑なロジックを持ったクラスの場合はそのクラス自体のテストも含めて考慮する必要があり、テストコードの記述が煩雑になったりそもそも実行できなかったりといった場合があります。
  • 「2」については、シングルトンパターンのインスタンスを利用しているため、こちらもテスト時にモックへ差し替えることができず、同様にテストが難しい状態です。
  • 「3」については、Staticアクセスで外部依存しており、こちらもテスト時にモックへ差し替えることができません。
    もし対象のクラスが自作クラスであればインスタンスメソッドで実装し直すことにより「1」と同等な状態になりますが、Apple標準ライブラリや3rd partyライブラリの場合はそうもいきません。

食べログのiOSアプリでは上記のようなコードが多く存在しており、テストが難しい状態になっていました。
そこで、これらのコードをテストしやすくするために、後述する依存性注入に基づいたテスタブル化を進めていきました。

テスタブル化の手法

基本: Dependency Injection

まずは基本的な手法として、Dependency Injection(依存性注入)について触れます。
前述のように、外部への依存は直接持つと結合度が上がりテストを難しくするため、外部から依存を注入できるようにすることで結合度を下げることができます。
(つまり、他クラスへの静的な参照やインスタンス生成は避けるということ)

依存性注入にはいくつかの手法がありますが、ここではConstructor Injectionを採用します。
これにより、「1」「2」は以下のように書き換えることができます。

protocol HogeRepositoryProtocol {
  func read() -> String
}
extension HogeRepository: HogeRepositoryProtocol {}

protocol FugaManagerProtocol {
  func read() -> String
}
extension FugaManager: FugaManagerProtocol {}

final class SampleUseCase {
  private let hogeRepository: HogeRepositoryProtocol
  private let fugaManager: FugaManagerProtocol

  init(
    hogeRepository: HogeRepositoryProtocol,
    fugaManager: FugaManagerProtocol
  ) {
    self.hogeRepository = hogeRepository
    self.fugaManager = fugaManager
  }

  // 「1'」
  func getHoge() -> String {
    hogeRepository.read()
  }

  // 「2'」
  func getFuga() -> String {
    fugaManager.read()
  }
  ...
}

これにより、直接的なクラスへの依存はなくなりテストしやすい形となりました。
プロダクトコードでの利用時にはインスタンスの生成やシングルトンインスタンスを適切に渡し、一方でテスト時にはモックを渡すことでテストが可能になります。
そのほかの利点として、外部への依存先がinitの引数として明示的になることで依存関係が明確になっています。

また、今回の課題例では触れていませんでしたが、既存の食べログのコードではService Locatorパターンとの混在も問題となっています。
詳細は省きますが、こちらも内部でService Locatorから取り出すのではなく、外部で取り出した後にConstructor Injectionでの依存注入によって解決できます。
これにより、Service Locatorパターンの欠点として挙げられる依存の不明瞭さも解消されます。

応用1: 静的アクセスの依存注入

古の時代であるObjective-Cでは、モックライブラリで簡単に静的アクセスの依存を差し替えることができましたが、Swiftでは静的アクセスの依存を差し替えることができません。
そこで、モックライブラリによる直接的な差し替えは諦め、Genericsを利用することで静的アクセスの依存を注入する方法を採用しました。
これにより、「3」は以下のように書き換えることができます。

protocol PiyoLibraryProtocol {
  static func read() -> String
}
extension PiyoLibrary: PiyoLibraryProtocol {}

final class SampleUseCase<Piyo: PiyoLibraryProtocol> {
  ...

  // 「3'」
  func getPiyo() -> String {
    Piyo.read()
  }
}


// MARK: - 利用時
let useCase = SampleUseCase<PiyoLibrary>()

// MARK: - テスト時
final class MockPiyoLibrary: PiyoLibraryProtocol {
  static func read() -> String {
    "test value"
  }
}
let testUseCase = SampleUseCase<MockPiyoLibrary>()

このようにすることで、静的アクセスであってもテスト時にモックへ差し替えることができます。
上記の場合は依存対象が1つだけでしたが、複数の依存対象がある場合でも同様に対応可能です。 しかし、Constructor Injectionに比べると記述が煩雑であり、依存対象が増えると型引数の数も増えてしまうため限定的な使用に留めることが望ましいです。
また、このクラスの利用側では型引数を指定する必要があるので、状況に応じてtypealiaseを定義したり、型消去により実際のクラスを隠蔽して整理したりなどの工夫をしても良いでしょう。

応用2: Bastard Injection

まず前提として断り書きを書きますが、この手法は一般的にはアンチパターンに分類されるものです。
そのため、特性を理解した上で、適切な場面でのみ利用するようにしてください。

Bastard Injectionとは、依存性注入の手法の1つで、依存性を注入する際に依存性のインスタンス生成自体をセットで定義する手法です。
前述までのコードは以下のようになります。

  ...
  init(
    hogeRepository: HogeRepositoryProtocol = HogeRepository(),
    fugaManager: FugaManagerProtocol = FugaManager.shared
  ) {
    self.hogeRepository = hogeRepository
    self.fugaManager = fugaManager
  }
  ...

一般的にはDIコンテナを利用して依存性を管理することが多いですが、現時点では食べログのiOSアプリはDIコンテナを利用せずにテスタブル化を進めています。

というのも、プロダクト側への大きな影響を避けるため細かいリリース単位での進行が要求されるため、初めからDIコンテナを前提とした構成を全適応するのが難しいと判断したためです。
Bastard InjectionではDIコンテナを利用せずに依存性の管理ができ、モジュールが複数に分かれていても個別にテスタブル化を進めることができるという手軽さがあります。

また、DIコンテナ導入にはコードの変更が大きく、テストが少ない現状の初手として導入するにはリスクが大きいという点もあります。
ひとまずBastard Injectionでの実装段階でテスタブル化は達成できるため、十分にテストを書いた後にあらためてDIコンテナ導入を検討できます。

ただし、前述したようにアンチパターンとしても扱われる手法であり、故意に依存性を差し替えたり、依存の切り離しが厳密にはできておらず妥協している箇所があったりするのも現実です。
現時点ではBastard Injectionを利用してテストが書けるようになることを優先して対応していますが、将来的にはDIコンテナ導入を検討していきたいと考えています。

応用3: 初期化の遅延

前述までの工程で、依存はすべて初期化時に渡されるようにはなり、テストも動かせるようになりました。
しかし、実際にアプリを起動してみるとクラッシュを発生する箇所がいくつかありました。
原因を調べてみると、既存ではシングルトンパターンなどで実行時に動的に依存を参照していた箇所が、すべて初期化時に静的に参照される形になり、その中で一部の依存関係が循環参照を起こしていました。

final class A {
  static let shared = A()
  private let b: B
  init(b: B = .shared) {
    self.b = b
  }
  ...
}

final class B {
  static let shared = B()
  private let c: C
  init(c: C = .shared) {
    self.c = c
  }
  ...
}

final class C {
  static let shared = C()
  private let a: A
  init(a: A = .shared) { // <- ここで循環参照
    self.a = a
  }
  ...
}

そこで、依存が循環している箇所を特定し、lazy変数として定義し初期化処理を遅延させることで回避しました。
循環参照を起こしていた箇所は以下のように書き換えることができます。

...
final class C {
  static let shared = C()
  private lazy var a: A = .shared
  init(a: A? = nil) {
    // テストでAを差し替えることを考慮しつつ、それ以外ではlazyで定義された遅延初期化されたインスタンスを使用する
    guard let a else { return }
    self.a = a
  }
  ...
}
...

このようにすることで、アプリ起動時には循環参照を起こさず、一方でテスト時には依存を差し替えることができるようになりました。

ただし、あくまでも循環参照を回避するための対応であり、お世辞にも綺麗なコードとはいえません。
定数ではなく変数としての定義になるため、クラスの内部からは変更したり再代入できるため注意が必要です。
また、Bastard Injection全般にもいえますが、外部からテスト目的以外でも依存を差し替えることができるため、本来の利用方法と異なる依存を注入されることを防ぐためにもデバッグマクロでの制御などを検討しても良いでしょう。

そもそも循環参照を起こさないように設計するのが望ましいですが、既存のコードを大きく変更することなく対応するという方針で今回は対応しました。
また、こちらはBastard Injectionを利用しているために起きた問題であり、DIコンテナを利用している場合はライブラリによっては遅延初期化がサポートされている場合があり、そちらを利用することをお勧めします。

まとめ

今回は、食べログのiOSアプリで取り組んでいるテスタブル化についてお話ししました。

具体的な手法や、取り組みで発生した問題とその回避策についてのお話となりましたが、いかがでしたでしょうか。
テストが書きづらいなと困っているエンジニアの参考になれば幸いです。

明日は shohei yamamoto さんの「設計書を書かない設計で開発効率を向上させた話」です。お楽しみに!