RIBs アーキテクチャにおける Unit Test への取り組み

💁🏻 ※本記事は Mobility Technologies の前身である JapanTaxi 時代に公開していたもので、記事中での会社やサービスに関する記述は公開当時のものです。

はじめに

「JapanTaxi」iOS アプリは RIBs アーキテクチャを採用しています。RIBs アーキテクチャを採用してから一年以上経ち、徐々に Unit Test も充実してきました。この記事では、RIBs アーキテクチャにおいて、動作をどのように検証し品質を担保しているのか紹介します。

RIBs のテスタビリティ

RIBs で構成されたアプリは、ビジネスロジックがツリー構造で表現され、子の RIB は親の RIB に依存します。

RIB は Router, Interactor, Builder から構成され、それぞれ以下の責務を担います。

  • Router
    • 子の RIB の attach / detach(ルーティング)
  • Interactor
  • Builder
    • 親の RIB との依存性の解決および自身の Router と Interactor を生成
  • (Presenter / View)
    • 必要があれば、 ViewController など UI 要素を含める

このように RIBs では、それぞれの要素の責務が明確に分かれています。また、親の RIB と子の RIB のビジネスロジックは切り離されているので、RIB 単位でのテストが容易に行うことができます。

それぞれの要素間のコミュニケーションは、Protocol で抽象化されています。上記図の白い線の部分が、Protocol を介して行われる処理になります。直接外部からメソッドが呼び出されることがなくなるため、Private メソッドを定義する必要がなく、すべての処理に対して Unit Test が書ける状態になっています。また、初期化時に必要な情報を与える設計(依存性の注入)になっており、その情報も基本的には Protocol で抽象化されています。

これらのことから、モックさえ用意すれば、すべての RIB において Unit Test が書ける状態が保たれているといえるでしょう。

何をテストするのか

Router および Interactor を Unit Test の対象としています。Builder も Unit Test を書くことはできますが、ビルドが通っていれば Builder としてはきちんと動作しているので、あえて Unit Test を書く必要はなさそうです。

「JapanTaxi」アプリでは、それぞれどのようなテストを行っているのか以下に紹介します。

Router のテスト

Router のテストは必須とはしておらず、ルーティングまわりのロジックを検証する必要がある場合のみ Unit Test を書くことにしました。主に、ビジネスロジックを起点としたルーティング処理の正常性を確認するためのテストです。

たとえば、上記の「注文詳細画面」の下部に表示されるコンテンツの出し分けに関するテストは以下のように行っています。

// Mocks
let confirmationListBuilder = ConfirmationListBuildableTestingMock()
let selectRouteBuilder = SelectRouteBuildableTestingMock()
let interactor = ModularConfirmationLayerInteractableTestingMock()
let viewController = ModularConfirmationLayerViewControllableTestingMock()

// Test target
var router: ModularConfirmationLayerRouter!

// Tests
func testRouting() {
     describe("Routingのテストをする") {
         context("1回目の Routing の場合") { ... }
         context("2回目以降の Routing の場合") {
             var expectationTransitionType: ModuleTransitionType?
             beforeEach {
                 self.viewController.transitionHandler = { _, transitionType, completion in
                     expectationTransitionType = transitionType
                     completion?()
                 }
             }

             afterEach {
                 expectationTransitionType = nil
             }

             context("List から経路選択に Routing が変更された場合") { ... }
             context("経路選択から List に Routing が変更された場合") {
                 beforeEach {
                     self.router.switchToSelectRoute()
                     self.router.switchToConfirmationList()
                 }

                 it("ConfirmationList が attachされていること") {
                     expect(self.router.children.count).to(equal(1))
                     expect(self.router.children.contains(where: { $0 is ConfirmationListRouting })).to(beTrue())
                 }

                 it("モジュールの切り替えにより、元のモジュールが閉じられていること") {
                     expect(self.viewController.transitionCallCount).to(equal(1))
                     expect(expectationTransitionType).to(equal(.dismiss))
                 }
             }
         }
     }
 }

後述する Mockolo などのモックツールを利用し、指定したメソッドが何回呼ばれたのかを検証しています。

画面遷移に関するトランジッションのメソッドに渡された値が想定していたものであったかを検証したり、attach されている Router の数を検証することで、Router の挙動を担保しています。

Interactor のテスト

Interactor のテストは必須です。記述したすべてのメソッドに対して基本的にはテストを行うようにしています。Interactor 内に定義された、Rx によるバインディングまわりのテストも行っています。

降車地のピンの上部には、タクシーでの移動時間と距離が表示されます。たとえば、この部分のビジネスロジックに関するテストは以下のようになります。

// Mocks
let presenter = RouteOutlinePresentableMock()
let router = RouteOutlineRoutingMock()
let listener = RouteOutlineListenerMock()
let orderSheetStreamMock = MutableOrderSheetStreamMock()
let mapCameraStreamMock = MapCameraStreamMock()

// Test target
let interactor = RouteOutlineInteractor()

