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