nalexn / ViewInspector

Runtime introspection and unit testing of SwiftUI views
MIT License
2.18k stars 150 forks source link

Better API for inspecting `UIViewRepresentable` #127

Open cameroncooke opened 3 years ago

cameroncooke commented 3 years ago

I'm really struggling to understand how to test a UIViewRepresentable, all my other tests are fine but the one test where I want to update the State values from the test and assert that the underlying UITextField is updated to reflect those states changes just won't work.

I've looked at other similar reported issues and all the documentation but I'm not really understanding the underlying implementation details to know what I need to do whether it's even possible. I can't see any examples in this repo where state bindings are updated by the test, only the other way around, perform a UI action and assert the bindings are updated.

I'm also seeing an Xcode run-time warning when running the below test:

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.

The point of the test is to assert the default UITextField's state is correctly configured based on defaults on the @State properties and then to update the state and assert that the UITextField is updated accordingly.

In test target:

class Tests: XCTestCase {

    func test_updateBindingsUpdatesTextField() throws {

        let sut = BetterTextFieldContentView()
        ViewHosting.host(view: sut)

        var textField = try sut.inspect().find(BetterTextField.self).actualView().uiView()

        // Assert UITextField is configured based on binding defaults
        XCTAssertEqual(textField.text, "")
        XCTAssertFalse(textField.isFirstResponder)

        // Update state values
        sut.text = "Hello"
        sut.isFirstResponder = true

        // Wait a second to allow the view to update?
        let exp = sut.inspection.inspect(after: 1) { view in

            // get the next field again, not sure if we need to do this?
            textField = try view.find(BetterTextField.self).actualView().uiView()
        }

        wait(for: [exp], timeout: 1.1)

        // Assert that the values have been updated on the underlying UITextField
        XCTAssertEqual(textField.text, "Hello")
        XCTAssertTrue(textField.isFirstResponder)
    }
}

// Do we actually need this content view wrapper in order
// to use @State when testing?
struct BetterTextFieldContentView: View {

    @State var text = ""
    @State var isFirstResponder = false

    internal let inspection = Inspection<Self>()

    var body: some View {
        BetterTextField(text: $text, isFirstResponder: $isFirstResponder)
            .onReceive(inspection.notice) { self.inspection.visit(self, $0) }
    }
}

extension Inspection: InspectionEmissary {}
extension BetterTextFieldContentView: Inspectable {}
extension BetterTextField: Inspectable {}

In the main target:

// Would be defined outside of the test target
public struct BetterTextField: UIViewRepresentable {

    @Binding var isFirstResponder: Bool
    @Binding var text: String

    public init(text: Binding<String>,
                isFirstResponder: Binding<Bool>) {
        _text = text
        _isFirstResponder = isFirstResponder
    }

    public func makeUIView(context: Context) -> UITextField {
        ...
    }

    public func updateUIView(_ uiView: UITextField, context: Context) {
        uiView.text = text

        switch isFirstResponder {
        case true: uiView.becomeFirstResponder()
        case false: uiView.resignFirstResponder()
        }
    }

    ...
}

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)
        }
    }
}

Any help would be greatly appreciated as I've spent two days on this now and getting nowhere. I know the actual implementation works from manual testing but trying to automate that here is just not working.

cameroncooke commented 3 years ago

Right, I think I worked it out, it's a bit involved due to two specific limitations when using UIViewRepresentable views:

  1. You can't inspect the uiView() reliably without waiting for the view to appear first, so need to implement Approach #1 as detailed in the Guide. If you don't use this hook I found that I would be inspecting the UITextField before it's been updated via the UIViewRepresentable's updateUIView(_:, context:) method, so while you can obtain the UITestField from calling uiView() you'll get it before it's been updated with the default bindings values.

  2. Need to use a Publisher to allow the @State properties to be updated from outside of the View using this Approach #2 but importantly this needs to be done within the closure of the didAppear call.

Here is my final implementation:

        var sut = BetterTextField.WrapperView(text: "Initial Value")
        sut.didAppear = { 

            let publisher = sut.publisher
            let exp1 = sut.inspection.inspect { view in

                // Assert inital value is set on the `UITextField` after the `View` has appeared
                let textField = try view.view(BetterTextField.self).actualView().uiView()
                XCTAssertEqual(textField.text, "Initial Value")

                // Update `@State var text` value to `New Value`
                publisher.send(("New Value", false))
            }

            // Important to drop first value on publisher otherwise we'll receive the initial value and the expectation will fulfill
            let exp2 = sut.inspection.inspect(onReceive: publisher.dropFirst()) { view in

                // Assert the `UITextField` text value is now set to `New Value`
                let textField = try view.view(BetterTextField.self).actualView().uiView()
                XCTAssertEqual(textField.text, "New Value")
            }

            defer { ViewHosting.expel() }
            ViewHosting.host(view: sut)

            self.wait(for: [exp1, exp2], timeout: 1)
        }

