hmlongco / Factory

A new approach to Container-Based Dependency Injection for Swift and SwiftUI.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

UnitTesting Cached Service Issue/Question

coletoncodes opened this issue Β· comments

I've read the docs a few times, and have tried a few different approaches for my UnitTests. I'll share my current approach, and perhaps I'm missing something.

I have created a SharedContainer named DIContainer, it looks like so:

final class DIContainer: SharedContainer {
    static var shared = DIContainer()
    
    var manager: ContainerManager = {
        let containerManager = ContainerManager()
        containerManager.trace.toggle()
        containerManager.logger = {
            log($0, .debug, .default)
        }
        return containerManager
    }()
    
    init() {}
}

extension DIContainer {
        
    public func registerMocks() {
        userLocalStore.register { MockUserLocalStore() }
        taskItemsPersistenceStore.register {
            MockPersistenceStore("MockTaskItems", defaultValue: [])}
        userPersistenceStore.register {
            MockPersistenceStore("Mock User", defaultValue: User.defaultValue)
        }
    }
        
    // MARK: - TaskItemLocalStore
    var taskItemLocalStore: Factory<TaskItemsLocalStore> {
        self { TaskItemsLocalRepo() as TaskItemsLocalStore }
            .cached
    }
    
    // MARK: - UserLocalStore
    var userLocalStore: Factory<UserLocalStore> {
        self { UserLocalRepo() as UserLocalStore }
            .cached
    }
    
    var unNotificationCenter: Factory<UNUserNotificationCenter> {
        self { UNUserNotificationCenter.current() }
            .graph
    }
    
    // MARK: - NotificationPermissionsInteracting
    var notificationPermissionsInteractor: Factory<NotificationPermissionsInteracting> {
        self { NotificationPermissionsInteractor() as NotificationPermissionsInteracting }
            .cached
    }
    
    // MARK: - OnboardingInteracting
    var onboardingInteractor: Factory<OnboardingInteracting> {
        self { OnboardingInteractor() as OnboardingInteracting }
            .cached
    }
    
    var taskGoalCompletionInteractor: Factory<TaskGoalCompletionInteracting> {
        self { TaskGoalCompletionInteractor() as TaskGoalCompletionInteracting }
            .cached
    }
    
    var userPersistenceStore: Factory<PersistenceStore<User>> {
        self { PersistenceStore("User") }
            .cached
    }
    
    var taskItemsPersistenceStore: Factory<PersistenceStore<[TaskItem]>> {
        self { PersistenceStore("TaskItems") }
            .cached
    }
}

I'm having this issue with all of my tests, but for simplicity I will only be discussing the OnboardingInteractorTests.

For context, this Interactor is injected into my RootView's ViewModel like so:

final class RootViewVM: ViewModel<RootViewViewState> {
    
    // MARK: - Dependencies
    @Injected(\DIContainer.userLocalStore) private var userStore
    
    // MARK: - Configure
    override func configure() {
        self.state.user = userStore.user
        
        self.userStore
            .userUpdatePublisher
            .receive(on: DispatchQueue.main)
            .sink { updatedUser in
                self.state.user = updatedUser
            }
            .store(in: &cancellables)
    }
}

I realize this isn't a normal architecture and the self.state is not explained here, but essentially this configure() method is called when the ViewAppears, and there is a middle layer called a "ViewState" which is what the View uses to draw it's content and is Published via @State.

UI Architecture aside, my RootView checks which view to present based on the User's onboarding state.

var body: some View {
// Computed property based on the persisted "OnboardingState"
        if state.shouldShowOnboarding {
            OnboardingRoot()
        } else {
            HomeScreen()
        }
    }

So almost immediately at app launch this injected service is used and the view is drawn.

In my normal workflow for UnitTesting I use "stubs" that are force unwrapped so that in that stub I can define the UnitTest functionality and make my assertions accordingly.

final class MockUserLocalStore: UserLocalStore {
    var userStub: (() -> User)!
    var saveUserStub: ((User) throws -> Void)!
    var userUpdatePublisherStub: (() -> AnyPublisher<User, Never>)!
    
    var user: User {
        userStub()
    }
    
    func save(_ user: User) throws {
        try saveUserStub(user)
    }
    
    var userUpdatePublisher: AnyPublisher<User, Never> {
        userUpdatePublisherStub()
    }
}

However, given the way Factory resolves its dependencies, I cannot use force unwrapping for this use case. Which is acceptable and makes sense to me. Not a major issue.

So I've adapted to be like so:

final class MockUserLocalStore: UserLocalStore {
    var userStub: (() -> User) = { User.defaultValue }
    var saveUserStub: ((User) throws -> Void) = { _ in }
    var userUpdatePublisherStub: (() -> AnyPublisher<User, Never>) = {
        Just(User.defaultValue).eraseToAnyPublisher()
    }
    
    var user: User {
        userStub()
    }
    
    func save(_ user: User) throws {
        try saveUserStub(user)
    }
    
    var userUpdatePublisher: AnyPublisher<User, Never> {
        userUpdatePublisherStub()
    }
}

Okay, prerequisites are provided for my Application. Now on to the UnitTesting setup.

