如何在 XCTestCase 的 viewModel 中正确测试通过发布者更改的 var

How do I properly test a var that changes through a publisher in my viewModel in XCTestCase

我正在尝试在 Combine 框架和 SwiftUI 中测试一个简单的发布者。我的测试在我的视图模型中测试了一个名为 isValid 的已发布布尔值。我的视图模型还有一个已发布的用户名字符串,当更改并变为 3 个字符或更多字符时,会为 isValid 分配值。这是视图模型。我确定我不了解发布者在测试环境中的工作方式、时间安排等……提前致谢。

public class UserViewModel: ObservableObject {
  @Published var username = ""
  @Published var isValid = false
  private var disposables = Set<AnyCancellable>()

  init() {
    $username
      .receive(on: RunLoop.main)
      .removeDuplicates()
      .map { input in
        print("~~~> \(input.count >= 3)")
        return input.count >= 3
    }
    .assign(to: \.isValid, on: self)
    .store(in: &disposables)
  }
}

这是我的看法,这里不是很重要

struct ContentView: View {
  @ObservedObject private var userViewModel = UserViewModel()
  var body: some View {
    TextField("Username", text: $userViewModel.username)
  }
}

这是我的测试文件和失败的单个测试

class WhosebugQuestionTests: XCTestCase {
  var model = UserViewModel()

    override func setUp() {
        model = UserViewModel()
    }

    override func tearDown() {
    }

    func testIsValid() {
      model.username = "1"
      XCTAssertFalse(model.isValid)
      model.username = "1234"
      XCTAssertTrue(model.isValid) //<----- THIS FAILS HERE
    }

}

原因是视图模型异步但测试是同步的...

$username
  .receive(on: RunLoop.main)

... 这里的 .receive 运算符在 RunLoop.main

的下一个事件循环中对 isValid 进行最终赋值

但是考试

model.username = "1234"
XCTAssertTrue(model.isValid) //<----- THIS FAILS HERE

预计isValid会立即更改。

