🍃

TCAにおけるテストまとめ

2023/09/08に公開

はじめに

TCAのコードリーディングをしていて、TCA Insideという形にまとめていっています。途中、副作用のコードを見ていると、テストがよく絡んできたので別途まとめてみました(iOSDCでテストについて、熱弁があったのに影響されたのもあります)。

ちなみに、すでにテスト周りについては、下記の通り素晴らしい記事が出ています。

https://zenn.dev/kalupas226/articles/634300ae1ca106
https://zenn.dev/zones/articles/5b143c7f2dc177

TCAが提供するインターフェースなど詳細については上に譲って、本稿ではテストを組み立てる様子をステップごとに紹介し、同期テスト、非同期テスト、Dependency、AsyncStreamを網羅する形をとります。

またTestStoreから入らずに、Storeから入ることでTestStoreがいかに便利かという点も紹介できたらと思います。

Storeによるテスト

TestStoreに入る前に、Storeで直接テストを書いてみましょう。
その方がTestStoreの恩恵がわかりやすいはずです。

同期テスト

Reducerの実装

まずは簡単なテストから考えてみたいと思います。数字のカウントを行うだけの簡単なCounterReducerを用意します。

struct CounterReducer: Reducer { 
    ...
}

Stateも今は単純にcount変数を置くだけにしましょう。

struct CounterReducer: Reducer {
    
    struct State: Equatable {
        var count: Int = .zero
    }
    ...
}

View上でボタンが押されたら、カウントをインクリメントするだけです。

struct CounterReducer: Reducer {
    ...
    enum Action: Equatable {
        case incrementButtonTapped
    }
    
    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .incrementButtonTapped:
            state.count += 1
            return .none
        }
    }
}

テストの準備

今のところテストはこのようにまっさらで何も書かれていません。

import XCTest

@testable import counterTest

final class counterTests: XCTestCase {
    
}

ボタンを押された際の挙動を確認するためまずは適当なテスト関数をおきましょう。

final class counterTests: XCTestCase {
    func testIncrement() {
    
    }
}

次いでテストの対象となるStoreを準備します。

    func testIncrement() {
        let store = Store(
            initialState: CounterReducer.State(),
            reducer: { CounterReducer() }
        )
    }

ここで、@MainActorが必要です

    @MainActor
    func testIncrement() {
        ...
    }

これがない場合、次のようなエラーに見舞われるでしょう。

そしておそらくStoreが何ものかコンパイラはわかっていません。
ComposableArchitectureをimportします。

import ComposableArchitecture

インクリメントのテスト

storeでactionをsendした後、stateを確認したいので次のように書きたくなります。

    func testIncrement() {
        let store = Store(
            initialState: CounterReducer.State(),
            reducer: { CounterReducer() })
        store.send(.incrementButtonTapped)
        XCTAssertEqual(store.state.count, 1) // 🟥
    }

これはすぐにエラーが出ます。

storeが持っているのはstateは、scope間の親子関係をつなぐCurrentValueSubjectです。
これらの親子関係の実装についてはこちらをご覧ください。

そこで次のようにViewStoreを使用する形に改良してみましょう。

    func testIncrement() {
        ...
        let viewStore = ViewStore(store, observe: { $0 })
        viewStore.send(.incrementButtonTapped)
        XCTAssertEqual(viewStore.state.count, 1) // 🟢
    }

これはうまくいきます。

非同期テスト

では、少し処理を追加して、非同期処理をReducerに追加してみます。

TCAの解説を見ているとたまに出てくる数字にまつわる短文を返してくれるAPIがあるのでそれを使います。

numbersapi.com/#42

Reducerの実装

まずは、Stateに短文を持つためにfact変数を追加します。

    struct State: Equatable {
        ...
        var fact: String = ""
    }

インクリメントボタンとは別に、NumberFactボタンを用意します。
それが押されたら短文を設定するようにします。

actionを次のように追加し

    enum Action: Equatable {
        ...
        case numberFactButtonTapped
        case setNumberFact(_ fact: String)
    }

reducerの処理を次のようにするとします。

    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        ...
	
        case .numberFactButtonTapped:
            let count = state.count
            return .run { send in
                let fact = try await String(
                  decoding: URLSession.shared.data(
                    from: URL(
                        string: "http://numbersapi.com/\(count)"
                    )!
                  )
                  .0,
                  as: UTF8.self
                )
                await send(.setNumberFact(fact))
            }
            
        case let .setNumberFact(fact):
            state.fact = fact
            return .none
        }
    }

APIテスト