For the tests related to the OnboardingInteractor class, I have this:

final class OnboardingInteractorTests: XCTestCase {
    private var sut: OnboardingInteractor!
    private var mockUserStore: MockUserLocalStore!
    
    // MARK: - Lifecycle
    override func setUp() {
        super.setUp()
        sut = OnboardingInteractor()
        DIContainer.shared.reset()
        DIContainer.shared.registerMocks()
        
        mockUserStore = DIContainer.shared.userLocalStore() as? MockUserLocalStore
    }
    
    override func tearDown() {
        super.tearDown()
        sut = nil
        mockUserStore = nil
    }
    
    // MARK: - Tests
    func test_OnboardingState_MatchesUserOnboardingState() {
        /** Given */
        var user = User(onboardingState: .start)
        for onboardingState in OnboardingState.allCases {
            
            mockUserStore.userStub = {
                user.onboardingState = onboardingState
                return user
            }
            
            mockUserStore.saveUserStub = { _ in }
            
            /** When/Then */
            // state should match user's
            XCTAssertEqual(sut.onboardingState, onboardingState)
        }
    }
    
    /// More tests, not relevant

Now my registration for the mock interactor is not being updated, and in fact is still cached between test runs.

Logs:

2023-10-09 21:25:54.645957-0500 EntreNote[21494:586271] [πŸ›] -- Debug | (Default) | Message: 0: EntreNote.DIContainer.userLocalStore<EntreNote.UserLocalStore> = N:105553151062208 | File: Container+.swift | Function: DIContainer at line: 20
2023-10-09 21:25:54.663558-0500 EntreNote[21494:586271] [πŸ›] -- Debug | (Default) | Message: 1:     EntreNote.DIContainer.userLocalStore<EntreNote.UserLocalStore> = C:105553151062208 | File: Container+.swift | Function: DIContainer at line: 20
2023-10-09 21:25:54.663739-0500 EntreNote[21494:586271] [πŸ›] -- Debug | (Default) | Message: 0: EntreNote.DIContainer.userLocalStore<EntreNote.UserLocalStore> = C:105553151062208 | File: Container+.swift | Function: DIContainer at line: 20
2023-10-09 21:25:54.882758-0500 EntreNote[21494:586271] [πŸ›] -- Debug | (Default) | Message: 0: EntreNote.DIContainer.userLocalStore<EntreNote.UserLocalStore> = C:105553151062208 | File: Container+.swift | Function: DIContainer at line: 20

In my UnitTest, it appears that the original stubs are not being recreated and the original MockUserLocalStore's stubs are always returned, even though I'm setting the closure to perform differently in my test.

Which sort of makes sense, as the object that is returned is the "cached" object, based on the above logs. However, I'm resolving the dependency based on the Testing documentation and it should operate as I expect.

As far as I can tell I have followed the Documentation accordingly, and after a few days of trying a few different approaches I've ran out of solutions and am hoping I am missing something simple and not exposing a bug.

A few of the things I've tried:

  • Using the AutoRegistering protocol, and .onTest context.
  • The push() & pop() setup in the Documentation
  • Registering and resolving the dependency inside the Test block, and not in the lifecycle. (see code snippet below)
func test_OnboardingState_MatchesUserOnboardingState() {
        /** Given */
        let mockUserStore = DIContainer.shared.userLocalStore() as! MockUserLocalStore
        var user = User(onboardingState: .start)
        for onboardingState in OnboardingState.allCases {
            
            mockUserStore.userStub = {
                user.onboardingState = onboardingState
                return user
            }
            
            mockUserStore.saveUserStub = { _ in }
            
            /** When/Then */
            // state should match user's
            XCTAssertEqual(sut.onboardingState, onboardingState)
        }
    }

Any suggestions are greatly appreciated, and hopefully I have provided enough information.

Not sure if I can debug this based on what's given, but I will mention a couple of things, the most notable of which is...

    override func setUp() {
        super.setUp()
        sut = OnboardingInteractor() // why is this created BEFORE reset and mock setup?
        DIContainer.shared.reset()
        DIContainer.shared.registerMocks()
        
        mockUserStore = DIContainer.shared.userLocalStore() as? MockUserLocalStore
    }

Generally you'd create the sut after setting up the mocks needed. As is, and since @injected resolves immediately, I'd expect you to be getting the original objects.

One other minor nit, in this code

        self.userStore
            .userUpdatePublisher
            .receive(on: DispatchQueue.main)
            .sink { updatedUser in
                self.state.user = updatedUser
            }
            .store(in: &cancellables)

You have a strong retain cycle in the sink. Should be sink { [weak self] updatedUser in self?.state.user = updatedUser }

If this doesn't help you'll probably need to create a baby project that demonstrates the problem.

That said, most issue of this type are due to timing. Breakpoint the unit tests and Factory registration closures and see when what is being created.

My goodness, I did actually fix the strong retain cycle in the sink shortly after updating this, but you are correct.

When I moved the SUT instantiation to the end, and added the .once() call for the factory my tests are now passing and behaving as expected.

I knew it was something simple, but only just now discovered the .once() modifier (great addition by the way).

We can consider this issue closed. Silly me. Thanks!

None of those registrations look like they should require once.