🛀

TCA × Firestore入門(テスト編)

2022/12/08に公開約6,500字

この記事は、The Composable Architecture Advent Calendar 2022 12/9の記事です。

1.はじめに

アマゾネスです。
前回のTCA × Firestore入門(実装編)はお楽しみいただけたでしょうか?
では早速、続編となる「テスト編」いかせていただきやす😎

この記事でわかること

  • TCAのテストの概要
  • ReducerProtocolに準拠した実装のテスト
  • AsyncThrowingStreamを使った実装のテスト

目次

1.はじめに
2.おさらい
3.テストに入る前に

  • Dependency

4.テストの実装

  • TestStore
  • 依存関係のテストの準備
  • 実行

5.まとめ

2.おさらい

実装編ではTCAとAsyncThrowingStreamを使用して、逐一更新されるFirestoreDBの値をリアルタイムで扱う実装をご紹介しました。今回はその実装のテスト、つまり、AsyncThrowingStreamを使った非同期処理を、TCAでどうやってテストするのかを説明します。

3.テストに入る前に

The testability of features built in the Composable Architecture is the #1 priority of the library. It should be possible to test not only how state changes when actions are sent into the store, but also how effects are executed and feed data back into the system.
ComposableArchitecture/Testing

TCAは、

  • アクションがストアに送られたときに状態がどのように変化するか
  • 副作用がどのように実行され、システムにデータをフィードバックするか

など、「Reducerに実装された処理」をテストする仕組みを提供しています。

なので、今回のテストでは、

  • Firestoreの値が流れてきた時に、正しくアクションが呼ばれ、Stateが更新されるか

の観点でテストを書いていきたいと思います。
まず、実際のテストを書いていく前に、テストを行うための準備をしましょう。

Dependency

実装編でDependencyの説明を行いました。その時に少しだけふれたTestDependencyKeyの実装を見ていきます。

TestKey.swift
extension SaunaCurrentStatesClient: TestDependencyKey {
    public static let testValue = Self(
        listen: unimplemented("\(Self.self).listen")
    )
    public static let previewValue = Self.noop
}

extension SaunaCurrentStatesClient {
    public static let noop = Self(
        listen: { _ in .never }
    )
}

TestDependencyKeyプロトコルは、testValuepreviewValueという2つのrequirementsを持っています。testValueを実装すると、後述するテスト用のStoreであるTestStoreを実行する時に使用されます。

実装を見ると、testValueにはunimplementedが実装されています。unimplementedは、呼び出された時に内部的にXCTFailが呼ばれるような実装になっています。つまり、テストが落ちます。TCAの公式ドキュメントでは、この実装を行うことを強く勧めています。その理由は、テストを実行して意図しない依存関係が呼び出された場合、テストを失敗させることができるためです。

テストの実行中に呼び出される依存関係については、テストを実行する前に、その依存関係に対してテストで実行したい処理を実装し、unimplementedが呼ばれないようにします。

また、previewValueも便利なrequirementsで、これを実装しておくと、Xcode プレビューで実行されたときに使用されるようになります。

4.テストの実装

準備ができたので、実際のテストを見ていきましょう👀
全体のテストとしては、こんな感じになります。

SaunaSettingFeatureTests.swift
func test_SaunaTemperatureUpdated() async {

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

    let (saunaCurrentStatesStream, saunaCurrentStatesContinuation) = AsyncThrowingStream<
        SaunaCurrentStates, Error
    >.streamWithContinuation()

    store.dependencies.saunaCurrentStatesClient.listen = { _ in
        saunaCurrentStatesStream
    }

    let saunaCurrentStatesStub = SaunaCurrentStates.init(
        temperature: "80"
    )

    let task = await store.send(.listen)
    saunaCurrentStatesContinuation.yield(saunaCurrentStatesStub)
    await store.receive(.listenSaunaCurrentStatesResponse(.success(automationCurrentStateStub))) {
        $0.temperature = saunaCurrentStatesStub.temperature
    }

    await task.cancel()
    saunaCurrentStatesContinuation.yield(saunaCurrentStatesStub)
}