先ほどと同じようにテスト関数を追加してViewStoreを準備しましょう。

final class counterTests: XCTestCase {
    ...
    func testNumberFact() {
        let store = Store(
            initialState: CounterReducer.State(),
            reducer: { CounterReducer() })
        let viewStore = ViewStore(store, observe: { $0 })
    }
}

actionをsendしてAPIから値が返ってくるのを待ちます。

    func testNumberFact() {
        ...
        viewStore.send(.numberFactButtonTapped)
        XCTAssertEqual(viewStore.state.fact, "some fact")
    }

これはうまくいきません。API側からどのような値が返ってくるか私たちにはわかりません。

少し処理を変えてみます。

        case .numberFactButtonTapped:
            let count = state.count
            return .run { send in
                try await Task.sleep(nanoseconds: NSEC_PER_SEC)
                await send(.setNumberFact("2 is the smallest prime"))
            }

API通信の代わりに少しだけ待って、適当な短文を返すようにしました。
これでどうでしょう?

    @MainActor
    func testNumberFact() async throws {
        ...
        viewStore.send(.numberFactButtonTapped)
        try await Task.sleep(nanoseconds: NSEC_PER_SEC)
        XCTAssertEqual(viewStore.state.fact, "2 is the smallest prime") // 🟥
    }

失敗しました。しかし何度かやるとうまくいきます。

sendされてから値が設定されるまでに間隙が存在するため、例えば次のようにするとうまくいくようになります。

    @MainActor
    func testNumberFact() async throws {
	...
        try await Task.sleep(nanoseconds: NSEC_PER_SEC * 2) // 🟢
        ...
    }

しかし、同期テストと違って、テストの挙動を確認するためにReducerを変更したり、恣意的にTask.sleepを入れたりしてしまっていてかなり問題があります。

コレらをうまくやるにはDependencyを使用する必要があります。

Dependencyのテスト

もう少し実装を複雑にしてみて、例えばDependencyを使うとどのようになるでしょうか。

numberFactの取得がアプリ内の共通処理でDependencyとして扱われている場合などです。

そのような構造体はもっぱらxxClientとして扱われますので次のようにします。

struct NumberFactClient {
  var fact: @Sendable (Int) async throws -> String
}

reducerで行っていた処理をそのまま移行します。

extension NumberFactClient: DependencyKey {
  static let liveValue = Self { number in
    try await String(
      decoding: URLSession.shared.data(
        from: URL(
          string: "http://numbersapi.com/\(number)"
        )!
      )
      .0,
      as: UTF8.self
    )
  }
}

最後に次のようにすれば完成です

extension DependencyValues {
  var numberFact: NumberFactClient {
    get { self[NumberFactClient.self] }
    set { self[NumberFactClient.self] = newValue }
  }
}

早速使ってみましょう。reduer内の処理を置き換えただけですので、次のようにすれば良いでしょう。


    @Dependency(\.numberFact) var numberFact
    
    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
	...
        case .numberFactButtonTapped:
            let count = state.count
            return .run { send in
                let fact = try await numberFact.fact(count)
                await send(.setNumberFact(fact))
            }
	
	...
    }

Dependecyを使用した場合、それは非常に便利に作られておりwithDependenciesを使用して直接APIを叩かずにモックの処理を差し込むことができます。

   @MainActor
   func testNumberFact() async throws {
       let store = withDependencies {
           $0.numberFact.fact = { _ in
               try await Task.sleep(nanoseconds: NSEC_PER_SEC)
               return "2 is the smallest prime"
           }
       } operation: {
           Store(
               initialState: CounterReducer.State(),
               reducer: { CounterReducer() })
       }
       ...
   }

そして、次のようにすれば、テストはうまく通ります。

    func testNumberFact() async throws {
	...
        let viewStore = ViewStore(store, observe: { $0 })
        viewStore.send(.numberFactButtonTapped)
        try await Task.sleep(nanoseconds: NSEC_PER_SEC * 2)
        XCTAssertEqual(viewStore.state.fact, "2 is the smallest prime") // 🟢
    }

AsyncStream

最後にもう少しだけ処理を追加してみます。

アプリ内の状態共有をClient実装に置いていて、状態変更をAsyncStreamで通知しているような場合です。

https://github.com/pointfreeco/swift-composable-architecture/discussions/2050#discussion-5106634

今回は上の実装を参考にDependencyを実装します。

Dependencyの実装

例えば、ログイン状態を返すようなクライアントを用意します。

