You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.07 11:00:56