I am new to working with dependency injection. When I run a test with a mocked object the success result case isn't getting triggered and the object isn't added to my @Published
property which I am trying to test.
When I go to test my viewModel
I can see the array to be tested but when getShows()
gets called in the test the Result
case is used.
Test File
import XCTest
@testable import PopularTVViewer
class PopularTVViewerTests: XCTestCase {
func testPopularTVModel() {
let mockedManager = MockedPopularTVManager()
mockedManager.result = .success(mockedManager.mockPopularShows)
let viewModel = PopularTVViewModel(manager: mockedManager)
#warning("getShows() success case isn't being called even through viewModel has its reference.")
viewModel.getShows()
XCTAssertNotNil(viewModel)
XCTAssertNotNil(mockedManager.result)
// Currently failing
// XCTAssertEqual(viewModel.popularTV.count, 4)
XCTAssertEqual(mockedManager.getPopularShowsCallCounter, 1)
}
}
MockedManager
class MockedPopularTVManager: PopularTVManagerProtocol {
var result: Result<[PopularTV], NetworkError>!
var getPopularShowsCallCounter = 0
func getPopularShows(completion: @escaping (Result<[PopularTV], NetworkError>) -> Void) {
completion(result)
getPopularShowsCallCounter = 1
}
let mockPopularShow = PopularTV(name: "House of the Dragon", posterPath: "/mYLOqiStMxDK3fYZFirgrMt8z5d.jpg", popularity: 4987.163, voteAverage: 7.7, voteCount: 881)
let mockPopularShows = [
PopularTV(name: "The Lord of the Rings: The Rings of Power", posterPath: "/mYLOqiStMxDK3fYZFirgrMt8z5d.jpg", popularity: 4987.163, voteAverage: 7.7, voteCount: 881),
PopularTV(name: "House of the Dragon", posterPath: "/z2yahl2uefxDCl0nogcRBstwruJ.jpg", popularity: 4979.127, voteAverage: 8.6, voteCount: 1513),
PopularTV(name: "She-Hulk: Attorney at Law", posterPath: "/hJfI6AGrmr4uSHRccfJuSsapvOb.jpg", popularity: 2823.739, voteAverage: 7.1, voteCount: 846),
PopularTV(name: "Dahmer – Monster: The Jeffrey Dahmer Story", posterPath: "/f2PVrphK0u81ES256lw3oAZuF3x.jpg", popularity: 1774.56, voteAverage: 8.3, voteCount: 402)
]
}
ViewModel
final class PopularTVViewModel: ObservableObject {
@Published var popularTV = [PopularTV]()
let manager: PopularTVManagerProtocol
let columns = ColumnLayoutHelper.threeColumnLayout
// Injecting for testing.
init(manager: PopularTVManagerProtocol = PopularTVManager()) {
self.manager = manager
}
// Grabbing next page of results
func getMoreShows() {
getShows()
}
// Initial network call.
func getShows() {
manager.getPopularShows() { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let popularTV):
for show in popularTV {
self?.popularTV.append(show)
}
case .failure(let error):
switch error {
case .invalidURL:
print("Invalid URL")
case .invalidData:
print("Invalid Data")
case .unableToComplete:
print("Unable to complete")
case .invalidResponse:
print("Invalid response")
}
}
}
}
}
}
I've done everything I can think of to make sure the object exists and the viewModel
has access to it but its as if the mockedManager
in my test doesn't have the success when getShows()
is run.
CodePudding user response:
The test is failing because you are switching threads during your test. The DispatchQueue.main.async
in your PopularTVViewModel
is causing it to fail.
You need to remove the use of DispatchQueue.main.async
from your test. This is possible to do.
We first need to create a function that will stand in place of the call to DispatchQueue.main.async
. This function will check that we are on the main thread and if we are execute the code directly without switching threads, otherwise if we are on a background thread it will dispatch on the main thread. This should mean that in your application it works exactly the same as before, and in your tests it avoids the thread hop so that they now pass.
/// You could make this a global function, an extension on DispatchQueue,
/// the choice where to put it is up to you, but it should be accessible
/// by whichever classes need to use it as chances are you may need to use
/// it in multiple places.
func performUIUpdate(using closure: @escaping () -> Void) {
if Thread.isMainThread {
closure()
} else {
DispatchQueue.main.async(execute: closure)
}
}
We can then update your PopularTVViewModel
to use the new function.
final class PopularTVViewModel: ObservableObject {
@Published var popularTV = [PopularTV]()
let manager: PopularTVManagerProtocol
let columns = ColumnLayoutHelper.threeColumnLayout
// Injecting for testing.
init(manager: PopularTVManagerProtocol = PopularTVManager()) {
self.manager = manager
}
// Grabbing next page of results
func getMoreShows() {
getShows()
}
// Initial network call.
func getShows() {
manager.getPopularShows() { [weak self] result in
performUIUpdate { // Note we use the new function here instead of DispatchQueue.main.async
switch result {
case .success(let popularTV):
// you could use the following instead of your for loop.
// self?.popularTV.append(contentsOf: popularTV)
for show in popularTV {
self?.popularTV.append(show)
}
case .failure(let error):
switch error {
case .invalidURL:
print("Invalid URL")
case .invalidData:
print("Invalid Data")
case .unableToComplete:
print("Unable to complete")
case .invalidResponse:
print("Invalid response")
}
}
}
}
}
}
Your tests should now pass.
There is a great article by John Sundell that shows how to reduce flakiness in testing.
Also this book, iOS Unit Testing by Example, by Jon Reid is very good and well worth having on your bookshelf.