struct UserClient {
    var status: @Sendable () -> AsyncStream<LoginStatus>
    var login: @Sendable () -> Void
    var logout: @Sendable () -> Void
}

ログイン状態としていますが、例えばUserといった構造体を渡すのでも良いでしょう。

ここでは簡便のため次のようなenumを使用します。

enum LoginStatus: Equatable {
    case logout
    case login
}

さて、このようにして、ログインとログアウトを実装したとします。

extension UserClient: DependencyKey {
    static var liveValue: Self {
        var loginStatus: LoginStatus = .logout
        let _status = CurrentValueSubject<LoginStatus, Never>(loginStatus)
        return .init(
            status: {
                AsyncStream { continuation in
                    let cancellable = _status
                        .dropFirst()
                        .removeDuplicates()
                        .sink { status in
                            continuation.yield(status)
                        }
                    
                    continuation.onTermination = { _ in
                        cancellable.cancel()
                    }
                }
            },
            login: {
	        loginStatus = .login
                _status.send(.login)
            },
            logout: {
	        loginStatus = .logout
                _status.send(.logout)
            }
        )
    }
}

先ほどと同じようにしてReducer側から使えるようにしましょう。

extension DependencyValues {
  var userClient: UserClient {
    get { self[UserClient.self] }
    set { self[UserClient.self] = newValue }
  }
}

Reducerの実装

View側では、taskで状態変更を待ち受けるようにし、ログイン状態に変更があればViewを変化させたいとします。

StateにloginStatusを追加しましょう。

    struct State: Equatable {
        ...
        var loginStatus: LoginStatus = .logout
    }

actionにはtaskと変更があった際に実際に状態を変更するsetLoginStatusを追加します。

    enum Action: Equatable {
        case task
	...
        case setLoginStatus(_ status: LoginStatus)
    }

そして、次のようにfor awaitでAsyncStreamから流れてくる値を待機します。

struct CounterReducer: Reducer {
    ...  
    @Dependency(\.userClient) var userClient
    
    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .task:
            return .run { send in
                for await status in userClient.status() {
                    await send(.setLoginStatus(status))
                }
            }
        ...
            
        case let .setLoginStatus(status):
            state.loginStatus = status
            return .none
        }
    }
}

テストの実装

テストとしては、初期状態とログイン、ログアウトされた際に適切に状態が変化するかを見たいです。

    @MainActor
    func testUserStatus() async throws {
        XCTAssertEqual(viewStore.state.loginStatus, .logout)
        // ログインされる
	// stateが変化
        XCTAssertEqual(viewStore.state.loginStatus, .login)
    }

ここで、userClientを操作するする方法がないという問題に直面しますが、このような場合には、AsyncStream.makeStreamを使用します。

    func testUserStatus() async throws {
        let users = AsyncStream.makeStream(of: LoginStatus.self)
	...
    }

先ほど見たように、withDependenciesを使用してDependencyの実装を差し替えることができますので、次のようにしましょう。

    func testUserStatus() async throws {
        ...
        let store = withDependencies {
            $0.userClient.status = {
                users.stream
            }
        } operation: {
            Store(
                initialState: CounterReducer.State(),
                reducer: { CounterReducer() })
        }
        ...
    }

すると、こういう風にテストが書けそうです。

    func testUserStatus() async throws {
        ...
        let viewStore = ViewStore(store, observe: { $0 })
        viewStore.send(.task)
        XCTAssertEqual(viewStore.state.loginStatus, .logout)
        users.continuation.yield(.login)
        XCTAssertEqual(viewStore.state.loginStatus, .login)
    }

しかしこれは失敗して、状態はlogoutのままになっています。

AsyncStreamで処理するだけの余裕が必要になるので、sleepするかTask.megaYield()を呼ぶとテストが通ります。

    func testUserStatus() async throws {
        ...
        await Task.megaYield()
        XCTAssertEqual(viewStore.state.loginStatus, .login) // 🟢
    }

TestStoreによるテスト

ここまでで、Storeによるテストを見てきましたが、TCAのテストはTestStoreを使うべきです。
Storeでやったテストと同じことをTestStoreで見ていきましょう。

同期テスト

まず、最初に大きな違いがあり、TestStoreはstateに直接アクセスできます。

  public var state: State {
    self.reducer.state
  }

従ってViewStoreで無駄なラップをしたりする必要がありません。

また、TestStoreのsendはactionの他にassertクロージャーを渡すことができます。

  @MainActor
  @discardableResult
  public func send(
    _ action: Action,
    assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil,
    file: StaticString = #file,
    line: UInt = #line
  ) async -> TestStoreTas

