Home > Net >  Unit Testing of Published object in Swift
Unit Testing of Published object in Swift

Time:01-19

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's init. 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 eraser AnyScheduler 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 DispatchQueues, so you don't need to use XCTestExpectation at all.

  • Related