Testing TCA Store in XCTest
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