Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
rafael-assis opened this issue Feb 24, 2023 · 9 comments
Labels
known limitation Currently unresolvable issue

Comments

@rafael-assis
Copy link

rafael-assis commented Feb 24, 2023

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
Copy link
Author

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
Copy link

lo1tuma commented Feb 24, 2023

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
Copy link
Author

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
Copy link
Contributor

bachand commented Feb 25, 2023

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
Copy link
Owner

nalexn commented Feb 25, 2023

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
Copy link
Author

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
Copy link

fl034 commented Mar 6, 2023

@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
Copy link
Owner

nalexn commented Mar 6, 2023

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

@nalexn nalexn added feature request New feature or request known limitation Currently unresolvable issue and removed feature request New feature or request labels Jul 13, 2023
@roman-paxton
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
known limitation Currently unresolvable issue
Projects
None yet
Development

No branches or pull requests

6 participants