Search code examples
swiftunit-testingmvvmrx-swift

Unit testing void function in Swift


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)
    }
    
    
}

Solution

  • 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 { 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.