🗃️

TCA で UserDefaults をテスタブルにしつつ ActorIsolated を使ってテストを行う方法

2022/12/15に公開約12,000字

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

本記事では TCA をある程度理解している方向けに UserDefaults を TCA で利用する方法を紹介しようと思います。

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

  • UserDefaults を抽象化してテスタブルにする方法
  • 抽象化された UserDefaults を利用しているコードをテストする方法
    • ActorIsolated という API の利用方法
    • XCTAssertNoDifference という API の利用方法

UserDefaults を TCA で利用している例

UserDefaults を TCA で利用している例としてパッと見つかるものは isowords があります。

https://github.com/pointfreeco/isowords

isowords における UserDefaults の使い方

では isowords でどのように UserDefaults が使われているかを見ていくことで、TCA で UserDefaults を扱う方法の一例を見ていくことにします。

今回は以下のような流れで紹介していこうと思います。

  • UserDefaults を Dependency として抽象化しつつ利用する方法
  • Dependency 化した UserDefaults を使ってテストを書く方法

UserDefaults を Dependency として抽象化しつつ利用する方法

Interface の実装

isowords では UserDefaultsClient という struct に UserDefaults のための interface が定義されています。

Interface.swift
public struct UserDefaultsClient {
  public var boolForKey: @Sendable (String) -> Bool
  public var dataForKey: @Sendable (String) -> Data?
  public var doubleForKey: @Sendable (String) -> Double
  public var integerForKey: @Sendable (String) -> Int
  public var remove: @Sendable (String) async -> Void
  public var setBool: @Sendable (Bool, String) async -> Void
  public var setData: @Sendable (Data?, String) async -> Void
  public var setDouble: @Sendable (Double, String) async -> Void
  public var setInteger: @Sendable (Int, String) async -> Void
  
  // ...
}

UserDefaults に対する操作は以下の二通りだと考えれば、上記のような interface は納得感があります。

  • 特定の key を指定して、ある値を取得する
    • 〇〇ForKey がこれにあたる
  • 特定の key を指定しつつ、ある値をセットする
    • set〇〇 がこれにあたる

この定義によって UserDefaults の操作を抽象化することができています。
ただ isowords では基本的にこれらの property をそのまま外部から利用するのではなく、以下のように利用しています。

Interface.swift
public struct UserDefaultsClient {
  // ...

  // 外部からは key を気にせず、特定の key で格納されている Bool を取得できる
  public var hasShownFirstLaunchOnboarding: Bool {
    self.boolForKey(hasShownFirstLaunchOnboardingKey)
  }
  // 外部からは key を気にせず、特定の key で Bool をセットできる
  public func setHasShownFirstLaunchOnboarding(_ bool: Bool) async {
    await self.setBool(bool, hasShownFirstLaunchOnboardingKey)
  }

  public var installationTime: Double {
    self.doubleForKey(installationTimeKey)
  }

  public func setInstallationTime(_ double: Double) async {
    await self.setDouble(double, installationTimeKey)
  }

  public func incrementMultiplayerOpensCount() async -> Int {
    let incremented = self.integerForKey(multiplayerOpensCount) + 1
    await self.setInteger(incremented, multiplayerOpensCount)
    return incremented
  }
}

// UserDefaults で利用する key を internal で定義しモジュールに閉じ込める
let hasShownFirstLaunchOnboardingKey = "hasShownFirstLaunchOnboardingKey"
let installationTimeKey = "installationTimeKey"
let multiplayerOpensCount = "multiplayerOpensCount"

このように、あくまでも key はモジュール内部に隠蔽することによって、外部モジュールからは key を意識せずに UserDefaults の値を取得したり、UserDefaults に値をセットしたりすることができるようになっています。

(そもそも set 系を async にする必要がなさそうという感じもあり、その辺りも追加で見てみて何かわかったら記事に追記します)

LiveKey の実装

では、実際にアプリケーションから利用される liveValue の実装がどうなっているかも見ていきます。

LiveKey.swift
extension UserDefaultsClient: DependencyKey {
  public static let liveValue: Self = {
    let defaults = { UserDefaults(suiteName: "group.isowords")! }
    return Self(
      boolForKey: { defaults().bool(forKey: $0) },
      dataForKey: { defaults().data(forKey: $0) },
      doubleForKey: { defaults().double(forKey: $0) },
      integerForKey: { defaults().integer(forKey: $0) },
      remove: { defaults().removeObject(forKey: $0) },
      setBool: { defaults().set($0, forKey: $1) },
      setData: { defaults().set($0, forKey: $1) },
      setDouble: { defaults().set($0, forKey: $1) },
      setInteger: { defaults().set($0, forKey: $1) }
    )
  }()
}

