nalexn / ViewInspector

Runtime introspection and unit testing of SwiftUI views
MIT License
2.09k stars 145 forks source link

Inspection of onTap gesture on custom Layout #303

Open babbage opened 1 month ago

babbage commented 1 month ago

I have been using ViewInspector with good success, but seem to have become stuck on how to correctly inspect a view and wonder if you could help me get my thinking straight:

Given this view:

public struct PersonCardView: View {
    var data: PersonCardData
    let actions: TimelineEntryActions

    public var body: some View {
        PersonCardViewContent(data: data)
            .onTapGesture {
                actions.onTap()
            }
    }
}

public struct PersonCardViewContent: View {
    var data: PersonCardData
    static let minHeight: CGFloat = 250

    public var body: some View {
        PersonCardHStack {
            PersonCardTextBlock(data: data)

            if let image = data.image {
                PersonCardImage(image: image)
            }
        }
        .padding(EdgeInsets(
            top: 0,
            leading: TimelineViewMetrics.cardPaddingLeading,
            bottom: 0,
            trailing: 0
        ))
        .frame(minHeight: PersonCardView.minHeight)
        .cardBackground()
    }
}

I can successfully test that the onTap gesture is correctly wired up with this test:

    func testA10_viewIsCorrectlyWiredToAction_onTap() throws {
        // Setup actions to test
        let expectation = expectation(description: "Action called")
        let minimalData = PersonCardData(
            type: .undefined,
            timestamp: Date(),
            givenName: "",
            familyName: "",
            jobTitle: "",
            department: "",
            organization: "",
            image: nil
        )
        let actions = actions(expectation, onTap: true)

        // Inspect view
        let view = PersonCardView(data: minimalData, actions: actions)
        let target = try view.inspect().find(PersonCardViewContent.self)

        // Process under test
        try target.callOnTapGesture()

        // Expectation
        waitForExpectations(timeout: 0.1)
    }

However, the only reason that the view is structured with the separate PersonCardViewContent was to get the test to pass. The actual SwiftUI view I originally wanted to test was basically the same, but with the onTapGesture just attached directly to the contents of the body:

public struct PersonCardView: View {
    var data: PersonCardData
    let actions: TimelineEntryActions
    static let minHeight: CGFloat = 250

    public var body: some View {
        PersonCardHStack {
            PersonCardTextBlock(data: data)

            if let image = data.image {
                PersonCardImage(image: image)
            }
        }
        .padding(EdgeInsets(
            top: 0,
            leading: TimelineViewMetrics.cardPaddingLeading,
            bottom: 0,
            trailing: 0
        ))
        .frame(minHeight: PersonCardView.minHeight)
        .cardBackground()
        .onTapGesture {
            actions.onTap()
        }
    }
}

In this case, I tried using .find(PersonCardView.self) as the target, but that would not trigger the expectation as the onTapGesture is not on that view, it's on the first child of that view...

PersonCardHStack is a custom SwiftUI layout, and I am unsure how to target it or its contents. I am sure this is a key contributor. It seems like in the PRs that have previously been addressed that it is possible to now target contents of custom layouts, but I am clearly missing something.

I would appreciate any pointers, so I don't have to change my view structure just to make this action wiring testable.

babbage commented 1 month ago

I have since modified the test to target the PersonCardViewContent by using:

let target = try view.inspect().find(PersonCardView.self).first!

This lets me make PersonCardViewContent no longer public, which is nice and what I'd prefer to do, since no other view should be instantiating that view. However, I can't use this approach with the originally preferred view structure... if I do, the target ends up being the PersonCardTextBlock and the test fails saying:

failed: caught error: "PersonCardTextBlock does not have 'onTapGesture' modifier"