hmlongco / Factory

A new approach to Container-Based Dependency Injection for Swift and SwiftUI.
MIT License
1.7k stars 107 forks source link

Unable to override a context-based factory #212

Closed raymondkam closed 1 week ago

raymondkam commented 1 week ago

When using Contexts with either a list of contexts .context(.test, .preview) or using one of the existing helpers (onTest, onPreview, etc), it seems like there's no way to override or replace the factory that is specified for the context

The reason for overriding the factory is in unit tests when I want to specify an exact instance of a dependency that I want to be able to control (such as manipulating the state of a mock), but also have the context factory for other unit tests where I don't need to directly control that dependency.

Here's a small example of the scenario

Let's say I have a protocol AuthServicing where I want to register a mock when running unit tests (and for previews)

protocol AuthServicing {
    func login() async throws -> Token
}

class AuthService: AuthServicing {
    func login() async throws -> Token {
        ...
    }
}

class MockAuthService: AuthServicing {
    // Has other functions to control the mock
    ...
    func login() async throws -> Token {
        ...
    }
}

extension Container {
    var authService: Factory<AuthServicing> {
        self {
            AuthService()
        }
        .onTest { _ in
            MockAuthService() // Will always use this registration instead of the other one in the test
        }
        /* This also has the same behaviour
        .context(.preview, .test) {
            MockAuthService()
        }
        */
    }
}

Then let's say I have a class that has this AuthServicing dependency

class UserManager {
    @Injected(\.authService) var authService
    ...
    func login() async {
        let token = await authService.login() 
        ...
    }
}

Then inside a unit test for the UserManager, I want to have a specific instance of my mock which has functions to control the mock object for various scenarios

final class UserManagerTests: XCTestCase {
    var mockAuthService: MockAuthService!
    var sut: UserManager!

    func setUp() {
        // Attempt to override with a separate instance of AuthServicing
        let mockAuthService = MockAuthService()
        self.mockAuthService = mockAuthService
        Container.shared.authService.register { _ in
            mockAuthService // never called
        }
        .onTest {
            mockAuthService // never called
        }

        sut = UserManager()
    }

    func test_login() async {
        mockAuthService.loginResult = ... // example of controlling the mock

        await sut.login()
        ... assertions, etc
    }
}

I've tried a couple of things such as using .reset on the type or on the container but it always reverts back to the original factory registration in the Container extension. The only way I've gotten this to work is to avoid using contexts where the factory override in the unit test class will work. The other alternative was to add a cached/singleton scope where I can resolve the mock instance and configure it there, but I thought the override would be cleaner and also allow for push and pop Container features to work as well.

Thanks in advance, let me know if I can provide any additional details

hmlongco commented 1 week ago

Checkout the documentation for once.

https://hmlongco.github.io/Factory/documentation/factory/modifiers/#Once

Yes, you changed the context... but when UserManager is initialized it's going to resolve authService, which calls the authService factory, which calls the onText modifier again before it resolves.

Yep, you set it. And then the factory you specified sets it back.

As I said in the docs it’s probably best simply to be careful in regard to what goes into our basic Factory definition.

Contexts, in particular, should probably be defined in the Container’s autoRegister function.

raymondkam commented 1 week ago

Thanks for the quick reply @hmlongco

The Container autoRegister works for the use case I'm thinking of, where I can auto register a default mock class for unit tests and where I need to do an override I can specify within the unit test class itself

extension Container: AutoRegistering {
    public func autoRegister() {
        #if DEBUG
        authService
            .context(.test) {
                MockAuthService() // This gets called in the test context if there's no override specified
            }
        #endif
    }
}

Then back in my unit test class

final class UserManagerTests: XCTestCase {
    var mockAuthService: MockAuthService!
    var sut: UserManager!

    func setUp() {
        let mockAuthService = MockAuthService()
        self.mockAuthService = mockAuthService
        Container.shared.authService.onTest {
            mockAuthService // Override is now called
        }

        sut = UserManager()
    }
    ...
}

Using once should also work for that same scenario. Appreciate the help pointing me to the right place 👍

hmlongco commented 1 week ago

Just reread that and realized I probably should have quoted once.... ;)

Checkout the documentation for once.