iosswiftunit-testingresolverswift-testing

Intermittent Test Failures with Resolver in Unit Tests


I use the Resolver dependency injection framework in my iOS project and unit tests. Some tests fail intermittently when run together (CMD + U), but they succeed when run independently.

For example, here's a test case that fails:

ViewModel

final class DefaultSuccessViewModel: SuccessViewModel {
    @LazyInjected private var navigator: DefaultNavigator<Destination>
    override func onUIEvent(_ event: SuccessViewIntent) {
        if case .onMainTap = event { navigator.popToRoot() }
    }
}

MockNavigator:

public class MockDefaultNavigator<Destination: Hashable>: DefaultNavigator<Destination> {
    public private(set) var popToRootCallsCount = 0
    override public func popToRoot() {
        popToRootCallsCount += 1
        super.popToRoot()
    }
}

Tests:

class DefaultSuccessViewModelTests {
    private let sut: DefaultSuccessViewModel
    private let mockNavigator: MockDefaultNavigator<Destination>

    init() {
        let mockNavigator = MockDefaultNavigator<Destination>()
        Resolver.main.register { mockNavigator as DefaultNavigator<Destination> }
        self.mockNavigator = mockNavigator
        sut = DefaultSuccessViewModel()
    }

    @Test
    func onUIEvent_onItemTap_popsToRoot() {
        sut.onUIEvent(.onMainTap)
        #expect(mockNavigator.popToRootCallsCount == 1)
    }
}

The test passes when run independently but sometimes fails when run with others (CMD + U). I suspect the MockDefaultNavigator state (popToRootCallsCount) is shared across tests. How can I isolate MockDefaultNavigator between tests to avoid shared state? Why does this issue occur?


Solution

  • So, in order to solve the potential race conditions in the DI during running tests in parallel, provide the Resolver in the initialiser of the class using the DI. If the resolver is nil, the default resolver (aka main) will be used.

    final class DefaultSuccessViewModel: SuccessViewModel {
    
        init(name: Resolver.Name? = nil, container: Resolver? = nil) {
            $navigator.name = name
            $navigator.container = container
        }
    
        @LazyInjected private var navigator: DefaultNavigator<Destination>
        
        override func onUIEvent(_ event: SuccessViewIntent) {
            if case .onMainTap = event { navigator.popToRoot() }
        }
    }
    

    In the test, you would need to create a local resolver instance and dependencies in every test function:

    class DefaultSuccessViewModelTests {
    
        init() {}
    
        @Test
        func onUIEvent_onItemTap_popsToRoot() {
            let resolver = Resolver() 
            let sut = DefaultSuccessViewModel(container: resolver)
            let mockNavigator = MockDefaultNavigator<Destination>()
            resolver.register { mockNavigator as DefaultNavigator<Destination> }
            sut.onUIEvent(.onMainTap)
            #expect(mockNavigator.popToRootCallsCount == 1)
        }
    }
    

    Note, that the dependencies might use other objects which access shared mutable state, in which case you might also get conflicts and race conditions. It's best to "mock away" all these dependencies and ensure that a test only depends on local instances and does not change or access anything from shared state outside the test.