ViewInspector icon indicating copy to clipboard operation
ViewInspector copied to clipboard

Help using async/await and ViewInspector in a single test

Open ethankay opened this issue 3 years ago • 2 comments

TLDR: Is it possible to have an async XCTest method which also uses ViewInspector (e.g. func test...() async { let exp = view.inspect.inspection }?

More details: I'm attempting to write tests that call async/await methods which eventually will influence a specific View I'm trying to test. Following the ViewInspector examples, I've been using the Inspection setup, which works great normally. However, whenever I mark a test as async it does not work as can be seen in the contrived examples below. Obviously in these examples I don't call any methods in the test body that actually use await, but I didn't include that as the expectations fails regardless of that fact; I can't even attach async to a test method with a ViewInspector expectation and have it work. Of note, I don't run into this problem if I use a simple XCTExpectation directly. Those can handle both async/await and expectations in a single test method.

Example:

final class Inspection<V> {
  let notice = PassthroughSubject<UInt, Never>()
  var callbacks = [UInt: (V) -> Void]()

  func visit(_ view: V, _ line: UInt) {
    if let callback = callbacks.removeValue(forKey: line) {
      callback(view)
    }
  }
}

extension Inspection: InspectionEmissary {}

class TestModel: ObservableObject {
  @Published var examples: [String] = []

  func addExamples() async throws {
    try await Task.sleep(nanoseconds: 1)
    let count = self.examples.count
    self.examples = self.examples + ["test\(count + 1)", "test\(count + 2)"]
  }
}

struct TestView: View {
  @StateObject private var testModel = TestModel()

  let inspection = Inspection<Self>()

  var body: some View {
    VStack {
      Button("button") {
        Task {
          try await testModel.addExamples()
        }
      }.id(1)
      ForEach(testModel.examples, id: \.self) {
        Text($0).tag("text")
      }
    }
    .onReceive(inspection.notice) {
      self.inspection.visit(self, $0)
    }
  }
}

extension TestView: Inspectable {}

class TestViewTest: XCTestCase {
  func test_NoAsyncNoMain() {  // Succeeds
    testImplementation()
  }

  @MainActor
  func test_NoAsyncMain() {  // Succeeds
    testImplementation()
  }

  func test_AsyncMoMain() async {  // Fails "Exceeded timeout of 4 seconds, with unfulfilled expectations"
    testImplementation()
  }

  @MainActor
  func test_AsyncMain() async {  // Fails "Exceeded timeout of 4 seconds, with unfulfilled expectations"
    testImplementation()
  }

  private func testImplementation() {
    let testView = TestView()
    let expectation1 = testView.inspection.inspect(after: 1) { view in
      XCTAssertEqual(view.findAll(where: { try $0.tag() as? String == "text" }).count, 0)
      try view.find(viewWithId: 1).button().tap()
    }
    let expectation2 = testView.inspection.inspect(after: 2) { view in
      XCTAssertEqual(view.findAll(where: { try $0.tag() as? String == "text" }).count, 2)
    }
    ViewHosting.host(view: testView)
    wait(for: [expectation1, expectation2], timeout: 4.0, enforceOrder: true)
  }
}

ethankay avatar Jul 28 '22 22:07 ethankay

While there is no well-thought and finalized approach supported by the library, you can consider going the route suggested by @sisoje in this repo

nalexn avatar Jan 20 '24 17:01 nalexn

For anyone interested, I suggest joining a proposed solution discussion in #291 PR

nalexn avatar Feb 25 '24 11:02 nalexn