まず、テストの一番初めにTestStoreを作成し、依存関係のテストの準備を行い、最後にReducerをテストしています。

TestStore

TestStoreは、Reducerのテストをするためのランタイムです。
TestStoreを使用すると、記述するテストに対して以下のような要求が発生します。

  • 各Actionが送信された後、Actionが送信される前と送信された後とでStateがどのように変化するかを実装する

  • 完了していないEffectや、未受信のActionがある状態でテストを終了することはできない

これらの要求を守ることで、意図するActionが呼ばれ、意図する形でStateが変化するか、つまり、正しくRuducerが機能しているかを担保することができます。

依存関係のテストの準備

SaunaCurrentStatesClientには、AsyncThrowingStreamを使用した非同期処理があります。これを一体どのようにテストするのかを説明します。

TCAではAsyncThrowingStreamを使った実装をテストするために、AsyncThrowingStreamのextensionとしてstreamWithContinuationというツールを用意してくれています。

SaunaSettingFeatureTests.swift
    let (saunaCurrentStatesStream, saunaCurrentStatesContinuation) = AsyncThrowingStream<
        SaunaCurrentStates, Error
    >.streamWithContinuation()

streamWithContinuationの戻り値でテスト用のStreamとContinuationを返してくれるので、これを使ってテストを進めていきます。

テスト用のStreamをどのように使用するかを見てみます。

SaunaSettingFeatureTests.swift
    store.dependencies.saunaCurrentStatesClient.listen = { _ in
        saunaCurrentStatesStream
    }

Dependencyの説明で、「テストの実行中に呼び出される依存関係については、テストを実行する前に、その依存関係に対してテストで実行したい処理を実装し、unimplementedが呼ばれないようにします。」と説明しました。ですので、SaunaCurrentStatesClient.listenで実行したい処理、つまりここでは、テスト用のStreamを実行するようにします。

これで、依存関係であるSaunaCurrentStatesClientの準備ができました。

実行

テストでやっていることは、いたってシンプルです。

SaunaSettingFeatureTests.swift
    let task = await store.send(.listen)
    saunaCurrentStatesContinuation.yield(saunaCurrentStatesStub)
    await store.receive(.listenSaunaCurrentStatesResponse(.success(automationCurrentStateStub))) {
        $0.temperature = saunaCurrentStatesStub.temperature
    }
  1. .listenActionを送信
    2.「FirestoreDBに更新があったこと」を表現するためにyieldする
  2. 副作用として期待される.listenSaunaCurrentStatesResponseActionの受信と、それに伴って期待されるStateの変化を実装

以上で、Firestoreの値が流れてきた時に、正しくアクションが呼ばれ、Stateが更新されるかをテストできます。

最後の方では、こんなことをしています。

SaunaSettingFeatureTests.swift
    await task.cancel()
    saunaCurrentStatesContinuation.yield(saunaCurrentStatesStub)

let task = await store.send(.listen)taskにはTestStoreTaskが入っているので、taskのcancel処理を行い、正しくcancelが行われているかをチェックしています。実装をみると、saunaCurrentStatesContinuation.yieldの後に、receiveの処理がないことがわかると思います。これはつまり、正しくcancelが行われて、後続の処理がよばれていないことを意味します。

また、もしもcancelの処理がなければ、An effect returned for this action is still running.という内容のエラーが発生して、テストが落ちます。これはTestStoreの要求の一つである「完了していないEffectや、未受信のActionがある状態でテストを終了することはできない」のためです。

以上で、テストの実装完了です🎉

5.まとめ

テスト編、いかがでしたでしょうか?TCAのテスト、すごいでしょう!
テストしたいことに対する正確性、網羅性を担保している仕組みになっているので、誰でも、ちゃんとテストを行えるようになっています。私はテストに対して超苦手意識あったのですが、TCAのテストを書くようになってから、テストを書くのが割と好きになりました💡なぜなら、そんな私でも書けるから。

来年も、沢山テスト書くぞ。では、良いお年を🍺
...いや、まだ私のアドベントカレンダーは終わらない。
次回、新章:TCAでdebounceする(12/19予定)、お楽しみに。

テスト編、完。

Discussion

ログインするとコメントできます