Home > Software design >  Unit testing void function in Swift
Unit testing void function in Swift

Time:12-27

I am new to unit testing. I am using MVVM in my project. I also use RxSwift to pass data or communicate between view and ViewModel. I don't understand how I can write unit tests in my case. Any suggestions are appreciable. I really appreciate any help you can provide.

class MovieListViewModel: ViewModel {
       private let moviesSubject: PublishSubject<[Movie]> = PublishSubject()
    private let errorSubject: PublishSubject<Error> = PublishSubject()
    

    public var movies: Observable<[Movie]> {
        return moviesSubject.asObservable()
    }
    
    public var errors: Observable<Error> {
        return errorSubject.asObservable()
    }
    
    init(httpMovieService: MovieApiService, cachedMovieService: LocalMovieService) {
        self.httpMovieService = httpMovieService
        self.cachedMovieService = cachedMovieService
    }
    
    func searchMovies(keyword: String, page: Int, type: String) {

        self.httpMovieService
            .searchMovies(keyword: keyword, page: page, type: type)
            .subscribe(onSuccess: { [weak self] movies in
                self?.moviesSubject.onNext(movies)
            }, onFailure: { [weak self] error in
                self?.errorSubject.onNext(error)
            }).disposed(by: disposeBag)
    }

Note: httpMovieService.searchMovies() provide a Single type object

Here is my MockService ..

class MockMovieApiService: MovieApiService {
    func searchMovies(keyword: String, page: Int, type: String) -> Single<[Movie]> {
        let posterUrl = "https://m.media-amazon.com/images/M/MV5BOGE4NzU1YTAtNzA3Mi00ZTA2LTg2YmYtMDJmMThiMjlkYjg2XkEyXkFqcGdeQXVyNTgzMDMzMTg@._V1_SX300.jpg"
        let movie = Movie(title: "Title",
                          year: "2022",
                          imdbID: "tt0800369",
                          type: "movie",
                          poster: posterUrl)
        let movie2 = Movie(title: "Title2",
                          year: "2022",
                          imdbID: "tt0800368",
                          type: "movie",
                          poster: posterUrl)

        return Single.just([movie, movie2])
    }
    
    func getMovieDetails(imdbId: String) -> Single<MovieDetails> {
        let posterUrl = "https://m.media-amazon.com/images/M/MV5BOGE4NzU1YTAtNzA3Mi00ZTA2LTg2YmYtMDJmMThiMjlkYjg2XkEyXkFqcGdeQXVyNTgzMDMzMTg@._V1_SX300.jpg"
        let movieDetails = MovieDetails(title: "Thor", year: "2022", rated: "PG-13", released: "Nov, 2022", runtime: "134 min", genre: "Horror, Comedy", director: "Director", writer: "Writer", actors: "salman khan", plot: "When thor sleeps..", language: "English", country: "Germany", awards: "Oscar", poster: posterUrl, ratings: [], metascore: "5", imdbRating: "4.5", imdbVotes: "566", imdbID: "tt1981115", type: "type", dvd: "dvd", boxOffice: "box", production: "prod", website: "n/a", response: "True")
        return Single.just(movieDetails)
    }
    
    
}

CodePudding user response:

This is how you would test:

func testExample() throws {
    let scheduler = TestScheduler(initialClock: 0)
    let sut = MovieListViewModel(httpMovieService: MockMovieApiService(), cachedMovieService: MockLocalMovieService())
    let result = scheduler.start(created: 0, subscribed: 0, disposed: 100) {
        let response = sut.movies.replayAll()
        _ = response.connect()
        sut.searchMovies(keyword: "", page: 0, type: "")
        return response
    }
    XCTAssertEqual(result.events, [.next(0, [movie, movie2])])
}

No need to worry about a DispatchSemaphore or anything like that because this test is synchronous.

But note that this test could be made much simpler with an architecture that more fully took advantage of what Rx can give you. In fact, the test would be completely unnecessary...

Edit

By making your init method accept a closure, you can do away with the entire Mock class and make the testing easier and more obvious. By refactoring the internals of the view model, you can do away with the contained DisposeBag (if you need a DisposeBag in your view model, you are likely doing something wrong.)

Then you have code that looks more like this:

final class ExampleTests: XCTestCase {
    func testExample() throws {
        let scheduler = TestScheduler(initialClock: 0)
        let result = scheduler.createObserver([Movie].self)
        let sut = MovieListViewModel(searchMovies: { _, _, _ in Single.just([movie, movie2]) })

        _ = sut.movies
            .take(until: rx.deallocating)
            .bind(to: result)
        sut.searchMovies(keyword: "", page: 0, type: "")

        XCTAssertEqual(result.events, [.next(0, [movie, movie2])])
    }
}

public class MovieListViewModel {
    public typealias MovieApiService = (_ keyword: String, _ page: Int, _ type: String) -> Single<[Movie]>

