Open cameroncooke opened 3 years ago
Right, I think I worked it out, it's a bit involved due to two specific limitations when using UIViewRepresentable
views:
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.
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!
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.
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.
@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.
@bitsmakerde Could you open a separate ticket and provide the essential part of the CameraDeviceController
and any other views referenced in the test
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 theState
values from the test and assert that the underlyingUITextField
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:
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 theUITextField
is updated accordingly.In test target:
In the main target:
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.