💯

Exhaustivity を利用して TCA で局所的にテストを行う

2022/12/18に公開

この記事は The Composable Architecture Advent Calendar 2022 の 18 日目の記事になります。

本記事では TCA をある程度理解している方向けに TCA の Exhaustivity という機能を紹介しようと思います。

この記事では以下を理解できるようになることを目指しています。

  • Exhaustivity の利用方法
  • Exhausitivty を利用すべきタイミングと注意しなければいけないポイント

Exhaustivity とは?

詳細は Point-Free のブログに記載されています。

本記事でも要点だけ説明しようと思います。

「Exhaustivitiy」は日本語だと「網羅性」のような意味を持っています。
ここでいう「網羅性」というのは、テストにおけるものを指していて、通常 TCA を利用し TestStore を利用したテストを書くとなると、開発者は網羅的なテストを書くことを強制されるようになっています。

この網羅的なテストは、TCA を利用する大きなメリットの一つだと個人的には思っています。
テストを書く際に、何のテストを書くべきか迷わなかったり、つい見落としてしまいがちなケースのテストも網羅的に書くことができるため、TCA のテストサポート機能は非常に強力なものです。

例えば、TCA で「Add Button が押された時に state で保持している items という property が変更される」ことの動作を確かめるテストは以下のように書くことができます。

func testAddItem() async {
  let store = TestStore(
    initialState: Feature.State(),
    reducer: Feature()
  )

  await store.send(.addButtonTapped) {
    $0.items = [
      Item(name: "", quantity: 1)
    ]
  }
}

Add Button が押された時に実は isAdding という property も変更されてしまう場合、上記のテストは以下のような理由で失敗するようになっています。

🛑 A state change does not match expectation: …

      Feature.State(
    −   isAdding: false,
    +   isAdding: true,
        items: […]
      )

(Expected: −, Actual: +)

「Add Button が押された時に、items だけではなく isAddding の状態も変更されているので、それもテストしてください」ということを詳細に報告してくれるのです。
そのため、このテストは以下のようにコードを修正することができます。

await store.send(.addButtonTapped) {
  $0.isAddding = true
  $0.items = [
    Item(name: "", quantity: 1)
  ]
}

これでこのテストは成功するようになりますが、「Add Button」を押した時に別の Action が発火するとなった場合は、

🛑 The store received 1 unexpected action after this one: …

Unhandled actions: [
  [0]: Feature.Action.addResponse(success: true)
]

上記のようなエラーが起こります。
TCA のテストでは、何らかの Action が発火される場合は、必ず以下のように明示的にハンドリングする必要があります。

await store.receive(.addResponse(success: true)) {
  $0.isAdding = false
}

このように TCA のテストでは何らかの状態が変更されたり、何らかの Action が発火されたりした場合、それらを確実に Assert することが求められます。
このことから TCA のテストでは Exhaustivity (網羅性) が担保されているということがわかります。

しかし、場合によってはその「網羅性」のために、冗長なテスト・変更に脆いテストになってしまうこともあります。
TCA ではツリー状に State を構成していくことが多いと思いますが、複雑な機能の場合、本当にテストしたいと思っている箇所以外のテストもしなければいけない時が出てきます。

例えば、アプリでログインボタンを押した時に Root State で保持しているタブの状態 selectedTab が正しい状態になるかを確認する場合、以下のようなテストを書くことができます。 (App.State = Root State が Login.State を持っているようなイメージ)

let store = TestStore(
  initialState: App.State(),
  reducer: App()
)

await store.send(.login(.submitButtonTapped)) {
  $0.login?.isLoading = true
  ...
}

await store.receive(.login(.loginResponse(.success))) {
  $0.login?.isLoading = false
  ...
}

await store.receive(.login(.delegate(.didLogin))) {
  $0.authenticatedTab = .loggedIn(
    Profile.State(...)
  )

  $0.selectedTab = .activity
}

このテストコードから、網羅性のあるテストには以下のような欠点があることがわかります。

  • App.State についてのテストを行う際に、Login に関する知識を必要としてしまう
  • Login の機能に変更が入った場合、上記のテストも修正する必要が出てきてしまう
  • テストコード自体が非常に長くなってしまう。またテストフローによって、微妙な違いはあるかもしれないが、似たようなテストコードを大量に書くことになってしまう可能性がある

このように TCA のテストは網羅性を担保できるという非常に強力な一面を持つ一方で、それが逆に欠点となってしまうタイミングが出てくることもありました。

