Home > Software engineering >  Unit testing view model that depends on a publisher
Unit testing view model that depends on a publisher

Time:01-28

I implemented a service class with a function that returns a publisher when some data is loaded:

class Service {
    let fileURL: URL // Set somewhere else in the program

    func loadModels() -> AnyPublisher<[MyModelClass], Error> {
        URLSession.shared.dataTaskPublisher(for: fileURL)
            .map( { $0.data } )
            .decode(type: [MyModelClass].self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

This function is used in my view model class like this:

class ViewModel: ObservableObject {
    @Published var models: [MyModelClass]?
    
    var cancellables = Set<AnyCancellable>()
    let service: Service
    
    init(service: Service) {
        self.service = service
        loadCityData()
    }
    
    func loadModels()  {
        service.loadModels()
            .sink { _ in
            } receiveValue: { [weak self] models in
                self?.models = models
            }
            .store(in: &cancellables)
    }
}

I find the view model difficult to unit-test because I don't have the publisher returned from the service available directly in my unit test class, but I have the @Published property instead. So I tried to implement a test like this one:

let expectation = expectation(description: "loadModels")

viewModel.$models
    .receive(on: RunLoop.main)
    .sink(receiveCompletion: { _ in
        finishLoading.fulfill()
    }, receiveValue: { _ in
    })
    .store(in: &cancellables) // class-scoped property

viewModel.loadModels()

wait(for: [expectation], timeout: 10)

The problem is that the receiveComplection callback is never called. If I had the publisher available (the one returned from the Service object), the same code applied to the publisher would run successfully and fulfill the expectation. Instead, the complection is not being called but the receiveValue is being called multiple times. Why?

CodePudding user response:

First, instead of passing the entire service to the view model, just pass in the Publisher itself. In the test, you can pass in a synchronous publisher which makes testing much easier.

final class ExampleTests: XCTestCase {
    func test() {
        let input = [MyModelClass()]
        let modelLoader = Just(input).setFailureType(to: Error.self).eraseToAnyPublisher()
        let viewModel = ViewModel(modelLoader: modelLoader)
        let cancellable = viewModel.$models
            .dropFirst(1)
            .sink(receiveValue: { output in
                XCTAssertEqual(input, output)
            })
        viewModel.loadModels()
    }
}

class ViewModel: ObservableObject {
    @Published var models: [MyModelClass]?

    var cancellables = Set<AnyCancellable>()
    let modelLoader: AnyPublisher<[MyModelClass], Error>

    init(modelLoader: AnyPublisher<[MyModelClass], Error>) {
        self.modelLoader = modelLoader
    }

    func loadModels()  {
        modelLoader
            .sink { _ in
            } receiveValue: { [weak self] models in
                self?.models = models
            }
            .store(in: &cancellables)
    }
}

Notice that there is no need to setup an expectation and wait for it. This makes for a much faster test.

Even simpler would be to just examine the models property directly:

final class ExampleTests: XCTestCase {
    func test() {
        let input = [MyModelClass()]
        let modelLoader = Just(input).setFailureType(to: Error.self).eraseToAnyPublisher()
        let viewModel = ViewModel(modelLoader: modelLoader)
        viewModel.loadModels()
        XCTAssertEqual(viewModel.models, input)
    }
}

For all of these though, what exactly do you think you are testing here? There are no transformations and no logic in this code. You aren't testing to ensure the ViewModel calls into the Service, because in order to do this test at all, you have to mock out the Service. So in reality, the only thing you are doing is testing to see if the test itself mocked out the Service correctly. But what's the point in that? Who cares if the test was set up correctly if it doesn't test production code?

CodePudding user response:

You could use a combination of interface and dependency injection to allow testing.

First you define an interface for service:

protocol ServiceInterface {
    func loadModels() -> AnyPublisher<[MyModelClass], Error>
}

Next you make Service conform to this new protocol:

class Service: ServiceInterface {
    // ...
}

Now you can inject Service into your ViewModel using the interface defined above:

class ViewModel: ObservableObject {
    //...
    let service: ServiceInterface
    
    init(service: ServiceInterface = Service()) {
        self.service = service
        loadModels()
    }
    //...
}

This means you are able to inject any entity conforming to ServiceInterface into the ViewModel, so let's define one in the test target:

struct MockService: ServiceInterface {
    
    let loadModelsResult: Result<[MyModelClass], Error>
    
    func loadModels() -> AnyPublisher<[MyModelClass], Error> {
        loadModelsResult.publisher.eraseToAnyPublisher()
    }
}

Lastly let's inject MockService into ViewModel for testing purposes:

func testExample() {
        let expectedModels = [MyModelClass()]
        let subject = ViewModel(service: MockService(loadModelsResult: .success(expectedModels)))
        let expectation = expectation(description: "expect models to get loaded")

        subject
            .$models
            .sink(
                receiveCompletion: { _ in },
                receiveValue: { actualModels in
                    // or any other test that is meaningful in your context
                    if actualModels == expectedModels {
                        expectation.fulfill()
                    }
                }
            )
            .store(in: &cancellables)

        subject.loadModels()

        waitForExpectations(timeout: 0.5)
    }
  • Related