Swift中MVVM+RxSwift架构下无返回值函数的单元测试咨询
针对MVVM+RxSwift的MovieListViewModel编写单元测试的建议
问题背景
刚接触单元测试,项目采用MVVM架构,用RxSwift实现视图与ViewModel间的数据传递。现有MovieListViewModel和MockMovieApiService代码,不清楚如何编写对应单元测试。
现有代码
MovieListViewModel
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) } }
说明:
httpMovieService.searchMovies()返回Single类型对象
MockMovieApiService
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) } }
单元测试编写建议
1. 依赖准备
- 用
MockMovieApiService替换真实的MovieApiService,同时给LocalMovieService写个空实现的Mock(ViewModel初始化需要它,但当前逻辑未用到其方法) - 引入
RxTest和RxBlocking库,这是RxSwift官方测试工具,用来监听Observable的事件变化
2. 测试成功场景
验证调用searchMovies后,ViewModel的movies Observable能正确接收Mock返回的电影列表:
import XCTest import RxSwift import RxTest import RxBlocking class MovieListViewModelTests: XCTestCase { private var viewModel: MovieListViewModel! private var mockApiService: MockMovieApiService! private var mockLocalService: MockLocalMovieService! private let disposeBag = DisposeBag() override func setUp() { super.setUp() mockApiService = MockMovieApiService() mockLocalService = MockLocalMovieService() viewModel = MovieListViewModel(httpMovieService: mockApiService, cachedMovieService: mockLocalService) } override func tearDown() { viewModel = nil mockApiService = nil mockLocalService = nil super.tearDown() } func testSearchMovies_Success() throws { viewModel.searchMovies(keyword: "test", page: 1, type: "movie") let result = try viewModel.movies.toBlocking().first() XCTAssertEqual(result?.count, 2) XCTAssertEqual(result?.first?.title, "Title") XCTAssertEqual(result?[1].title, "Title2") } } // 空实现的LocalMovieService Mock class MockLocalMovieService: LocalMovieService { // 根据协议要求实现空方法即可 }
3. 测试错误场景
先改造MockMovieApiService,添加开关控制返回成功或错误:
class MockMovieApiService: MovieApiService { var shouldReturnError = false func searchMovies(keyword: String, page: Int, type: String) -> Single<[Movie]> { if shouldReturnError { return Single.error(NSError(domain: "test", code: -1, userInfo: [NSLocalizedDescriptionKey: "搜索失败"])) } // 原成功返回代码 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]) } // getMovieDetails方法不变 }
再添加错误场景测试用例:
func testSearchMovies_Failure() throws { mockApiService.shouldReturnError = true viewModel.searchMovies(keyword: "test", page: 1, type: "movie") let error = try viewModel.errors.toBlocking().first() XCTAssertNotNil(error) XCTAssertEqual((error as NSError?)?.code, -1) XCTAssertEqual((error as NSError?)?.localizedDescription, "搜索失败") }
4. 注意事项
- 确保
Movie和MovieDetails实现Equatable协议,这样才能用XCTAssertEqual直接比较对象 - 用
toBlocking()将RxSwift的异步事件转为同步等待,方便断言结果 - 每个测试用例独立,在
setUp初始化新的ViewModel和Mock对象,tearDown清空引用
内容的提问来源于stack exchange,提问作者Sulaiman Khan
相关产品推荐
相关产品推荐