所以有以下可能的解决方案:

  1. 完全删除 .receive 运算符(在这种情况下更可取,因为它是 UI 工作流,无论如何总是在主运行循环中,所以使用计划接收是多余的。

    $username
        .removeDuplicates()
        .map { input in
            print("~~~> \(input.count >= 3)")
            return input.count >= 3
        }
    .assign(to: \.isValid, on: self)
    .store(in: &disposables)
    

结果:

model.username = "1234"
XCTAssertTrue(model.isValid) // << PASSED
  1. 让 UT 等待一个事件,然后才测试 isValid(在这种情况下,应该记录 isValid 故意具有异步性质)

    model.username = "1234"
    RunLoop.main.run(mode: .default, before: .distantPast) // << wait one event
    XCTAssertTrue(model.isValid) // << PASSED
    

backup

正如@Asperi 所说:这个错误的原因是您接收的值是异步的。我搜索了一下,发现 Apple's tutorial about XCTestExpectation usage. So I tried to use it with your code and the tests passed successfully. The other way is to use Combine Expectations.

class WhosebugQuestionTests: XCTestCase {

    var model = UserViewModel()

    override func setUp() {
        model = UserViewModel()
    }

    func testIsValid() throws {

        let expectation = self.expectation(description: "waiting validation")

        let subscriber = model.$isValid.sink { _ in
            guard self.model.username != "" else { return }
            expectation.fulfill()
        }

        model.username = "1234"
        wait(for: [expectation], timeout: 1)
        XCTAssertTrue(model.isValid)

    }

    func testIsNotValid() {

        let expectation = self.expectation(description: "waiting validation")

        let subscriber = model.$isValid.sink { _ in
            guard self.model.username != "" else { return }
            expectation.fulfill()
        }

        model.username = "1"
        wait(for: [expectation], timeout: 1)
        XCTAssertFalse(model.isValid)

    }
}

更新 为了清楚起见,我添加了所有代码和输出。我像您的示例中那样更改了测试验证(您在其中测试“1”和“1234”选项)。你会看到,我只是复制粘贴你的模型(除了变量的名称和 publicinit())。但是,我没有这个错误:

Asynchronous wait failed: Exceeded timeout of 1 seconds, with unfulfilled expectations: "waiting validation".

// MARK: TestableCombineModel.swift file
import Foundation
import Combine

public class TestableModel: ObservableObject {

    @Published public var username = ""
    @Published public var isValid = false
    private var disposables = Set<AnyCancellable>()

    public init() {
        $username
            .receive(on: RunLoop.main) // as you see, I didn't delete it
            .removeDuplicates()
            .map { input in
                print("~~~> \(input.count >= 3)")
                return input.count >= 3
        }
        .assign(to: \.isValid, on: self)
        .store(in: &disposables)
    }

}

// MARK: WhosebuganswerTests.swift file:
import XCTest
import Whosebuganswer
import Combine

class WhosebuganswerTests: XCTestCase {

    var model: TestableModel!

    override func setUp() {
        model = TestableModel()
    }

    func testValidation() throws {

        let expectationSuccessfulValidation = self.expectation(description: "waiting successful validation")
        let expectationFailedValidation = self.expectation(description: "waiting failed validation")

        let subscriber = model.$isValid.sink { _ in
            // look at the output. at the first time there will be "nothing"
            print(self.model.username == "" ? "nothing" : self.model.username)
            if self.model.username == "1234" {
                expectationSuccessfulValidation.fulfill()
            } else if self.model.username == "1" {
                expectationFailedValidation.fulfill()
            }

        }

        model.username = "1234"
        wait(for: [expectationSuccessfulValidation], timeout: 1)
        XCTAssertTrue(model.isValid)

        model.username = "1"
        wait(for: [expectationFailedValidation], timeout: 1)
        XCTAssertFalse(model.isValid)

    }

}

这是输出

2020-01-14 09:16:41.207649+0600 Whosebuganswer[1266:46298] Launching with XCTest injected. Preparing to run tests.
2020-01-14 09:16:41.389610+0600 Whosebuganswer[1266:46298] Waiting to run tests until the app finishes launching.
Test Suite 'All tests' started at 2020-01-14 09:16:41.711
Test Suite 'WhosebuganswerTests.xctest' started at 2020-01-14 09:16:41.712
Test Suite 'WhosebuganswerTests' started at 2020-01-14 09:16:41.712
Test Case '-[WhosebuganswerTests.WhosebuganswerTests testValidation]' started.
nothing
~~~> true
1234
~~~> false
1
Test Case '-[WhosebuganswerTests.WhosebuganswerTests testValidation]' passed (0.004 seconds).
Test Suite 'WhosebuganswerTests' passed at 2020-01-14 09:16:41.717.
     Executed 1 test, with 0 failures (0 unexpected) in 0.004 (0.005) seconds
Test Suite 'WhosebuganswerTests.xctest' passed at 2020-01-14 09:16:41.717.
     Executed 1 test, with 0 failures (0 unexpected) in 0.004 (0.005) seconds
Test Suite 'All tests' passed at 2020-01-14 09:16:41.718.
     Executed 1 test, with 0 failures (0 unexpected) in 0.004 (0.006) seconds

UPDATE 2 实际上,如果我更改了这行代码,我确实会发现“异步等待失败:...”的错误:

let subscriber = model.$isValid.sink { _ in

对此,Xcode提议:

model.$isValid.sink { _ in // remove "let subscriber ="

我一直在使用 Combine Testing Extensions 来帮助进行 Publisher 测试,代码看起来相当不错:

// ARRANGE
let showAlert = viewModel.$showAlert.record(numberOfRecords: 2)

// ACT
viewModel.doTheThing()

// ASSERT
let records = showAlert.waitAndCollectRecords()
XCTAssertEqual(records, [.value(false), .value(true)])