I also found out that I didn't need to introduce the Inspection class or the wrapper view in the production target and instead could just create it in the Test target by importing the SwiftUI module.

extension BetterTextField {

    struct WrapperView: View, Inspectable {

        @State var text: String = ""
        let publisher = PassthroughSubject<String, Never>()

        var didAppear: ((BetterTextField) -> Void)?
        let inspection = Inspection<Self>()

        var body: some View {
            BetterTextField(text: $text)
                .onAppear {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                            didAppear?(self)
                        }
                }
                .onReceive(publisher) { text = $0 }
                .onReceive(inspection.notice) { self.inspection.visit(self, $0) }
        }
    }
}

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 { }
extension BetterTextField: Inspectable {}

IMO I kind of feel the primary use case for this library is testing 3rd party views as opposed to Apple views and the current documentation seems to lean more towards the latter. Would be good to have more detail like I've posted above on how to achieve testing of UIViewRepresentable views as it's a bit more involved and would save future me's going through the pain.

Also, I think putting the WrapperView in the Test target is far better than adding it to the app target.

Anyway, seems to work let me know if there are any further improvements I can make. Thanks!

nalexn commented 3 years ago

Hey @cameroncooke

Thanks for the feedback, I agree the project lacks such real-world examples and docs, my focus is really on the functionality still, catching up with the SwiftUI versions. The case with UIViewRepresentable really deserves better tooling, I can clearly see the problem here.

cameroncooke commented 3 years ago

I agree around tooling, I think the issue here is that there are some intricacies with testing UIViewRepresentable views and about a million different ways of writing the tests but only one way works reliably in my experience, so ideally this would be abstracted away by the tooling so that mistakes can't be made at the test implementation site.

bitsmakerde commented 1 year ago

@nalexn I come back after servile Weeks to my project and my test fails now:

found (extension in BusinessPhotoAppTests):BusinessPhotoApp.CameraDeviceController.WrapperView instead of (extension in BusinessPhotoAppTests):BusinessPhotoApp.CameraDeviceController.WrapperView

Do Giu have an idea what I muss change?

import XCTest
import ViewInspector
import AVFoundation
import SwiftUI
@testable import BusinessPhotoApp

class CameraControllerTests: XCTestCase {
    func testViewLoad_shouldNotCrash() throws {
        let (sut, _) = makeSUT()
        XCTAssertNotNil(sut)
    }

    func testViewLoad_renderAVCaptureVideoPreviewLayer() throws {
        var (sut, viewModel) = makeSUT()

        let exp = sut.on(\.didAppear) { view in
            let uiView = try view.view(CameraDeviceController.self).actualView().uiView()

            XCTAssertEqual(uiView.layer.sublayers?.first, viewModel.preview)
        }

        ViewHosting.host(view: sut)
        wait(for: [exp], timeout: 0.1)
    }

    // MARK: - helper

    func makeSUT() -> (sut: CameraDeviceController.WrapperView, viewModel: CameraDeviceViewModel) {
        let cameraDeviceService = CameraDeviceService(
            cameraAccessDelegate: CameraAccessNavigationAdapter()
        )
        let cameraDeviceViewModel = CameraDeviceViewModel(preview: CALayer(layer: Text("hallo")))
        let cameraDeviceStore = CameraDeviceStore(cameraDeviceViewModel: cameraDeviceViewModel)
        let sut = CameraDeviceController.WrapperView(
            cameraDeviceService: cameraDeviceService,
            cameraDeviceViewModel: cameraDeviceViewModel,
            cameraDeviceStore: cameraDeviceStore
        )

        return (sut: sut, viewModel: cameraDeviceViewModel)
    }
}

extension CameraDeviceController {
    struct WrapperView: View {
        var didAppear: ((Self) -> Void)?
        let cameraDeviceService: CameraDeviceService
        let cameraDeviceViewModel: CameraDeviceViewModel
        let cameraDeviceStore: CameraDeviceStore

        var body: some View {
            CameraDeviceController(cameraDeviceStore: cameraDeviceStore)
                .onAppear {
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                        didAppear?(self)
                    }
                }
        }
    }
}

A downgrade to 0.9.2 fix the problem.

nalexn commented 1 year ago

@bitsmakerde Could you open a separate ticket and provide the essential part of the CameraDeviceController and any other views referenced in the test