I'm working on an iOS app (utilizing Swift, XCTest, and Combine) trying to test a function within my view model, which is calling and setting a sink
on a publisher. I'd like to test the view model, not the publisher itself. I really don't want to use DispatchQueue.asyncAfter(
because theoretically I don't know how long the publisher will take to respond. For instance, how would I test XCTAssertFalse(viewModel.isLoading)
class ViewModel: ObservableObject {
@Published var isLoading: Bool = false
@Published var didError: Bool = false
var dataService: DataServiceProtocol
init(dataService: DataServiceProtocol) {
self.dataService = dataService
}
func getSomeData() { // <=== This is what I'm hoping to test
isLoading = true
dataService.getSomeData() //<=== This is the Publisher
.sink { (completion) in
switch completion {
case .failure(_):
DispatchQueue.main.async {
self.didError = true
}
case .finished:
print("finished")
}
DispatchQueue.main.async {
self.isLoading = false
}
} receiveValue: { (data) in
print("Ok here is the data", data)
}
}
}
I'd like to write a test that might look like:
func testGetSomeDataDidLoad() {
// this will test whether or not getSomeData
// loaded properly
let mockDataService: DataServiceProtocol = MockDataService
let viewModel = ViewModel(dataService: mockDataService)
viewModel.getSomeData()
// ===== THIS IS THE PROBLEM...how do we know to wait for getSomeData? ======
// It isn't a publisher...so we can't listen to it per se... is there a better way to solve this?
XCTAssertFalse(viewModel.isLoading)
XCTAssertFalse(viewModel.didError)
}
Really hoping to refactor our current tests so we don't utilize a DispatchQueue.asyncAfter(
CodePudding user response:
I suggest you look at the combine-schedulers
package. With that package, you can change ViewModel
to take an AnySchedulerOf<DispatchQueue>
argument:
class ViewModel: ObservableObject {
@Published var isLoading: Bool = false
@Published var didError: Bool = false
var dataService: DataServiceProtocol
var scheduler: AnySchedulerOf<DispatchQueue>
init(
dataService: DataServiceProtocol,
scheduler: AnySchedulerOf<DispatchQueue>
) {
self.dataService = dataService
self.scheduler = scheduler
}
func getSomeData() {
isLoading = true
dataService
.getSomeData()
// ********
// We use the scheduler instead of DispatchQueue.main.
// ********
.receive(on: scheduler)
.sink { (completion) in
switch completion {
case .failure(_):
self.didError = true
case .finished:
print("finished")
}
self.isLoading = false
}
} receiveValue: { (data) in
print("Ok here is the data", data)
}
}
}
When you create your ViewModel
in a production environment, you pass in the real DispatchQueue.main
:
let viewModel = ViewModel(
dataService: LiveDataService(),
scheduler: DispatchQueue.main.eraseToAnyScheduler()
)
In your test, you pass in a TestSchedulerOf<DispatchQueue>
, and drive the flow of time manually:
func testGetSomeDataDidLoad() {
let mockDataService: DataServiceProtocol = MockDataService()
let clock = DispatchQueue.test
let viewModel = ViewModel(
dataService: mockDataService,
scheduler: clock
)
viewModel.getSomeData()
// Time hasn't advanced yet, so the model is still loading:
XCTAssertTrue(viewModel.isLoading)
XCTAssertFalse(viewModel.didError)
// Assuming MockDataService completed synchronously, there are actions
// queued in the test scheduler.
// Run the test scheduler until there are not pending actions left.
clock.run()
// Now viewModel should have finished loading.
XCTAssertFalse(viewModel.isLoading)
XCTAssertFalse(viewModel.didError)
}
CodePudding user response:
Yeah, everybody's saying, MVVM increases testability. Which is hugely true, and thus a recommended pattern. But, how you test View Models is shown only very rarely in tutorials. So, how can we test this thing?
The basic idea testing a view model is using a mock which can do the following:
- The mock must record changes in its output (which is the published properties)
- Record a change of the output
- Apply an assertion function to the output
- Possibly record more changes
In order to work better with the following tests, refactor your ViewModel slightly, so it gets a single value representing your view state, using a struct:
final class MyViewModel {
struct ViewState {
var isLoading: Bool = false
var didError: Bool = false
}
@Published private(set) var viewState: ViewState = .init()
...
}
Then, define a Mock for your view. You might try something like this, which is a pretty naive implementation:
The mock view also gets a list of assertion functions which test your view state in order.
class MockView {
var viewModel: MyViewModel
var cancellable = Set<AnyCancellable>()
typealias AssertFunc = (MyViewModel.ViewState) -> Void
let asserts: ArraySlice<AssertFunc>
private var next: AssertFunc? = nil
init(viewModel: MyViewModel, asserts: [AssertFunc]) {
self.viewModel = viewModel
self.asserts = ArraySlice(asserts)
self.next = asserts.first
viewModel.$viewState
.sink { newViewState in
self.next?(newViewState)
self.next = self.asserts.dropFirst().first
}
}
}
You may setup the mock like this:
let mockView = MockView(
viewModel: viewModel,
asserts: [
{ state in
XCTAssertEqual(state.isLoading, false)
XCTAssertEqual(state.didError, false)
},
{ state in
XCTAssertEqual(state.isLoading, true)
...
},
...
])
You can also use XCT expectation in the assert functions.
Then, in your test you create the view model, your mock data service and the configured mockView.
let mockDataService: DataServiceProtocol = MockDataService
let viewModel = ViewModel(dataService: mockDataService)
let mockView = MockView(
viewModel: viewModel,
asserts: [
{ state in
XCTAssertEqual(state.isLoading, false)
XCTAssertEqual(state.didError, false)
},
...
{ state in
XCTAssertEqual(state.isLoading, false)
XCTAssertEqual(state.didError, false)
expectFinished.fulfill()
},
...
])
viewModel.getSomeData()
// wait for the expectations
Caution: I didn't compile or run the code.
You may also take a look at Entwine