Exhaustivity の利用方法

そのような TCA の強力な網羅性をオフにして (non-exhaustivity) テストを書けるようにする機能が Exhaustivity というものになります。
Exhaustivity は v0.45.0 で TCA に導入されており、比較的新しい機能となっています。

実際に利用例を見ると利用方法を理解できると思うので、先ほど例に出したコードで Exhaustivity を利用するとどのようなコードになるかを示します。

let store = TestStore(
  initialState: App.State(),
  reducer: App()
)
store.exhaustivity = .off // ⬅️

await store.send(.login(.submitButtonTapped))
await store.receive(.login(.delegate(.didLogin))) {
  $0.selectedTab = .activity
}

store.exhaustivty = .off という記述によって、TCA のテストの網羅性を失わせることができます。
そのコードの後には、自分が Assert したいもののみ記述していけば、本当にテストしたいと思っているもののみテストすることができるようになります。

store.exhaustivity = .off の部分を store.exhaustivity = .off(showSkippedAssertions: true) に変えると、Assert していないけれど本当は発火している Action や State の変更が Xcode 上で表示されるようになります。 (以下のような形)

◽️ A state change does not match expectation: …

     App.State(
       authenticatedTab: .loggedOut(
         Login.State(
   −       isLoading: false
   +       isLoading: true,
           …
         )
       )
     )

   (Expected: −, Actual: +)

◽️ Skipped receiving .login(.loginResponse(.success))

◽️ A state change does not match expectation: …

     App.State(
   −   authenticatedTab: .loggedOut(…)
   +   authenticatedTab: .loggedIn(
   +     Profile.State(…)
   +   ),
       …
     )

   (Expected: −, Actual: +)

このように Exhaustivity を利用すれば、自分がテストしたい部分のテストのみ書くことができるようになり、大きな機能でも簡潔なテストにすることができます。

しかし、個人的には Exhaustivity の利用は最小限に留めた方が良いと感じています。
Exhaustivity を利用してしまえば TCA におけるテストの網羅性を失ってしまい、以下のようなデメリットがあると感じているからです。

  • 人間なので、テストコードを書く時に見落としが発生してしまいがちになる
    • TCA によって網羅性が担保されていれば、自ずとあらゆる State の変更や Action の発火をテストする必要が出てきます
    • Exhaustivity によって網羅性が失われてしまえば、「実はある Action によって State が変更されてしまっていて、意図していないバグが生まれてしまっている」ことなどにテストで気づくタイミングが失われてしまう可能性が高いと感じています
    • 人によってテストコードの質のバラつきも出やすくなると思います
  • コードの質を改善する機会が少なくなる
    • TCA のテストでは網羅性が担保されているため、網羅的なテストを書く過程で、冗長だったり誤っている実装に気づく機会が多いと感じています
    • Exhaustivity によって網羅性が失われれば、当然その機会が少なくなってしまうと思っています

参考までに、個人的な Exhaustivity の利用方針は以下のような形です。

  1. 可能な限り Exhaustivity を利用せずにテストコードを書くようにする
  2. テストコードが複雑になりすぎている場合、テスト対象のコードが冗長である可能性が高いため、実装を修正できないか考える (経験上、例えば Action をやたらめったら次々と発火させていたり、一つの Action を複数の Action から呼び出していたり、親から子の Action を呼び出してしまっている時などに複雑になりやすいため、その辺りを改善できないかを考える → この辺りは 22 日公開予定のアドカレで多少触れるかもしれません)
  3. テスト対象のコードが Root 寄り (アプリのエントリーポイント寄り) であるなら Exhaustivity の利用を検討する。Leaf 寄り (Child が少ない Reducer など) であるなら Exhaustivity を利用せずに網羅的なテストを書く
  4. Exhaustivity を利用する場合は、store.exhaustivity = .off(showSkippedAssertions: true) の形で利用するようにすることで、テストコードを触る際にどんな State の変更や Action の発火が起きているのかを開発者に意識してもらえるようにする

あくまで個人的な方針ではありますが、Exhaustivity を利用する際はチーム内である程度方針を固めて利用するようにした方が良いと思っています。

おわりに

本記事では TCA のテストサポート機能の一つである「Exhaustivity」について、基本的な利用方法と個人的に気をつけなければいけないと思っていることについて説明しました。
この記事が Exhaustivity を利用するどなたかの参考になれば嬉しいです🙏

Discussion