シンプルな実装ですが、当然 liveValue では実際の UserDefaults を利用する必要があるため、UserDefaults を定義して、それを利用して 〇〇Keyset〇〇 を実現していることがわかります。

TestKey の実装

では、TestKey がどうなっているかも見てみます。

TestKey.swift
extension UserDefaultsClient: TestDependencyKey {
  public static let previewValue = Self.noop

  public static let testValue = Self(
    boolForKey: XCTUnimplemented("\(Self.self).boolForKey", placeholder: false),
    dataForKey: XCTUnimplemented("\(Self.self).dataForKey", placeholder: nil),
    doubleForKey: XCTUnimplemented("\(Self.self).doubleForKey", placeholder: 0),
    integerForKey: XCTUnimplemented("\(Self.self).integerForKey", placeholder: 0),
    remove: XCTUnimplemented("\(Self.self).remove"),
    setBool: XCTUnimplemented("\(Self.self).setBool"),
    setData: XCTUnimplemented("\(Self.self).setData"),
    setDouble: XCTUnimplemented("\(Self.self).setDouble"),
    setInteger: XCTUnimplemented("\(Self.self).setInteger")
  )
}

isowords のコードは常に最新化されているわけではないため、unimplemented ではなく XCTUnimplemented が利用されていることは置いておきます。
また、previewValue についても本記事で伝えたいことではないため、説明を省略します。

testValue を見てみると、XCTUnimplemented を利用して、テストで override せずに Dependency が利用された場合にテストが失敗するようになっています。
TCA における Dependency の実装として特に違和感のない実装になっていると思います。

Dependency 化した UserDefaults を使ってテストを書く方法

ここまでで isowords において UserDefaults がどのように Dependency として定義されているかが掴めたと思うので、次は TestKey を使って UserDefaults 利用部分のコードをどのようにテストしているのかを見ていきます。

例としては OnboardingFeature で UserDefaults を利用しているため、そこを見ていくことにします。

まず、UserDefaults を利用しているコードを抜粋します。

OnboardingView.swift
public struct Onboarding: ReducerProtocol {
  // ...
  @Dependency(\.userDefaults) var userDefaults
  
  public var body: some ReducerProtocol<State, Action> {
    // ...
    case .delegate(.getStarted):
      return .fireAndForget {
        await self.userDefaults.setHasShownFirstLaunchOnboarding(true)
	// ...
      }
    case .skipButtonTapped:
      guard !self.userDefaults.hasShownFirstLaunchOnboarding else {
        return .run { send in
	  await send(.delegate(.getStarted), animation: .default)
	  // ...
	}
	// ...
      }
  }
}

途中の処理については省略して、UserDefaults を利用している部分のみ抜粋しました。

とりあえず上記のコードから以下のような挙動になると理解しておけば、この後のテストコードの説明も理解できるかなと思います。

  • skipButtonTapped Action が発火するとuserDefaults.hasShownFirstLaunchOnboarding の状態がチェックされる
  • userDefaults.hasShownFirstLaunchOnboardingtrue であれば、.delegate(.getStarted) Action が発火する
  • .delegate(.getStarted) Action が発火すると userDefaults.setHasShownFirstLaunchOnboarding(true) が呼ばれる

では、このコードのテストコードである OnboardingFeatureTests も見ていきましょう。同じように関係する部分のみを以下に抜粋します。

OnboardingFeatureTests.swift
@MainActor
class OnboardingFeatureTests: XCTestCase {
  func testSkip_HasSeenOnboardingBefore() async {
    // `ActorIsolated` というものを使って Bool 値を定義。
    // UserDefaults の set が正しく動作しているか確認するために利用される。
    // `ActorIsolated` については後ほど説明します。
    let isFirstLaunchOnboardingKeySet = ActorIsolated(false)

    let store = TestStore(
      initialState: Onboarding.State(presentationStyle: .help),
      reducer: Onboarding()
    )

    // ...
    
    // `boolForKey` が呼び出されたら `key` に関わらず `true` を返すように override する。
    // `XCTAssertNoDifference` を利用して、`key` が意図したものになっているかどうかもチェック。
    // `XCTAssertNoDifference` については後ほど説明します。
    store.dependencies.userDefaults.boolForKey = { key in
      XCTAssertNoDifference(key, "hasShownFirstLaunchOnboardingKey")
      return true
    }
    
    // `setBool` が呼び出されたら、用意しておいた Bool の変数に `true` をセットするような動作で override する。
    // `XCTAssertNoDifference` を利用しつつ、`key` と `value` が意図したものになっているかもチェック。
    store.dependencies.userDefaults.setBool = { value, key in
      XCTAssertNoDifference(key, "hasShownFirstLaunchOnboardingKey")
      XCTAssertNoDifference(value, true)
      await isFirstLaunchOnboardingKeySet.setValue(true)
    }

    // ...

    // `.skipButtonTapped` Action が発火すると `userDefaults.hasShownFirstLaunchOnboarding` が呼ばれる。
    // その Bool 値は `true` になるように override しているため、
    // `.delegate(.getStarted)` Action が引き続いて発火する
    await store.send(.skipButtonTapped)
    await store.receive(.delegate(.getStarted))

    // `.delegate(.getStarted)` Action が発火した結果、
    // `userDefaults.setHasShownFirstLaunchOnboarding(true)` が呼ばれる。
    // その結果、`isFirstLaunchOnboardingKeySet` が正しい状態になっていることをチェック。
    await isFirstLaunchOnboardingKeySet.withValue { XCTAssert($0) }
  }
}

