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 XCTExpectations 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)
}