swift 依赖于发布者的单元测试视图模型

m0rkklqb  于 2023-01-29  发布在  Swift
关注(0)|答案(2)|浏览(158)

我实现了一个服务类,其中包含一个函数,当加载一些数据时,该函数返回一个发布者:

class Service {
    let fileURL: URL // Set somewhere else in the program

    func loadModels() -> AnyPublisher<[MyModelClass], Error> {
        URLSession.shared.dataTaskPublisher(for: fileURL)
            .map( { $0.data } )
            .decode(type: [MyModelClass].self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

这个函数在我的视图模型类中是这样使用的:

class ViewModel: ObservableObject {
    @Published var models: [MyModelClass]?
    
    var cancellables = Set<AnyCancellable>()
    let service: Service
    
    init(service: Service) {
        self.service = service
        loadCityData()
    }
    
    func loadModels()  {
        service.loadModels()
            .sink { _ in
            } receiveValue: { [weak self] models in
                self?.models = models
            }
            .store(in: &cancellables)
    }
}

我发现视图模型很难进行单元测试,因为我没有直接从单元测试类中的服务返回的发布者,而是使用了@Published属性,所以我尝试实现了这样一个测试:

let expectation = expectation(description: "loadModels")

viewModel.$models
    .receive(on: RunLoop.main)
    .sink(receiveCompletion: { _ in
        finishLoading.fulfill()
    }, receiveValue: { _ in
    })
    .store(in: &cancellables) // class-scoped property

viewModel.loadModels()

wait(for: [expectation], timeout: 10)

问题是receiveComplection回调函数从来没有被调用过。如果发布者可用(从Service对象返回的那个),应用于发布者的相同代码将成功运行并实现预期。相反,没有调用complection,但receiveValue被多次调用。为什么?

gc0ot86w

gc0ot86w1#

首先,不是将整个服务传递给视图模型,而是只传递发布者本身。在测试中,您可以传递一个同步发布者,这使得测试更加容易。

final class ExampleTests: XCTestCase {
    func test() {
        let input = [MyModelClass()]
        let modelLoader = Just(input).setFailureType(to: Error.self).eraseToAnyPublisher()
        let viewModel = ViewModel(modelLoader: modelLoader)
        let cancellable = viewModel.$models
            .dropFirst(1)
            .sink(receiveValue: { output in
                XCTAssertEqual(input, output)
            })
        viewModel.loadModels()
    }
}

class ViewModel: ObservableObject {
    @Published var models: [MyModelClass]?

    var cancellables = Set<AnyCancellable>()
    let modelLoader: AnyPublisher<[MyModelClass], Error>

    init(modelLoader: AnyPublisher<[MyModelClass], Error>) {
        self.modelLoader = modelLoader
    }

    func loadModels()  {
        modelLoader
            .sink { _ in
            } receiveValue: { [weak self] models in
                self?.models = models
            }
            .store(in: &cancellables)
    }
}

注意,不需要设置期望值并等待它,这使得测试更快。
更简单的方法是直接检查models属性:

final class ExampleTests: XCTestCase {
    func test() {
        let input = [MyModelClass()]
        let modelLoader = Just(input).setFailureType(to: Error.self).eraseToAnyPublisher()
        let viewModel = ViewModel(modelLoader: modelLoader)
        viewModel.loadModels()
        XCTAssertEqual(viewModel.models, input)
    }
}

但是,对于所有这些,您认为您在这里测试的究竟是什么?在此代码中没有转换和逻辑。您没有进行测试以确保ViewModel调用到服务中,因为为了执行此测试,您必须模拟服务。因此,实际上,你所做的唯一的事情就是测试,看看测试本身是否正确地模拟了服务。但是这有什么意义呢?如果测试设置不正确,谁在乎呢?测试测试生产代码?

8cdiaqws

8cdiaqws2#

您可以使用接口和依赖注入的组合来进行测试。
首先定义服务的接口:

protocol ServiceInterface {
    func loadModels() -> AnyPublisher<[MyModelClass], Error>
}

接下来,让Service符合这个新协议:

class Service: ServiceInterface {
    // ...
}

现在可以使用上面定义的接口将Service注入到ViewModel中:

class ViewModel: ObservableObject {
    //...
    let service: ServiceInterface
    
    init(service: ServiceInterface = Service()) {
        self.service = service
        loadModels()
    }
    //...
}

这意味着您可以将任何符合ServiceInterface的实体注入到ViewModel中,因此让我们在测试目标中定义一个实体:

struct MockService: ServiceInterface {
    
    let loadModelsResult: Result<[MyModelClass], Error>
    
    func loadModels() -> AnyPublisher<[MyModelClass], Error> {
        loadModelsResult.publisher.eraseToAnyPublisher()
    }
}

最后,让我们将MockService注入ViewModel以进行测试:

func testExample() {
        let expectedModels = [MyModelClass()]
        let subject = ViewModel(service: MockService(loadModelsResult: .success(expectedModels)))
        let expectation = expectation(description: "expect models to get loaded")

        subject
            .$models
            .sink(
                receiveCompletion: { _ in },
                receiveValue: { actualModels in
                    // or any other test that is meaningful in your context
                    if actualModels == expectedModels {
                        expectation.fulfill()
                    }
                }
            )
            .store(in: &cancellables)

        subject.loadModels()

        waitForExpectations(timeout: 0.5)
    }

相关问题