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

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

问题根源分析

  1. 过滤方法逻辑错误:filterFruits方法未使用传入的text参数,反而依赖ViewModel的searchText属性。测试调用时该属性为空,导致直接返回原数组,而原数组要么未加载完成,要么测试传入的是空数组。
  2. 测试参数传错:调用过滤方法时传入的是测试类自身的空数组self.filteredFruit,而非ViewModel中已加载的fruits数据。
  3. 订阅重复创建:每次调用fetchFruit都会重新创建搜索相关的Combine订阅,导致逻辑混乱。
  4. 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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.24 05:37:16