skip to content
私的歌詞倉庫

The Composable Architectureのテストを書く ~swift-testingも添えて~

/ 8 min read

Updated:
Table of Contents

はじめに

最近個人開発でmacで動作する英語向けIME、Raelizeを開発しています。

自分が使いたい技術を色々詰め込んでいて、The Composable Architecture(TCA)swift-testingを使ったりしています(他にも色々あるけど)。

TCAのテストを書く時に最初は少し難しいな〜と思っていたのですが、分かると書きやすかったので自分がどのように書いているか紹介します。一応swift-testingも少しだけあります。

今回紹介するテストコードはここにあります。

Raelize/RaelizeInputMethodKit/Tests/RaelizeInputMethodKitTests at main · Tatsumi0000/Raelize

ツールやライブラリのバージョンです。

ツールバージョン
XCode15.3
Swift5.10
swift-testing0.6.0
TCA1.9.2

どのように書いているか

自分がどのように書いているかについて紹介します。

@Dependency

例えば以下のようにTCAの@Dependencyを使っているときです。

@Reducer
public struct RaelizeIMKReducer {
@Dependency(\.wordListFileUseCase)
private var wordListFileUseCase
// 他にも色々ある…
}

この場合は、TestStoreの引数であるwithDependenciesに以下のようにテスト専用のモックを渡しています。モックを渡すためにDI対象のクラスはprotocolで抽象化しています

final class WordListFileUseCaseMock: WordListFileUseCaseType {
// この中にモックのメソッドを作ってあげる
}
let store = TestStore(
initialState: RaelizeIMKReducer.State(raelizeState: .inputMode),
reducer: { RaelizeIMKReducer() },
withDependencies: {
$0.wordListFileUseCase = WordListFileUseCaseMock() // ここでモックを渡す
})

TestStoreが何者かというと、実際のコードを書くときはStoreを使うと思うのですがそれのテスト版みたいなもので、シンプルかつ簡潔に書けるようになるようです。

The library comes with a tool specifically designed to make testing like this much simpler and more concise. It’s called TestStore, and it is constructed similarly to Store by providing the initial state of the feature and the Reducer that runs the feature’s logic:

TestStoreを使うときは@MainActor をつけてあげる必要があります。

@Test @MainActor
func resetState() async {
let store = TestStore(
initialState: RaelizeIMKReducer.State(raelizeState: .inputMode),
reducer: { RaelizeIMKReducer() },
withDependencies: {
$0.wordListFileUseCase = WordListFileUseCaseMock()
})
await store.send(.resetState(.neutralMode)) {
$0.raelizeState = .neutralMode
}
}

Tests that use TestStore should be annotated as @MainActor and marked as async since most assertion helpers on TestStore can suspend.

@Testはswift-testingの機能でこれがテスト実行対象ですよというのを教えます。

Reducerのテスト

Reducerのテストを書くときは、ActionがStateを意図した状態に変更しているかについてテストを書いてます。

自分は一つのActionに対して複数のテストを書くときはアクション名でクラスを作って、その中にテストを書くようにしています。今回だと、operationEventKeyというアクションにユーザのキー入力が入ってくるので、エンターキーが押されたとき、バックスペースキーが押されたとき…といったテストを書いています(ただネストがだんだんと深くなってくるので微妙かも…?)。

今回紹介するコード例だとOperationEventKey というクラスを作ってその中にテストコードを追加していってます。

