Unit testing SwiftUI/Combine @Published boolean values
I am trying to acquaint myself with unit testing some view models in SwiftUI. The view model currently has two @Published
boolean values that publish changes when an underlying UserDefaults
property changes. For my unit tests, I have followed this guide on how to setup UserDefaults
for testing so my production values are not modified. I am able to test the default value as such:
func testDefaultValue() {
XCTAssertFalse(viewModel.canDoThing)
}
How would I go about toggling the @Published
value then ensuring my view model has received the changes? So for instance, I have a reference to my mock user defaults in my XCTestCase. I attempted to do the following with zero success:
func testValueTogglesToTrue() {
defaults.canDoThing = true
XCTAssertTrue(viewModel.canDoThing)
}
The thought being that updating the underlying user defaults value that is publishing changes to the published value in the view model will notify our view model. The above does not do anything to the view model variable. Do I need to subscribe to the publisher and use sink to accomplish this?
Let's say you store a flag in UserDefaults
to know whether the user has completed onboarding:
extension UserDefaults {
@objc dynamic public var completedOnboarding: Bool {
bool(forKey: "completedOnboarding")
}
}
You have a ViewModel which tells your View
whether to show onboarding or not and has a method to mark onboarding as completed:
class ViewModel: ObservableObject {
@Published private(set) var showOnboarding: Bool = true
private let userDefaults: UserDefaults
public init(userDefaults: UserDefaults) {
self.userDefaults = userDefaults
self.showOnboarding = !userDefaults.completedOnboarding
userDefaults
.publisher(for: \.completedOnboarding)
.map { !$0 }
.receive(on: RunLoop.main)
.assign(to: &$showOnboarding)
}
public func completedOnboarding() {
userDefaults.set(true, forKey: "completedOnboarding")
}
}
To test this class you have a XCTestCase
:
class MyTestCase: XCTestCase {
private var userDefaults: UserDefaults!
private var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
try super.setUpWithError()
userDefaults = try XCTUnwrap(UserDefaults(suiteName: #file))
userDefaults.removePersistentDomain(forName: #file)
}
// ...
}
Some of the test cases are synchronous for example you can easily test that showOnboarding depends on UserDefaults
completedOnboarding property:
func test_whenCompletedOnboardingFalse_thenShowOnboardingTrue() {
userDefaults.set(false, forKey: "completedOnboarding")
let subject = ViewModel(userDefaults: userDefaults)
XCTAssert(subject.showOnboarding)
}
func test_whenCompletedOnboardingTrue_thenShowOnboardingFalse() {
userDefaults.set(true, forKey: "completedOnboarding")
let subject = ViewModel(userDefaults: userDefaults)
XCTAssertFalse(subject.showOnboarding)
}
Some test are asynchronous, which means you need to use XCTExpectation
s to wait for the @Published
value to change:
func test_whenCompleteOnboardingCalled_thenShowOnboardingFalse() {
let subject = ViewModel(userDefaults: userDefaults)
// first define the expectation that showOnboarding will change to false (1)
let showOnboardingFalse = expectation(
description: "when completedOnboarding called then show onboarding is false")
// subscribe to showOnboarding publisher to know when the value changes (2)
subject
.$showOnboarding
.filter { !$0 }
.sink { _ in
// when false received fulfill the expectation (5)
showOnboardingFalse.fulfill()
}
.store(in: &cancellables)
// trigger the function that changes the value (3)
subject.completedOnboarding()
// tell the tests to wait for your expectation (4)
waitForExpectations(timeout: 0.1)
}