// Tests
func testDurationDistanceVisibility() {
     describe("時間と距離の表示状態を試験する") {
         beforeEach {
                self.orderSheetStreamMock.given(.orderRoute(getter: .just(...)))
                self.orderSheetStreamMock.given(.preFixedFareRoute(getter: .just(...)))
                self.orderSheetStreamMock.given(.selectedFareType(getter: .just(...)))
                self.mapCameraStreamMock.given(.projection(getter: .just(...)))
            }

         context("乗車地用の場合") { ... }
         context("降車地用の場合") {
             beforeEach {
                 self.interactor = self.initInteractor(orderLocation: ...,
                                                       routeOutlineType: .dropOff)
                 self.interactor.router = self.router
                 self.interactor.listener = self.listener
                 
                 self.interactor.activate()
             }

             afterEach {
                 self.interactor.deactivate()
             }

             it("料金と距離は表示されること") {
                 self.presenter.verify(.showDurationDistance(), count: .once)
                 self.presenter.verify(.hideDurationDistance(), count: .never)
             }
         }
     }
 }

Router のテストの例では Mockolo を利用した記述でしたが、上記は SwiftyMocky を利用したものになっています。

モックに対してテスト用の情報を事前に与えておき、Interactor が activate されたと同時に、想定している処理が流れる仕組みになっています。

Interactor のテストでは、Interactor から Router や Presenter へ然るべき命令が呼び出されているかどうかを検証しています。UI についてのテストは直接行わず、Presenter に対して正しく命令を出せているかどうかを検証することで担保しています。

Unit Test を書きやすくするために

少しでもテストを書くことへの障壁を取り除くため、「JapanTaxi」アプリでは様々なライブラリやツールを利用しています。以下にそれらの活用の仕方について紹介します。

モック化

様々な条件に対応したテストを書くために、モックを作成する必要があります。

RIBs では上述したように Protocol で抽象化されたメソッドやプロパティを通じた処理が多いです。そのため、Protocol のモックを生成する必要があります。その Protocol に準拠したクラスや構造体を自前で実装してもよいですが、様々なパターンを必要とするテストでは実装コストが高くなってしまいます。そこで、以下に紹介するモック化ライブラリを利用することにしました。

SwiftyMocky

SwiftyMocky は、Protocol のモックを簡単に生成してくれるライブラリです。モック化したい Protocol に対してアノテーションを付けてコマンドを実行するだけでモックが生成されます。

今秋に行われた「After iOSDC」にて、その使い方について紹介しましたのでご参考ください。

Mockolo

SwiftyMocky は非常に便利で使いやすいライブラリでしたが、一つだけ問題がありました。それは、モック生成にかかる時間です。

現在「JapanTaxi」アプリでは 500 個以上の Protocol をモック化しています。そのうち 70% が SwiftyMocky を利用したものなのですが、モック生成にかかる時間は 4 分を超えてしまいます……。また、生成中は PC のメモリ消費が激しくなり、場合によってはメモリ不足でモック生成が正常に完了しないこともありました。

この問題を解決するために、先月から Mockolo を導入して運用することにしました。現在は全体の 30% が Mockolo を利用したモックになっています。Mockolo は、大量の Protocol を高速にモック化できることを売りにしています。

上述の SwiftyMocky でモック化している Protocol を、Mockolo を利用してモック化した場合の結果が以下になります。(同じ 365 個の Protocol をモック化したときの違いになります。)

モック生成までの時間は 2.6 秒と、比べ物にならないくらい速くなりました。また、生成されたモックファイルの行数も大幅に削減できます。

しかし、数万行のファイルを Xcode で開いた場合、フリーズしてしまうことがあるので、RIB ごとにモックファイルを分けるなどの工夫を今後行いたいと思います。

SwiftyMocky から Mockolo へ移行しても、使い方で大きな差はありません。メソッドが呼ばれた回数のカウントやテスト用の値の設定など、SwiftyMocky で使える機能は Mockolo でも実現可能です。(少し書き方が冗長に感じるかもしれませんが。)

具体的な使い方は、上述の「Router のテスト」をご参考ください。

Fakery

モック化ではありませんが、Fakery というライブラリを利用して、ダミーデータを作成しています。 モックに対して何かしらの値を設定しないといけない場合に便利です。テスト内容に関係のないような値をわざわざ考える必要がなくなります。

let faker = Faker(locale: "jp")
let user = UserMock()
user.given(.userID(getter: faker.number.randomInt(min: 10, max: 10)))
user.given(.userName(getter: faker.name.name()))
user.given(.cellPhone(getter: CellPhone(phoneNumber: faker.phoneNumber.cellPhone())))
user.given(.email(getter: faker.internet.email()))
user.given(.birthDate(getter: faker.date.birthday(20, 80)))

テンプレート

いざテストを書こうと思い立っても、モックを生成するためにアノテーションを張ったり、必要なプロパティの初期化など、実際のテストコードを書くための準備が必要です。

ある程度決まりきった作業になるので、それを避けるために Unit Test 用のテンプレートを用意しました。

RIBs で用意されている RIB テンプレートを応用し、モックのアノテーションや Quick による記述の一部をテンプレートとして登録しました。これにより、Unit Test を追加したい RIB の名前を指定するだけで、テストに必要なファイルや構成が揃うようになっています。

おわりに

「JapanTaxi」アプリが採用している RIBs アーキテクチャにおける Unit Test への取り組みを紹介しました。モック化やテンプレートについては、RIBs とは関係なく様々なプロジェクトで参考になるかと思います。特に Mockolo は Protocol のモック化が高速になるのでおすすめです。

これからもテストが書きやすい状態が保たれるよう心がけて開発をしていきたいです。

💁🏻 ※本記事は Mobility Technologies の前身である JapanTaxi 時代に公開していたもので、記事中での会社やサービスに関する記述は公開当時のものです。