@Suite("RaelizeInputMethodKit.Action.operationEventKey Tests")
final class OperationEventKey {
@Test @MainActor
func enterKeyAndCandinatesEmpty() async {
let store = TestStore(
initialState: RaelizeIMKReducer.State(raelizeState: .neutralMode),
reducer: { RaelizeIMKReducer() },
withDependencies: {
$0.wordListFileUseCase = WordListFileUseCaseMock()
})
let enterKeyEvent = NSEvent.keyEvent(
with: .keyDown, location: NSPoint(), modifierFlags: NSEvent.ModifierFlags(),
timestamp: 0, windowNumber: 0, context: nil, characters: "",
charactersIgnoringModifiers: "", isARepeat: false, keyCode: 36)!
await store.send(.operationEventKey(enterKeyEvent)) {
$0.raelizeState = .operationMode
}
await store.receive(.resetState(.neutralMode)) {
$0.raelizeState = .neutralMode
}
}
@Test @MainActor
func enterKeyAndInputWord() async {
let store = TestStore(
initialState: RaelizeIMKReducer.State(
raelizeState: .neutralMode, candinates: ["a0", "a1"], inputWord: "a",
selectedWord: "a0"),
reducer: { RaelizeIMKReducer() },
withDependencies: {
$0.wordListFileUseCase = WordListFileUseCaseMock()
})
let enterKeyEvent = NSEvent.keyEvent(
with: .keyDown, location: NSPoint(), modifierFlags: NSEvent.ModifierFlags(),
timestamp: 0, windowNumber: 0, context: nil, characters: "",
charactersIgnoringModifiers: "", isARepeat: false, keyCode: 36)!
await store.send(.operationEventKey(enterKeyEvent)) {
$0.raelizeState = .operationMode
}
await store.receive(.insertText("a0")) {
$0.insertText = "a0"
$0.raelizeState = .inputMode
}
await store.receive(.resetState(.neutralMode)) {
$0 = RaelizeIMKReducer.State(raelizeState: .neutralMode)
}
}
// テストが続く…

クラスにはswift-testingの@Suite をつけることでテスト実行時のログを見やすくします(@Suite に書いた文字列がテスト実行時に表示されて見やすいです)。

TestStore.sendの第一引数のactionに実行するActionを渡して、第二引数のassert で期待するStateの状態をクロージャで渡します。

ここで注意なのが状態が変化した場合のみStateを渡すということです。TestStoreで初期Stateを決めると思うのですが、初期Stateが変わらないならassertにStateを渡す必要はありません。

テスト対象のActionの中でさらに別のActionを渡すときはTestStore.receiveでそのActionを呼び出します。さらにそのActionも他のActionを呼び出しているなら追加でreceiveを呼び出してあげます。

先ほどのコードだとここらへんです。

await store.send(.operationEventKey(enterKeyEvent)) {
$0.raelizeState = .operationMode
}
await store.receive(.insertText("a0")) {
$0.insertText = "a0"
$0.raelizeState = .inputMode
}
await store.receive(.resetState(.neutralMode)) {
$0 = RaelizeIMKReducer.State(raelizeState: .neutralMode)
}

operationEventKey の中でinsertText("a0") を呼び出しているのでreceiveで呼び出します。さらにそのinsertTextresetState(.neutralMode) を呼び出しているのでreceiveを追加してます。

ただ、自分だけかは分からないのですが、コードを書く時にsendとreceiveの補完がうまく動かないです…他にもassertのクロージャ内のStateの補完も半分くらい書かないと動かないんですよね…

終わりに

TCAでテストを書く時に気をつけていることを紹介しました。

テスト時の@Dependency の渡し方や、sendとreceiveの違いがあまり分かってなかったのですが、書いていくうちにだんだんと理解が深まってきました。普通のアサーションと違って戸惑う部分もありましたが、今では書きやすくていいです。

IMEを作るためにInputMethodKitを使っているのですが、IMKInputControllerを継承したクラスを作って操作に応じて必要なメソッドをオーバーライドしていきます。

この継承したクラスに対してテストコードを直接書くのは難しそうなので、クラス内でTCAのActionとStateを呼び出すだけにすれば、実質IMKInputController内をテストしたことになるので非常に便利です。

swift-testingにも少しだけ触れましたが、@Suite などのアノテーションでテスト結果を見やすくできるのはいい感じです。あとはXcodeとの統合が進んで、Xcode上でテスト結果を見れるようになることを祈ってます…

参考文献