Testing TCA Store in XCTest

Lee young-jun
8 min readMay 23, 2024

--

Since last article, My next journey was setting up tests for the modularized Stores.

Basic

TCA offers us TestStore only for testing. If my reducer has selectDate ,

case .selectDate(let date):
state.selectedDate = date
return .none

we can make testSelectDate.

func testSelectDate() {
let store = TestStore.init(initialState: AddDateStore.State()) {
AddDateStore()
}
}

It would be easier if we bring the store and the reducer definition from #Preview.

#Preview {
AddDateScreen(store: Store(initialState: AddDateStore.State(), reducer: {
AddDateStore()
}))
}

According to the official tutorial, the usage in tests seemed similar to SwiftUI views. But strangely the TestStore can’t auto complete members.(this problem is solved by upgrading to 1.9.3)

Fortunately I could use member by manual typing the code.

The test method should be asynchronous to run send method.

func testSelectDate() async {
let store = TestStore.init(initialState: AddDateStore.State()) {
AddDateStore()
}

let date = Date.now
await store.send(.selectDate(date: date))
}

Now there is no compile error. Let’s test!

Main Thread

My testing has been failed with some errors. Store requires main thread. So we should mark it with @MainActor

@MainActor
final class AddCharacterStoreTests: XCTestCase {

Next error is related to the state of Store. They expect to change if user run any actions.

09:33.033Z is equal to 09:33.078Z?? This is weird. The test couldn’t detect modification of State even though I changed date like this.

let date = Date.now.addingTimeInterval(-3600)

This is why? The tutorial tells us the State should conform Equatable. It’s a solution? My State is already equatable. 🤫

@Reducer
public struct AddDateStore {
@ObservableState
public struct State: Equatable {

Ah! The tutorial also mentioned the error. We should give state modified after performing reduce actions.

await store.send(.selectDate(date: date)) {
$0.selectedDate = date
}

Typically other test frameworks provides method like expect. However TCA’s TestStore seems containing the feature within common method.

Simple tests are for simple actions. How about complex actions like this?

case .saveButtonTapped:
state.isSaving = true

return .run { [selectedDate = state.selectedDate] send in
await send(.delegate( .saveDate(savedDate: selectedDate)))
await self.dismiss()
}

If a user tapped the save button, this Store will update isSaving to true and request save to the presenter Store. So can we test saveButtonTapped ?

await store.send(.saveButtonTapped) {
$0.isSaving = true
}

The test failed with this error

dismiss

First error is caused by dismiss dependency.

@Dependency(\.dismiss) var dismiss

We can solve this problem simply with withDependencies

let store = TestStore.init(...

} withDependencies: { dependencies in
dependencies.dismiss = .init({
isDismissed = true
})
}

However we cannot know whether dismiss called or not. So we should mix XCTest with TestStore.

var testDismissing : XCTestExpectation = .init(description: "Add Character Feature Dismissed")
...
let store = ...
} withDependencies: { dependencies in
dependencies.dismiss = .init({
testDismissing.fulfill()
})
}
...
await fulfillment(of: [testDismissing], timeout: 1)

Second error is related to nested action. So I tried to handle the action, but the Xcode couldn’t detect the action.

await store.receive(\.delegate(.saveDate(savedDate: date)), 
timeout: .seconds(1)) {state in

}

Xcode can solve only .delegate.

await store.receive(\.delegate, 

Therefore there is no solution for nested action. Instead we can ignore action handle.

store.exhaustivity = .off

Please tell me if you have another solution. 🙏

Mocking dependency

Next module is SearchStore. The search actions fetch filtered data from database and pass via update action.

case .search(let keyword):
return .run { @MainActor send in
let characters = try! database.fetchCharacter(where: keyword) ?? []
debugPrint("searched character", characters.count)

send(.updateCharacters(characters))
}

Let’s just run the action in the test.

final class SearchCharacterStoreTests: XCTestCase {
@MainActor
func testSearch() async {
let store = TestStore.init(initialState: SearchCharacterStore.State()) {
SearchCharacterStore()
}

let keyword = "abc"

await store.send(.search(keyword))

This test will raise the error as we expect.

This time we can solve the problem by only one line of code. updateCharacters is not nested action, so it is receivable.

await store.receive(\.updateCharacters, timeout: 1)

However there is something strange. 🤔 The Store has dependency.

@Dependency(\.database) var database

But there is no error related to the database. Why? testValue is defined for DependencyKey it will be used for only testing.

extension DatabaseDependencyKey: TestDependencyKey {
@MainActor
public static var testValue: DatabaseRepository
= DatabaseRepositoryProvider.init(container: ModelContainer.generate(forPreview: true))
}

What would be happended if I remove testValue definition? I couldn’t see the error in IDE like dismiss. The error was printed on the console

@Dependency(\.database) has no test implementation, but was accessed from a test context:

Location:
SearchCharacterStore/SearchCharacterStore.swift:45
Key:
DatabaseDependencyKey
Value:
DatabaseRepository

Dependencies registered with the library are not allowed to use their default, live implementations when run from tests.

To fix, override 'database' with a test value. If you are using the Composable Architecture, mutate the 'dependencies' property on your 'TestStore'. Otherwise, use 'withDependencies' to define a scope for the override. If you'd like to provide a default value for all tests, implement the 'testValue' requirement of the 'DependencyKey' protocol.

Using testValue can solve the error, however we cannot exactly check whether everything works correctly. So we have to create mock of database

class DatabaseProviderMock: DatabaseRepository {
private var characters: [QBCharacter] = []

init(_ characters: [QBCharacter]) {
self.characters = characters
}

func fetchCharacter(where keyword: String) throws -> [QBCharacter]? {
return characters.filter{ $0.name.contains(keyword) }
}

func insert<Model>(_ model: Model) where Model : PersistentModel {
characters.append(model as! QBCharacter)
}
}

Let’s inject this mock into the test.

let filteredCharacters = characters.filter{ $0.name.contains(keyword) }

let store = TestStore.init(initialState: SearchCharacterStore.State()) {
SearchCharacterStore()
} withDependencies: { dependencyValues in
dependencyValues.database = DatabaseProviderMock(characters)
}

await store.send(.search(keyword))

await store.receive(\.updateCharacters, timeout: 1) {
$0.characters = filteredCharacters
}

This test had been failed.

The model is Swift Data Model, thereforeModelContainer activation is required. I created temporary container. forPreview will be passed as isStoredInMemoryOnly of ModelConfiguration.

func testSearch() async {
let modelContainer = ModelContainer.generate(forPreview: true)

This Store’s addButtonTapped action will create addDateState as a nested store.

case .addButtonTapped:
state.addDateState = AddDateStore.State.init(selectedDate: Date.now)
return .none

Date.now

It is impossible to give Date.now to addButtonTapped action. So we should inject mock again something like DateProvider??

Fortunately TCA offers built-in Date.now dependency.

@Dependency(\.date.now) var now
...
case .addButtonTapped:
state.addDateState = AddDateStore.State.init(selectedDate: now)

And now is settable,

/// The current date.
public var now: Date {
get { self.generate() }
set { self.generate = { newValue } }
}

therefore we are able to override it.

@MainActor func testAddButtonTapped() async {
let now = Date.now
let store = TestStore.init(initialState: SearchCharacterStore.State()) {
SearchCharacterStore()
} withDependencies: { dependencyValues in
dependencyValues.date.now = now
}

await store.send(.addButtonTapped) { [now = now] in
$0.addDateState = .init(selectedDate: now)
}
}

Handle presented actions

Actually AddDateStore will call delegate. How to send action to the nested store? When the Search Store has addDateAction,

public num Action {
case addDateAction(PresentationAction<AddDateStore.Action>)
}

test should be written like this.

await store.send(\.addDateAction.saveButtonTapped) {
$0.addDateState?.isSaving = true
}

Unluckily to us this test also has problems.

I could solve the second error with this handling.

await store.receive(\.addDateAction.dismiss) {
$0.addDateState = nil
}

CasePathable

But another one was something different, I couldn’t specify the key path to saveDate action, so I had to handle it with delegate , not delegate.saveDate

await store.receive(\.addDateAction.presented.delegate)

I couldn’t find other solutions. So I asked to Point-Free Slack and got @CasePathable .

@CasePathable
public enum DelegateAction: Equatable {

Now I can handle delegate.saveDate

await store.receive(\.addDateAction.presented.delegate.saveDate)

If you want to check also the parameter, give the value

...delegate.saveDate, now)

I just focused on my simple project.
If you want more advanced cases, please see the official guide.

If you found any helpful from this post, please give it a round of applause 👏. Explore more iOS-related content in my other posts.

You can give me claps up to 50 if you keep pressing the button :)

For additional insights and updates, check out my LinkedIn profile. Thank you for your support!

Troubleshootings

‘async’ call in a function that does not support concurrency

func testSelectDate() async {

A store initialized on a non-main thread. …

@MainActor func testSelectDate() async {

Main actor-isolated class ‘…Tests’ has different actor isolation from nonisolated superclass ‘XCTestCase’; this is an error in Swift 6

@MainActor func

don’t

@MainActor final class ...Tests: XCTestCase

A reducer requested dismissal at …, but couldn’t be dismissed. …

withDependencies: { dependencies in
dependencies.dismiss = .init({
testDismissing.fulfill()
})
}

Unhandled actions: .delegate(.saveDate(savedDate:))

store.exhaustivity = .off

failed to find a currently active container for

Create ModelContainer

let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true

no such module ‘ProjectDescriptionHelpers’

remove Tuist directory from sub project.

no such module after .relativeToRoot

remove Tuist directory from sub project.

TestStore can’t autocomplete

Specify State, Action type

let store : TestStore<...State, ...Action>

Or Upgrade 1.9.3 or later

References

--

--

No responses yet