テスト全体の処理の流れについては、コード中にコメントしたので次以降で二点だけ補足します。

ActorIsolated について

まず ActorIsolated について説明します。
ActorIsolated は Swift Concurrency の標準 API というわけではなく、TCA で実装されているものとなっています。

ActorIsolated の役割は「mutable value を actor として isolate すること」です。
役割だけ説明しても利用用途がわかりにくいと思うので、ActorIsolated のコメントに記載されている利用例をもとに説明してみます。

@Dependency(\.analytics) var analytics

func reduce(into state: inout State, action: Action) -> Effect<Action, Never> {
  switch action {
  case .buttonTapped:
    return .fireAndForget { try await self.analytics.track("Button Tapped") }
  }
}

例えば、上記のように analytics という Dependency が定義されていて、.buttonTapped Action では、その analytics を利用して何らかのトラックイベントを送るような実装があるとします。

では、これをテストしようとするとどうなるかを見ていきます。

まず単純にテストしようとすると以下のようなコードを書くことになると思います。

@MainActor
func testAnalytics() async {
  let store = TestStore()

  var events: [String] = []
  store.dependencies.analytics = AnalyticsClient(
    track: { event in // この closure は @Sendable
      events.append(event) // ここでコンパイルエラーが発生する
    }
  )

  await store.send(.buttonTapped)

  XCTAssertEqual(events, ["Button Tapped"])
}

テストでは実際のトラックイベントを送るよりも、その動作を最低限保証できるような代わりの動作を提供したいため、events という変数を用意して、その変数に event を格納する形で override しています。

しかし、このコードだと以下のコンパイルエラーが発生してしまいます。

Mutation of captured var 'events' in concurrently-executing code

要するに events という mutable value を定義して、それを @Sendabletrack という closure 内で変更しようとしていましたが、Sendable な closure の仕組みによって、それが許されていないということを表しています。

TCA で定義されている ActorIsolated を利用すると、この問題を解決することができます。

@MainActor
func testAnalytics() async {
  let store = TestStore()

  let events = ActorIsolated<[String]>([])
  store.dependencies.analytics = AnalyticsClient(
    track: { event in
      await events.withValue { $0.append(event) }
    }
  )

  await store.send(.buttonTapped)

  await events.withValue { XCTAssertEqual($0, ["Button Tapped"]) }
}

ActorIsolated によって、events は actor となっているため、Sendable な closure でも await keyword を利用すれば concurrent context でも安全に処理を実行できるようになり、コンパイルエラーもなくなります。

このように Swift Concurrency を利用したコードでは、ActorIsolated が役立つ場面が時々出てきます。

ちなみに ActorIsolated の定義はシンプルで以下のようになっています。

@dynamicMemberLookup
public final actor ActorIsolated<Value: Sendable> {
  public var value: Value
  
  public init(_ value: Value) {
    self.value = value
  }

  public subscript<Subject>(dynamicMember keyPath: KeyPath<Value, Subject>) -> Subject {
    self.value[keyPath: keyPath]
  }

  public func withValue<T: Sendable>(
    _ operation: @Sendable (inout Value) async throws -> T
  ) async rethrows -> T {
    var value = self.value
    defer { self.value = value }
    return try await operation(&value)
  }

  public func setValue(_ newValue: Value) {
    self.value = newValue
  }
}

Key Path Member Lookup の仕組みによって、Value 型の変数にアクセスする際に value という変数名にアクセスする必要がないようになっています。
withValuesetValue の実装もシンプルだと思います。

XCTAssertNoDifference について

XCTAssertNoDifference についても軽く触れておきます。
こちらは Point-Free 製の swift-custom-dump というライブラリに入っている仕組みの一つで、基本動作は XCTAssertEqual と同じですが、エラーが発生した際のエラーメッセージが人間により優しい形に整形されるものになっています。

詳しくはこちらをご覧ください。

おわりに

本記事では TCA で UserDefaults を扱う方法とテストする方法について説明しました。
isowords の UserDefaults の抽象化方法は、TCA を利用していなくても参考になる部分がありそうです。

Discussion

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