nalexn / ViewInspector

Runtime introspection and unit testing of SwiftUI views
MIT License
2.14k stars 147 forks source link

Inspection of nested SwiftUI Views using @State properties not possible #231

Open rafael-assis opened 1 year ago

rafael-assis commented 1 year ago

Context

In an attempt to inspect SwiftUI Views that use @State Properties, I found out that these properties don't change their value, hence making it impossible to inspect a view after an action (a button tap for example) happened in order to validate its new state.

I tried the Approach 1 listed in the guide section that covers workarounds to inspect @State properties.

That approach seems to work only for the root level SwiftUI View. Other nested SwiftUI Views used in its body computed property woudn't have their state changed even though the find and tap functions will execute successfully throughout the child SwiftUI Views in the hierarchy.

Code example

As a code example of the issue, consider the SwiftUI Views defined below:

import SwiftUI

@main
struct SwiftUISampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
    var body: some View {
      ParentView(content: ChildView())
    }
}

struct ParentView<Content : View> : View {
  @State var parentStatus = "Initial"
  var content: Content

  var body: some View {
    Text("parentStatus: \(parentStatus)")
    Button("Change parentStatus") {
      if parentStatus == "Initial" {
        parentStatus = "Final"
      } else {
        parentStatus = "Initial"
      }
    }

    content
      .onAppear {
        print("Parent View onAppear: \n\(self)")
      }
  }
}

struct ChildView: View {
  @State var childStatus = "Initial"

  var body: some View {
    VStack {
      Text("childStatus: \(childStatus)")
      Button("Change childStatus") {
        if childStatus == "Initial" {
          childStatus = "Final"
        } else {
          childStatus = "Initial"
        }
      }
    }.onAppear {
      print("Child View onAppear: \n\(self)")
    }
  }
}

Running a sample SwiftUI application, the following output was generated:

Child View onAppear: 
  ChildView(
    _childStatus: SwiftUI.State<Swift.String>(
        _value: "Initial", 
        _location: Optional(SwiftUI.StoredLocation<Swift.String>)))

Parent View onAppear: 
ParentView<ChildView>(
  _parentStatus: SwiftUI.State<Swift.String>(
      _value: "Initial", 
      _location: Optional(SwiftUI.StoredLocation<Swift.String>)),
      content: SwiftUISample.ChildView(
        _childStatus: SwiftUI.State<Swift.String>(
          _value: "Initial", 
          _location: nil)))

I noticed that @State properties that didn't work had their _location property set to nil. This is a good indication of the cause of it not working.

Another curious fact I found out about is that @State properties only had a non-nil _location property in the context of their containing SwiftUI View's body computed property or in the context of the onAppear event handler which coincides with the statement in the aforementioned documentation: The inspection will be fully functional inside the didAppear callback.

Notice that in the output above the _location of the _childStatus in the context of the ParentView.onAppear is nil.

Test failure

The test below illustrates the issue as it fails with the following message: testChildView(): XCTAssertNotEqual failed: ("childStatus: Initial") is equal to ("childStatus: Initial")

struct Parent: View {
  internal var didAppear: ((Self) -> Void)?

  var body: some View {
    Child().onAppear {
      self.didAppear?(self)
    }
  }
}

struct Child: View {
  @State var childStatus = "Initial"

  var body: some View {
    VStack {
      Text("childStatus: \(childStatus)")
      Button("Change childStatus") {
        if childStatus == "Initial" {
          childStatus = "Final"
        } else {
          childStatus = "Initial"
        }
      }
    }
  }
}

final class StateVarTests: XCTestCase {
  func testChildView() throws {
    var sut = Parent()

    let exp = sut.on(\.didAppear) { view in
      let text = try view.find(ViewType.Text.self)
      let textstr = try text.string()

      try view.find(button: "Change childStatus").tap()

      let text2 = try view.find(ViewType.Text.self)
      let text2str = try text2.string()

      XCTAssertNotEqual(textstr, text2str)
    }

    ViewHosting.host(view: sut)

    wait(for: [exp], timeout: 0.1)
  }
}
rafael-assis commented 1 year ago

Hi @nalexn.

In an attempt to cover more scenarios in our usage of ViewInspector, @bachand and I hit this issue with @State properties. We'd really appreciate if we can have your input/perspective on it.

Thank you for your collaboration!

lo1tuma commented 1 year ago