    public let movies: Observable<[Movie]>
    public let error: Observable<Error>

    private let search = PublishSubject<(String, Int, String)>()

    public init(searchMovies: @escaping MovieApiService) {
        let errorSubject = PublishSubject<Error>()
        error = errorSubject.asObservable()
        movies = search
            .flatMap { [errorSubject] keyword, page, type in
                searchMovies(keyword, page, type)
                    .asMaybe()
                    .catch { error in
                        errorSubject.onNext(error)
                        return Maybe.empty()
                    }
            }
    }

    func searchMovies(keyword: String, page: Int, type: String) {
        search.onNext((keyword, page, type))
    }
}

Something to think about.

CodePudding user response:

First you need to decide what you want to test for. I'm guessing that it's some properties in movies. So your test would instantiate MovieListViewModel, do whatever other set up is needed, then iterate through movies using XCTAssert... for whatever it is you are testing for.

There are some issues though. httpMovieService.searchMovies appears to do its work asynchronously. Since the function isn't marked async, I guess you're not using Swift's async/await keywords, so you'll need to rig up a way to wait for it your test to block until it completes and get the data, but that exposes another issue, which is that you haven't written the code to be testable. That happens when you write the code you're going to test before writing the test. If you write the test first, you'll almost certainly create an API that's easy to test (and typically easy to use).

What you need is a completion handler for searchMovies. You can add it like like this:

func searchMovies(
    keyword: String, 
    page: Int, 
    type: String, 
    onCompletion handler: (Result<Observable<[Movie]>, Error>) -> Void = { _ in  })
{
    self.httpMovieService
        .searchMovies(keyword: keyword, page: page, type: type)
        .subscribe(onSuccess: { [weak self] movies in
             self?.moviesSubject.onNext(movies)
             handler(.success(movies))
        }, onFailure: { [weak self] error in
             self?.errorSubject.onNext(error)
             handler(.failure(error)
        }).disposed(by: disposeBag)
}

You may even find being able to specify a completion handler useful in parts of your app.

Because the handler has a default empty handler, you shouldn't need to change other code that uses it, but now in your test you can write something like:

   let sem = DispatchSemaphore(value: 1) // Will be used to wait for data
   let movieService = MovieListViewModel(
       httpMovieService: httpMovieService,   // Assumed available 
       cachedMovieService: localMovieCache   // Assumed available
   )
   // whatever other set up

   var movies: Observable[Movie]? = nil
   var error: Error? = nil
   movieService.searchMovies(keyword: "War", page: 1, type: "Action") { _ in
       switch $0 {
           case let .success(films): movies = films
           case let .failure(err):   error = err
       }
       sem.signal()
   }
   sem.wait() // Wait for the signal

   // Since your movies/error properties are public, you could
   // could just use those properties for your test here instead of
   // setting them in the closure

   XCTAssertEqual(error, nil) // unless you're testing errors are handled.
   // assert on whatever you want to look for in the movies

I usually use DispatchSemaphore to block for tests like this, but XCTest does provide XCTestExpectation you can use. I'd like to say my reasons for not using it are good ones, but really it's just that I often don't remember that it's there.

You could also write a blocking version of searchMovies that waits on the asynchronous version to finish and returns the data, then use the blocking version in your test. I prefer not to do that, because I want my tests to use the same API the application uses.

But you have another problem, which is that your tests depend on an external server you, and they shouldn't. So you'll need to mock one that doesn't do real networking, but does let you configure a movie list or error for it to return. It should also simulate the asynchronous behavior, which you can do using a concurrent DispatchQueue and its asyncAfter method. Whatever the mock returns it should be in identical format to whatever the real movie service would return. I've never used RxSwift, but it's possible it may provide a mock or something you can use to more easily write one yourself.

Depending on its design, you might not need to mock LocalMovieService.

  • Related