TCA で UserDefaults をテスタブルにしつつ ActorIsolated を使ってテストを行う方法
この記事は The Composable Architecture Advent Calendar 2022 の 15 日目の記事になります。
本記事では TCA をある程度理解している方向けに UserDefaults を TCA で利用する方法を紹介しようと思います。
この記事では以下を理解できるようになることを目指しています。
- UserDefaults を抽象化してテスタブルにする方法
- 抽象化された UserDefaults を利用しているコードをテストする方法
-
ActorIsolated
という API の利用方法 -
XCTAssertNoDifference
という API の利用方法
-
UserDefaults を TCA で利用している例
UserDefaults を TCA で利用している例としてパッと見つかるものは isowords があります。
isowords における UserDefaults の使い方
では isowords でどのように UserDefaults が使われているかを見ていくことで、TCA で UserDefaults を扱う方法の一例を見ていくことにします。
今回は以下のような流れで紹介していこうと思います。
- UserDefaults を Dependency として抽象化しつつ利用する方法
- Dependency 化した UserDefaults を使ってテストを書く方法
UserDefaults を Dependency として抽象化しつつ利用する方法
Interface の実装
isowords では UserDefaultsClient
という struct に UserDefaults のための interface が定義されています。
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 をそのまま外部から利用するのではなく、以下のように利用しています。
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
の実装がどうなっているかも見ていきます。
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 を定義して、それを利用して 〇〇Key
や set〇〇
を実現していることがわかります。
TestKey の実装
では、TestKey がどうなっているかも見てみます。
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 を利用しているコードを抜粋します。
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.hasShownFirstLaunchOnboarding
がtrue
であれば、.delegate(.getStarted)
Action が発火する -
.delegate(.getStarted)
Action が発火するとuserDefaults.setHasShownFirstLaunchOnboarding(true)
が呼ばれる
では、このコードのテストコードである OnboardingFeatureTests も見ていきましょう。同じように関係する部分のみを以下に抜粋します。
@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 を定義して、それを @Sendable
な track
という 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
という変数名にアクセスする必要がないようになっています。
withValue
と setValue
の実装もシンプルだと思います。
XCTAssertNoDifference
について
XCTAssertNoDifference
についても軽く触れておきます。
こちらは Point-Free 製の swift-custom-dump というライブラリに入っている仕組みの一つで、基本動作は XCTAssertEqual
と同じですが、エラーが発生した際のエラーメッセージが人間により優しい形に整形されるものになっています。
詳しくはこちらをご覧ください。
おわりに
本記事では TCA で UserDefaults を扱う方法とテストする方法について説明しました。
isowords の UserDefaults の抽象化方法は、TCA を利用していなくても参考になる部分がありそうです。
Discussion
これ僕も疑問に思ってたんですが、その後何かわかったことはありますか?
UserDefaultsはスレッドセーフなので、単に値をセットするだけであればロックの仕組みは特に不要と思っています。
また、asyncにしたからといってactor隔離されるわけでも無い(ですよね...?)から、安全にするためというわけでもなさそう...?
コメントしてすぐこれを見つけましたw
set系をあえて非同期にしてるのは、UserDefaultsへの書き込み処理をEffect内で行うことを強制するためだそうですね。
Stateの更新と同じ要領でUserDefaultsへの書き込みを行うことは避け、あえてEffectで行うようにすることで一貫性を持たせようということか。
@takehilo
コメントありがとうございます🙏
takehilo さんがおっしゃっているように「UserDefaults への書き込みを副作用だと捉えれば、確かに非同期にしているのはある程度納得感があるな」という形で自分も考えていたのですが、記事更新できておらずでした🙇♂️
ついつい、そもそもの処理が非同期かどうかというところで Client の interface を考えてしまいがちですが、この Discussion にある話のように interface を考えることは重要ですね...
おそらく処理的には問題になることは少ないのだと思いますが、一貫性という観点で
set
methods をEffect.run
経由でしか扱えないようにするのは良さそうです先週くらいにこれに関する質問も立っていたのは把握できていなかったので、コメントしてくださって助かりました!記事の方にも後ほど追記させて頂こうと思います!