I’m experiencing a similar issue. In my case, I’m updating a @State property after a button click and render its value in the same view. I would like to use ViewInspector to assert the updated value in the View instead of checking the @State property directly. Per default this does not work and the value is always the initial value.

I’ve tried to make this work using Approach #2 and the proposal here to avoid having the extra boilerplate code for Inspectable in my production code. But in this case I get the error Accessing State's value outside of being installed on a View. This will result in a constant Binding of the initial value and will not update.

rafael-assis commented 1 year ago

I’m experiencing a similar issue. In my case, I’m updating a @State property after a button click and render its value in the same view. I would like to use ViewInspector to assert the updated value in the View instead of checking the @State property directly. Per default this does not work and the value is always the initial value.

I’ve tried to make this work using Approach #2 and the proposal here to avoid having the extra boilerplate code for Inspectable in my production code. But in this case I get the error Accessing State's value outside of being installed on a View. This will result in a constant Binding of the initial value and will not update.

@lo1tuma I tried a similar approach as the the one described in the link you shared with similar results: the View is not updated probably because the @State var numClicks: Int = 0 property's _location is nil.

I even tried running the exact test in the example which fails probably because ContentView is a nested/child view of TestWrapperView.

test_failure

import Combine
import XCTest
import SwiftUI
import ViewInspector

internal 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 {}

public let TEST_WRAPPED_ID: String = "wrapped"
struct TestWrapperView<Wrapped: View> : View{
   internal let inspection = Inspection<Self>()
   var wrapped: Wrapped

   init( wrapped: Wrapped ){
       self.wrapped = wrapped
   }

   var body: some View {
      wrapped
        .id(TEST_WRAPPED_ID)
        .onReceive(inspection.notice) {
           self.inspection.visit(self, $0)
        }
    }
}

struct ContentViewFromInternet: View {
   @State var numClicks:Int = 0

   var body: some View {
      VStack{
         Button("Click me"){
            numClicks += 1
         }.id("Button1")
         Text("\(numClicks)")
           .id("Text1")
           .padding()

      }
   }
}

final class InternetTestCase: XCTestCase {

  func testContentViewFromInternet() throws{
     let sut = TestWrapperView(wrapped: ContentViewFromInternet())
     let exp = sut.inspection.inspect { view in
         let wrapped = try view.find(viewWithId: TEST_WRAPPED_ID)
         let button = try wrapped.find(viewWithId: "Button1").button()
         try button.tap()
         let numClicks = try wrapped
                           .view(ContentViewFromInternet.self)
                           .actualView()
                           .numClicks
         XCTAssertEqual(numClicks, 1)
         let text = try wrapped.find(viewWithId: "Text1").text()
         let value = try text.string()
         XCTAssertEqual(value, "1")
      }

    ViewHosting.host(view: sut)
    wait(for: [exp], timeout: 1)
   }
}
bachand commented 1 year ago

Thanks for writing this up in such detail @rafael-assis . Ideally the "storage" for all @State properties in view hierarchy would be functional during inspection. When only the outermost view's @State properties are properly configured, it's easy to write a test that finds a subview, operates on that subview, and ultimately fails due to a @State property of that subview not having any _location.

nalexn commented 1 year ago

Right, this is a known limitation of the library. Your investigation led in the right direction, ultimately, the _location = nil is the reason why the state updates on the contained views don't work. You can read more about the mechanics of the inner state management in SwiftUI in this post. The didAppear workarounds in the guide are there exactly for this reason, so far I didn't find a better way to overcome the limitation

rafael-assis commented 1 year ago

Thank you @nalexn for taking the time to review the issue and providing your thoughts! We'll be on the lookout for potential workarounds and keep this issue up to date.

fl034 commented 1 year ago

@nalexn we're facing the same issue. Do you think it will technically be feasible on library-side? Or is it a SwiftUI limitation somehow? I didn't get that from the link you provided.

As @rafael-assis, we wanted to use a wrapper to prevent polluting our production code with inspection stuff. And it's working great for @ObservedObjects and stuff, but not at all for @States

nalexn commented 1 year ago

Yeah, that's a limitation of SwiftUI. If you use objects for state management, they share the state and always contain the actual values, but @State does not

roman-paxton commented 3 weeks ago

Any updates on this? I found also the same issue for @StateObject ... it's completely blocking me from using this library for testing..