I'm struggling with unit testing of Published object.
I have a viewmodel class as below
class MovieListViewModel {
@Published public private(set) var arrayOfMovies: [Movie] = []
@Published private var arraofFavoriteMoviesID: [FavouriteMovieID] = []
init(request: NetworkServiceProtocol) {
addSubscribers()
callServicesInSequence()
}
func addSubscribers() {
$arrayOfMovies.combineLatest($arraofFavoriteMoviesID)
.debounce(for: 0.0, scheduler: DispatchQueue.main)
.sink { [weak self] (_, _) in
self?.fetchWachedMovies()
self?.fetchTobeWatchedMovies()
self?.fetchFavoriteMovies()
}
.store(in: &subscriptions)
}
func callServicesInSequence() {/*..service request...*}
}
Here addSubscribers() listen for any changes happening in arrayOfMovies
or arraofFavoriteMoviesID
and works perfectly is the app.
But when I tried to mock and write unit test cases. Any chnages happening in arrayOfMovies
or arraofFavoriteMoviesID
does not make any effect (addSubscribers's body never get called).
Can any one please guide me what am I doing wrong while writing unit test cases for Combine/Published objects.
Please let me know if more clarification required.
CodePudding user response:
Your code has two obvious dependencies: a NetworkServiceProtocol
and DispatchQueue.main
.
NetworkServiceProtocol
is not part of the iOS SDK. I assume it is a type you created, and you pass it to the model's init
, so you can substitute a testable implementation in your test cases.
However, DispatchQueue
is part of the iOS SDK, and you cannot create your own testable implementation of it for use in test cases. You have only a limited ability to run the main queue, which makes it difficult to test code that depends on it.
Here are three solutions:
My favorite solution is to adopt The Composable Architecture (TCA) or a similar framework, which by design makes it easy to control dependencies and hence to test code like this.
A less invasive solution is to replace the direct use of
DispatchQueue.main
with a type eraser, which you pass to the model'sinit
. Then, in tests, you can pass in a deterministic scheduler that you control in the test case. The Combine Schedulers package, for example, provides the type eraserAnyScheduler
and several scheduler implementations specifically for use in testing. (TCA, mentioned above, uses this package.)Writing your own type eraser for the
Scheduler
protocol is simple enough that you could do it yourself if you don't want to depend on a third-party package.The least invasive solution is to use
XCTestExpectation
APIs to run the main queue in your test case. I'll demonstrate that here.
You didn't post enough code to demonstrate, so I'll use the following simple types:
struct Movie: Equatable {
let id: UUID = .init()
}
struct NetworkClient {
let fetchMovies: AnyPublisher<[Movie], Error>
}
And here is the simplified model that uses them:
class Model: ObservableObject {
@Published public private(set) var movies: [Movie] = []
@Published public private(set) var error: Error? = nil
private let client: NetworkClient
private var fetchTicket: AnyCancellable? = nil
init(client: NetworkClient) {
self.client = client
fetchMovies()
}
func fetchMovies() {
fetchTicket = client.fetchMovies
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] in
self?.fetchTicket = nil
if case .failure(let error) = $0 {
self?.error = error
}
},
receiveValue: { [weak self] in
self?.movies = $0
}
)
}
}
For testing, I can set up a NetworkClient
where fetchMovies
is a PassthroughSubject
. That way my test case can decide exactly what the “network” sends and when it sends it.
To test the success case, where the network “works”, I subscribe to the model's $movies
publisher and fulfill an XCTestExpectation
if it publishes the correct value.
func testSuccess() throws {
let fetchMovies = PassthroughSubject<[Movie], Error>()
let client = NetworkClient(
fetchMovies: fetchMovies.eraseToAnyPublisher()
)
let model = Model(client: client)
let expectedMovies = [ Movie(), Movie() ]
let ex = expectation(description: "movies publishes correct value")
let ticket = model.$movies.sink { actualMovies in
if actualMovies == expectedMovies {
ex.fulfill()
}
}
fetchMovies.send(expectedMovies)
waitForExpectations(timeout: 2)
// Some mention of ticket here keeps the subscription alive
// during the wait for the expectation.
ticket.cancel()
}
To test the failure case, where the network “fails”, I subscribe to the $error
publisher and fulfill an XCTestExpectation
if it publishes the correct error code.
func testFailure() throws {
let fetchMovies = PassthroughSubject<[Movie], Error>()
let client = NetworkClient(
fetchMovies: fetchMovies.eraseToAnyPublisher()
)
let model = Model(client: client)
let expectedCode = URLError.Code.resourceUnavailable
let ex = expectation(description: "error publishes correct code")
let ticket = model.$error.sink { error in
if (error as? URLError)?.code == expectedCode {
ex.fulfill()
}
}
fetchMovies.send(completion: .failure(URLError(expectedCode)))
waitForExpectations(timeout: 2)
ticket.cancel()
}
Note though that if a test fails (for example if you change testFailure
to purposely publish the wrong code), it takes 2 seconds to fail. That is annoying. These two tests are simple enough that we could rewrite them to fail quicker in the case that the wrong thing is published. But in general it might be difficult to write all test cases to “fail fast” when relying on XCTestExpectation
. That is the sort of problem you can avoid by replacing the direct use of DispatchQueue
with a type eraser. It lets your test case use a controllable scheduler so the test case can make time flow instantly, without any use of DispatchQueue
s, so you don't need to use XCTestExpectation
at all.