Swift中已发布对象的单元测试

hrysbysz  于 2023-01-19  发布在  Swift
关注(0)|答案(1)|浏览(170)

我正在努力进行Published对象的单元测试。
我有一个视图模型类,如下所示

class MovieListViewModel {
    @Published public private(set) var arrayOfMovies: [Movie] = []
    @Published private var arraofFavoriteMoviesID: [FavouriteMovieID] = []

     init(request: NetworkServiceProtocol) {
        addSubscribers()
        callServicesInSequence()
    }

    func addSubscribers() {
        $arrayOfMovies.combineLatest($arraofFavoriteMoviesID)
            .debounce(for: 0.0, scheduler: DispatchQueue.main)
            .sink { [weak self] (_, _) in
                self?.fetchWachedMovies()
                self?.fetchTobeWatchedMovies()
                self?.fetchFavoriteMovies()
            }
            .store(in: &subscriptions)
    }

    func callServicesInSequence() {/*..service request...*}
}

在这里addSubscribers()监听arrayOfMoviesarraofFavoriteMoviesID中发生的任何更改,并在应用程序中完美运行。
但是当我试着模拟和编写单元测试用例时,在arrayOfMoviesarraofFavoriteMoviesID中发生的任何更改都不会产生任何影响(addSubscribers的主体永远不会被调用)。
在为合并/Published对象编写单元测试用例时,有谁能告诉我我做错了什么吗?
如需进一步澄清,请告知。

t3irkdon

t3irkdon1#

您的代码有两个明显的依赖项:一个NetworkServiceProtocol和一个DispatchQueue.main
NetworkServiceProtocol不是iOS SDK的一部分,我假设它是您创建的一个类型,并且您将它传递给模型的init,这样您就可以在测试用例中替换一个可测试的实现。
但是,DispatchQueue是iOS SDK的一部分,您不能创建自己的可测试实现以用于测试用例,您运行主队列的能力有限,这使得测试依赖于它的代码变得困难。
以下是三种解决方案:

  • 我最喜欢的解决方案是采用The Composable Architecture(TCA)或类似的框架,它的设计使控制依赖性变得容易,从而使测试代码变得容易。
  • 一个侵入性较小的解决方案是用类型擦除器代替直接使用DispatchQueue.main,并将其传递给模型的init。然后,在测试中,您可以传递在测试用例中控制的确定性调度器。例如,Combine Schedulers包提供了类型擦除器AnyScheduler和几个调度器实现,专门用于测试。(上面提到的TCA使用此软件包。)

Scheduler协议编写自己的type eraser非常简单,如果不想依赖第三方软件包,可以自己编写。

  • 侵入性最小的解决方案是使用XCTestExpectationAPI在测试用例中运行主队列。

您没有发布足够的代码来进行演示,因此我将使用以下简单类型:

struct Movie: Equatable {
    let id: UUID = .init()
}

struct NetworkClient {
    let fetchMovies: AnyPublisher<[Movie], Error>
}

下面是使用它们的简化模型:

class Model: ObservableObject {
    @Published public private(set) var movies: [Movie] = []
    @Published public private(set) var error: Error? = nil

    private let client: NetworkClient
    private var fetchTicket: AnyCancellable? = nil

    init(client: NetworkClient) {
        self.client = client
        fetchMovies()
    }

    func fetchMovies() {
        fetchTicket = client.fetchMovies
            .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
            .sink(
                receiveCompletion: { [weak self] in
                    self?.fetchTicket = nil
                    if case .failure(let error) = $0 {
                        self?.error = error
                    }
                },
                receiveValue: { [weak self] in
                    self?.movies = $0
                }
            )
    }
}

为了进行测试,我可以设置一个NetworkClient,其中fetchMovies是一个PassthroughSubject,这样我的测试用例就可以准确地决定“网络”发送什么以及何时发送。
为了测试成功案例,即网络“工作”的情况,我订阅了模型的$movies发布器,如果它发布了正确的值,则实现一个XCTestExpectation

func testSuccess() throws {
        let fetchMovies = PassthroughSubject<[Movie], Error>()

        let client = NetworkClient(
            fetchMovies: fetchMovies.eraseToAnyPublisher()
        )

        let model = Model(client: client)
        let expectedMovies = [ Movie(), Movie() ]
        let ex = expectation(description: "movies publishes correct value")
        let ticket = model.$movies.sink { actualMovies in
            if actualMovies == expectedMovies {
                ex.fulfill()
            }
        }

        fetchMovies.send(expectedMovies)

        waitForExpectations(timeout: 2)

        // Some mention of ticket here keeps the subscription alive
        // during the wait for the expectation.
        ticket.cancel()
    }

为了测试网络“失败”的失败案例,我订阅了$error发布器,如果它发布了正确的错误代码,则实现XCTestExpectation

func testFailure() throws {
        let fetchMovies = PassthroughSubject<[Movie], Error>()

        let client = NetworkClient(
            fetchMovies: fetchMovies.eraseToAnyPublisher()
        )

        let model = Model(client: client)
        let expectedCode = URLError.Code.resourceUnavailable
        let ex = expectation(description: "error publishes correct code")
        let ticket = model.$error.sink { error in
            if (error as? URLError)?.code == expectedCode {
                ex.fulfill()
            }
        }

        fetchMovies.send(completion: .failure(URLError(expectedCode)))

        waitForExpectations(timeout: 2)

        ticket.cancel()
    }

但请注意,如果测试失败(例如,如果您更改testFailure以故意发布错误的代码),失败需要2秒。这很烦人。这两个测试足够简单,我们可以重写它们,以便在发布错误内容的情况下更快地失败。但一般来说,当依赖XCTestExpectation时,可能很难将所有测试用例编写为“快速失败”。这类问题可以通过用类型擦除器代替直接使用DispatchQueue来避免,它允许您的测试用例使用一个可控的调度器,这样测试用例可以使时间立即流动,而不需要使用DispatchQueue,因此您根本不需要使用XCTestExpectation

相关问题