Swift UI搜索应用ViewModel单元测试返回nil问题排查
SwiftUI搜索功能测试失败问题排查与修复
问题现象
测试SwiftUI搜索功能时,输入文本“App”预期返回3条记录,但实际返回0条,报错:
testSearcFruit_success_WithLatter_A(): XCTAssertEqual failed: ("0") is not equal to ("3") - Total is empty
怀疑是debounce或延迟问题,但添加延迟处理后错误依旧。
尝试的延迟代码
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { let resultCount = self.viewModel.filterFruits(text: "App", fruit: self.filteredFruit) XCTAssertEqual(resultCount.count ,3, "Total is empty") expectation.fulfill() }
ViewModel代码
final class FruitListViewModel: FruitListViewModelType, ObservableObject { private let service: Service @Published private(set) var fruits = [Fruits]() @Published var filteredFruit: [Fruits] = [] @Published var searchText: String = "" private var cancellable = Set<AnyCancellable>() init(service:Service = ServiceImpl()) { self.service = service fetchFruit() } func fetchFruit() { let client = ServiceClient(baseUrl:EndPoints.baseUrl.rawValue, path:Path.fruitList.rawValue, params:"", method:"get") service.fetchData(client: client, type: [Fruits].self) .receive(on: RunLoop.main) .sink { completion in switch completion { case let .failure(error): print(error) default: break } } receiveValue: { response in self.fruits = response }.store(in: &cancellable) $searchText .combineLatest($fruits) .debounce(for: .seconds(0.5), scheduler: RunLoop.main) .map(filterFruits) .sink { [weak self] (filterList) in self?.filteredFruit = filterList } .store(in: &cancellable) } public func filterFruits(text: String, fruit: [Fruits]) -> [Fruits] { guard !searchText.isEmpty else { return fruit } let lowercasedText = searchText.lowercased() let filterFruitList = fruit.filter { (fruit) -> Bool in fruit.name.lowercased().contains(lowercasedText) || fruit.genus.lowercased().contains(lowercasedText) || fruit.family.lowercased().contains(lowercasedText) } return filterFruitList } }
Mock Service代码
class MockService: Service { var responseFileName = "" func fetchData<T>(client: FruitsDemoSwiftUI.ServiceClient, type: T.Type) -> AnyPublisher<T,ServiceError> where T : Decodable, T : Encodable { let bundle = Bundle(for: MockService.self) guard let url = bundle.url(forResource: responseFileName, withExtension:"json"), let data = try? Data(contentsOf: url) else { return Fail(outputType: type, failure: ServiceError.requestNotCreated(message: Constants.requestNotCreated)).eraseToAnyPublisher() } do { let decodeObject = try JSONDecoder().decode(T.self, from: data) return Just(decodeObject) .setFailureType(to: ServiceError.self) .eraseToAnyPublisher() } catch { return Fail(outputType: type, failure: ServiceError.requestNotCreated(message: Constants.requestNotCreated)) .eraseToAnyPublisher() } } }
测试代码
import XCTest @testable import FruitsDemoSwiftUI final class FruitsDemoSwiftUISearchTests: XCTestCase { var mockService: MockService! var viewModel: FruitListViewModel! var filteredFruit: [Fruits] = [] override func setUp() { mockService = MockService() viewModel = FruitListViewModel(service: mockService) } override func tearDownWithError() throws { viewModel = nil } func testSearcFruit_success_WithLatter_A() { let expectation = expectation(description: "Wait for async code to complete") mockService.responseFileName = "FruitSuccessResponse" viewModel.fetchFruit() DispatchQueue.main.asyncAfter(deadline: .now() + 2) { let resultCount = self.viewModel.filterFruits(text: "App", fruit: self.filteredFruit) XCTAssertEqual(resultCount.count ,3, "Total is empty") expectation.fulfill() } wait(for: [expectation], timeout: 2.0) } }
问题根源分析
- 过滤方法逻辑错误:
filterFruits方法未使用传入的text参数,反而依赖ViewModel的searchText属性。测试调用时该属性为空,导致直接返回原数组,而原数组要么未加载完成,要么测试传入的是空数组。 - 测试参数传错:调用过滤方法时传入的是测试类自身的空数组
self.filteredFruit,而非ViewModel中已加载的fruits数据。 - 订阅重复创建:每次调用
fetchFruit都会重新创建搜索相关的Combine订阅,导致逻辑混乱。 - Mock配置时机错误:ViewModel初始化时自动调用
fetchFruit,此时Mock文件名尚未设置,首次数据加载失败。
修复步骤
1. 修正过滤方法逻辑
将方法内的searchText替换为传入的text参数:
public func filterFruits(text: String, fruit: [Fruits]) -> [Fruits] { guard !text.isEmpty else { return fruit } let lowercasedText = text.lowercased() let filterFruitList = fruit.filter { fruit in fruit.name.lowercased().contains(lowercasedText) || fruit.genus.lowercased().contains(lowercasedText) || fruit.family.lowercased().contains(lowercasedText) } return filterFruitList }
2. 调整ViewModel订阅逻辑
将搜索相关的Combine订阅移至init方法,避免重复创建:
init(service:Service = ServiceImpl()) { self.service = service // 仅初始化一次搜索订阅 $searchText .combineLatest($fruits) .debounce(for: .seconds(0.5), scheduler: RunLoop.main) .map(filterFruits) .sink { [weak self] filterList in self?.filteredFruit = filterList } .store(in: &cancellable) fetchFruit() } func fetchFruit() { let client = ServiceClient(baseUrl:EndPoints.baseUrl.rawValue, path:Path.fruitList.rawValue, params:"", method:"get") service.fetchData(client: client, type: [Fruits].self) .receive(on: RunLoop.main) .sink { completion in switch completion { case let .failure(error): print(error) default: break } } receiveValue: { response in self.fruits = response }.store(in: &cancellable) }
3. 修正测试代码
先配置Mock文件名再初始化ViewModel,确保首次加载使用正确测试数据,同时使用ViewModel的有效数据进行断言:
func testSearcFruit_success_WithLatter_A() { let expectation = expectation(description: "Wait for async code to complete") // 先配置Mock,再初始化ViewModel mockService.responseFileName = "FruitSuccessResponse" viewModel = FruitListViewModel(service: mockService) // 等待debounce延迟+数据加载完成 DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { // 使用ViewModel已加载的fruits数组进行过滤 let resultCount = self.viewModel.filterFruits(text: "App", fruit: self.viewModel.fruits) XCTAssertEqual(resultCount.count, 3, "Total is empty") expectation.fulfill() } wait(for: [expectation], timeout: 1.0) }
也可以通过监听filteredFruit的变化来断言:
func testSearcFruit_success_WithLatter_A() { let expectation = expectation(description: "Wait for filtered results") mockService.responseFileName = "FruitSuccessResponse" viewModel = FruitListViewModel(service: mockService) // 监听过滤结果变化 var cancellable: AnyCancellable? cancellable = viewModel.$filteredFruit .dropFirst() // 跳过初始空数组 .sink { filtered in if filtered.count == 3 { XCTAssertEqual(filtered.count, 3, "Total is empty") expectation.fulfill() cancellable?.cancel() } } // 设置搜索文本触发过滤 viewModel.searchText = "App" wait(for: [expectation], timeout: 1.0) }
内容的提问来源于stack exchange,提问作者user21666271
相关产品推荐
相关产品推荐

