Как правильно протестировать переменную, которая изменяется через издателя в моей viewModel в XCTestCase - PullRequest
3 голосов
/ 11 января 2020

Я пытаюсь протестировать простого издателя в рамках Combine и SwiftUI. Мой тест проверяет опубликованный bool с именем isValid в моей модели представления. В моей модели представления также есть опубликованная строка с именем пользователя, которой при изменении значения 3 или более символов присваивается значение isValid. Вот вид модели. Я уверен, что не понимаю, как издатели работают в тестовой среде, сроки и т. Д. c ... Заранее спасибо.

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 StackoverFlowQuestionTests: 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
    }

}

Ответы [ 2 ]

3 голосов
/ 11 января 2020

Причина в том, что модель представления асинхронная, но тестирование синхронное ...

$username
  .receive(on: RunLoop.main)

... оператор .receive здесь делает окончательное назначение isValid в следующем цикле событий RunLoop.main

, но тест

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

ожидает, что isValid будет немедленно изменен.

Таким образом, существуют следующие возможные решения:

1) удалить оператор .receive вообще (в данном случае это предпочтительнее, поскольку это рабочий процесс пользовательского интерфейса, который в любом случае всегда выполняется на главном runl oop, поэтому использование запланированного приема является избыточным.

$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

2) заставить UT ждать одного события и только затем проверять isValid (в этом случае следует документально подтвердить, что isValid имеет намеренный асинхронный характер)

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

Как сказал @Asperi: причина этой ошибки в том, что вы получаете значения асинхронно. Я немного искал и нашел учебник Apple о XCTestExpectation использовании. Поэтому я попытался использовать его с вашим кодом, и тесты прошли успешно. Другой способ - использовать Объединить ожидания .

class StackoverFlowQuestionTests: 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»). И вы увидите, что я просто скопировал и вставил вашу модель (кроме имени и public для переменных и init()). Но, тем не менее, у меня нет этой ошибки:

Асинхронное ожидание не выполнено: превышено время ожидания в 1 секунду с невыполненными ожиданиями: "ожидание проверки".

// 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: stackoverflowanswerTests.swift file:
import XCTest
import stackoverflowanswer
import Combine

class stackoverflowanswerTests: 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 stackoverflowanswer[1266:46298] Launching with XCTest injected. Preparing to run tests.
2020-01-14 09:16:41.389610+0600 stackoverflowanswer[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 'stackoverflowanswerTests.xctest' started at 2020-01-14 09:16:41.712
Test Suite 'stackoverflowanswerTests' started at 2020-01-14 09:16:41.712
Test Case '-[stackoverflowanswerTests.stackoverflowanswerTests testValidation]' started.
nothing
~~~> true
1234
~~~> false
1
Test Case '-[stackoverflowanswerTests.stackoverflowanswerTests testValidation]' passed (0.004 seconds).
Test Suite 'stackoverflowanswerTests' 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 'stackoverflowanswerTests.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

ОБНОВЛЕНИЕ 2 На самом деле я ловлю ошибки " Асинхронное ожидание не удалось: ...", если я изменил эту строку кода:

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

на это, как предлагает Xcode:

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