これによって、インクリメントのテストは次のように書くことができます。

    @MainActor
    func testIncrement() async throws {
        let store = TestStore(
            initialState: CounterReducer.State(),
            reducer: { CounterReducer() })
        await store.send(.incrementButtonTapped) {
            $0.count = 1 // 🟢
        }
    }

これはStoreによるテストと比べるとわかりますが、より端的にまた直感的にわかりやすい記述になっていることがわかります。

ここでassertクロージャーに試しに違う値を入れてみましょう。

        await store.send(.incrementButtonTapped) {
            $0.count = 999
        }

すると、エラーメッセージが表示されますがXCTAssertEqualよりはるかに意味ある情報が表示されるのが分かります。

非同期・Dependencyテスト

続いて、非同期テストはどうでしょうか?

Storeによる非同期テストは実装を変容させました。
そこでDependencyを利用して解決しましたが、これはTestStoreでも同じです。
引き続きNumberFactClientを利用したテストを見ていきましょう。

同じようにDependencyを差し替えて、actionをsendします。

    @MainActor
    func testNumberFact() async throws {
        let store = TestStore(
            initialState: CounterReducer.State(),
            reducer: { CounterReducer() }
        ) {
            $0.numberFact.fact = { count in
                try await Task.sleep(nanoseconds: NSEC_PER_SEC)
                return "\(count) is a good number."
            }
        }
        
        await store.send(.numberFactButtonTapped) {
            $0.fact = "0 is a good number."
        }
    }

おっと。これは失敗です。factがまだ切り替わっていません。

ここでStoreのテストで見たようにTask.sleepしたりする必要はありません。
store.receiveで期待するactionを指定し、sendと同様asserクロージャーで検査できます。

    @MainActor
    func testNumberFact() async throws {
        let store = TestStore(
            initialState: CounterReducer.State(),
            reducer: { CounterReducer() }
        ) {
            $0.numberFact.fact = { count in
                try await Task.sleep(nanoseconds: NSEC_PER_SEC)
                return "\(count) is a good number."
            }
        }
        
        await store.send(.numberFactButtonTapped)
        await store.receive(.setNumberFact("0 is a good number.")) {
            $0.fact = "0 is a good number." // 🟢
        }
    }

TestStoreとStoreで大きく異なるのは、Storeの時は検査できなかったsetNumberFactが検査できるようになっている点と、暗黙的だったそのアクションがテスト上で確認できることです。

下のように比較するとStoreによるテストでは、numberFactButtonTappedの副作用の様子を確認することはできません。

       // TestStore
       await store.send(.numberFactButtonTapped)
       await store.receive(.setNumberFact("0 is a good number.")) {
            $0.fact = "0 is a good number."
        }
	
	// Store
        let viewStore = ViewStore(store, observe: { $0 })
        viewStore.send(.numberFactButtonTapped)
        try await Task.sleep(nanoseconds: NSEC_PER_SEC * 2)
        XCTAssertEqual(viewStore.state.fact, "2 is the smallest prime")

AsyncStreamのテスト

最後にAsyncStreamによるテストを確認しましょう。
AsyncStream.makeStreamを使用してStoreと同様にテストを作成します

    @MainActor
    func testUserStatus() async throws {
        let users = AsyncStream.makeStream(of: LoginStatus.self)
        let store = TestStore(
            initialState: CounterReducer.State(),
            reducer: { CounterReducer() }
        ) {
            $0.userClient.status = {
                users.stream
            }
        }
        await store.send(.task)
        users.continuation.yield(.login)
        await store.receive(.setLoginStatus(.login)) {
            $0.loginStatus = .login
        }
    }

一見問題なさそうですが、コレは失敗します。
store.send(.task)が返したEffectTaskがAsyncStreamで待機しているため、キャンセルしない限り寿命が続きます。

従って、このようにすれば良いでしょう。

    @MainActor
    func testUserStatus() async throws {
        ...
        let task = await store.send(.task)
        users.continuation.yield(.login)
        await store.receive(.setLoginStatus(.login)) {
            $0.loginStatus = .login
        }
        await task.cancel() // 🟢
    }

まとめ

上記で確認したように、TCAではさまざまなテストを簡単に書くことができます。

Storeを使用しては検査できない内容も、TestStoreを使用すれば明示的に行うことができ、Dependencyをへのテスト実装も簡単に行えます。書き心地も直感的に分かりやすく、エラーメッセージも充実しています。

コレを見て、あっ、かけそうと思ったら、是非、TCAでテストを書いてみましょう。

Discussion