nalexn / ViewInspector

Runtime introspection and unit testing of SwiftUI views
MIT License
2.15k stars 148 forks source link

Question: why do views need to conform to `Inspectable` to be inspected? #203

Closed bachand closed 1 year ago

bachand commented 1 year ago

This is an amazing library. Wow!

I am learning how this library works and I have a question about the Inspectable protocol.

Let's say I have a toy SwiftUI view setup like this:

import SwiftUI

struct MyScreen: View {
  var body: some View {
    MyTextWrapperView(title: "Hello world")
  }
}

struct MyTextWrapperView: View {
  let title: String

  var body: some View {
    Text(title)
  }
}

And then let's say I want to verify that my screen shows the string "Hello world" somewhere at any level of the hierarchy. I'd write that test like this:

import ViewInspector
import XCTest

final class MyScreenTests: XCTestCase {
  func test_verifyTextPresence() {
    XCTAssertNoThrow(try MyScreen().inspect().find(text: "Hello world"))
  }
}

I find that for this to to pass I need to conform both MyScreen and MyTextWrapperView to Inspectable.

extension MyScreen: Inspectable { }
extension MyTextWrapperView: Inspectable { }

When building infrastructure for a large codebase it can be challenging to ensure that all of the expected views conform to Inspectable. I was hoping that ViewInspector's find(text:) would work out of the box without any view needing to be conformed to Inspectable. I don't mind needing to conform MyScreen to Inspectable; the part that is more difficult to reason about is the need to conform child view types, like MyTextWrapperView, used within the hierarchy.

I am trying to understand the reason why the Inspectable conformance is necessary. I see that the Inspectable protocol has one property and one method, and that there's a default implementation for each when Self: View. However I haven't been able to find any documentation as to why the Inspectable protocol is necessary.

Thanks again for filling this huge gap in the SwiftUI ecosystem which such a thoughtfully built library đŸ˜„

bachand commented 1 year ago

I found a related test in this repository.

https://github.com/nalexn/ViewInspector/blob/ac7df67c4e0593470eda90a550f8493a81609745/Tests/ViewInspectorTests/ViewSearchTests.swift#L217-L225

The test leverages a custom view that does not conform to Inspectable.

https://github.com/nalexn/ViewInspector/blob/ac7df67c4e0593470eda90a550f8493a81609745/Tests/ViewInspectorTests/ViewSearchTests.swift#L64-L68

The test describes this view as a "blocker" view.

nalexn commented 1 year ago

Hey @bachand , good question. Conformance to Inspectable is an unfortunate requirement that I could not work around yet. Here is my understanding why this is impossible to go without it:

As you may already know, the library is relying on swift reflection for digging into the views. In reflection, you can see the names of the variables, you can extract their values, you can get a string representation of their types, but extracted value's type is always Any. It does not give you the Type you can cast it to, only the name of the type.

Custom views provide the hierarchy in the computed variable body, but reflection does not call computed variables, so we have to do it ourselves. Since the value of the extracted view is Any, swift won't allow us to call body on Any, we need a way to assure swift that this method exists on the variable - so we need to either cast it to the exact type of the view, or to a protocol it conforms to. Here we end up with Inspectable.

Casting to the exact type works in some cases, the library uses this approach in a few places, but in general, this is far more inconvenient than Inspectable conformance: the factual view type might be super cumbersome due to the heavy use of generics in SwiftUI, this makes the tests super fragile, as even a tiny tweak in the view hierarchy changes the resolved view type.

I hope this sheds some light on the problem.

bachand commented 1 year ago

Hey @nalexn. Thank you so much for your thoughtful response. I appreciate you taking the time to write this out.

Custom views provide the hierarchy in the computed variable body, but reflection does not call computed variables, so we have to do it ourselves. Since the value of the extracted view is Any, swift won't allow us to call body on Any, we need a way to assure swift that this method exists on the variable - so we need to either cast it to the exact type of the view, or to a protocol it conforms to. Here we end up with Inspectable.

When I look at the Inspectable protocol I see two members, an entity property and an extractContent(…) method. https://github.com/nalexn/ViewInspector/blob/ac7df67c4e0593470eda90a550f8493a81609745/Sources/ViewInspector/BaseTypes.swift#L5-L9

I see what you are saying about not being able to call the body computed property on an instance of type Any. I am not following how the Inspectable protocol addresses this issue since it does not define body property.

Can you point me to a code example in the library where we are using Inspectable to invoke body? I'm asking to learn. I think seeing the code may help me connect the dots.

My goal is to continue to understand the internal mechanics of this library more deeply so that I can figure out the best way to integrate it into a large codebase with many custom views.

nalexn commented 1 year ago

I see what you are saying about not being able to call the body computed property on an instance of type Any. I am not following how the Inspectable protocol addresses this issue since it does not define body property.

It does call body in an extension, see extension Inspectable where Self: View and similar for ViewModifier, etc.

You cannot cast to a View protocol for calling body because it has an associated type.

bachand commented 1 year ago

I see what you are saying about not being able to call the body computed property on an instance of type Any. I am not following how the Inspectable protocol addresses this issue since it does not define body property.

It does call body in an extension, see extension Inspectable where Self: View and similar for ViewModifier, etc.

You cannot cast to a View protocol for calling body because it has an associated type.

Ah that makes sense @nalexn . With Swift 5.7 it's now possible to cast to the View protocol and invoke body on the existential! I've made removed the need for an Inspectable conformance when performing content extraction in https://github.com/nalexn/ViewInspector/pull/216.

bachand commented 1 year ago

My goal is to remove the need for a developer to conform their views to Inspectable when using this library. Relaxing this restriction should make this library easier to adopt, especially in a large codebase with many views.

After https://github.com/nalexn/ViewInspector/pull/216 the Inspectable protocol is a "marker" protocol, as it has no property or method requirements. I tried going further to relax the requirement that views conform to Inspectable. When I did so I encountered this code that I had trouble wrapping my head around.

https://github.com/nalexn/ViewInspector/blob/ac7df67c4e0593470eda90a550f8493a81609745/Sources/ViewInspector/SwiftUI/CustomViewModifier.swift#L46-L51

The value content.isCustomView is determined by whether the view conforms to Inspectable.

https://github.com/nalexn/ViewInspector/blob/ac7df67c4e0593470eda90a550f8493a81609745/Sources/ViewInspector/SwiftUI/CustomView.swift#L52-L54

@nalexn can you help me understand the unwrappedModifiedContent() and the above child(…) method?

If it's not a requirement that views conform to Inspectable I am not sure when we would want to call content.extractCustomView() vs. content.unwrappedModifiedContent().

bachand commented 1 year ago

We were able to remove the requirement that views conform to Inspectable in https://github.com/nalexn/ViewInspector/pull/216 and through subsequent changes added to the https://github.com/nalexn/ViewInspector/commits/0.9.3 release. Thanks for your help in making this happen @nalexn !