How to Test SwiftUI Views Containing @State in ViewInspector
A simple yet powerful SwiftUI trick
For a very good getting started with SwiftUI testing, using ViewInspector
framework, you can read this or this.
One of the items described in the first tutorial (and on the ViewInspector GitHub page) is the usage of @State
in the view that you want to test.
Basically, if your view is something like this:
struct ContentView: View {
@State var numClicks:Int = 0
var body: some View {
VStack{
Button("Click me"){
numClicks += 1
}.id("Button1")
Text("\(numClicks)")
.id("Text1")
.padding()
}
}
}
It's actually not possible to test the action of clicking the button on the numClicks
.
The acceptable workaround is to add a bit of code to the view, transforming it basically in :
struct ContentView: View {
@State var numClicks:Int = 0
internal let inspection = Inspection<Self>()
var body: some View {
VStack{
Button("Click me"){
numClicks += 1
}.id("Button1")
Text("\(numClicks)")
.id("Text1")
.padding()
}.onReceive(inspection.notice) {
self.inspection.visit(self, $0) }
}
}
where Inspection is:
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 {}
As you can see ContentView
got a new Inspection property and the onReceive of its body is instructed to run the visit method of the Inspection property when data is published to the noticed publisher.
This solves the issue, and a functioning test case could look like this:
func testContentView() throws{
let sut = ContentView()
_ = sut.inspection.inspect { view in
let button = try view.find(viewWithId: “Button1”).button()
try button.tap()
XCTAssertEqual(try view.actualView().numClicks, 1)
let text = try view.find(viewWithId: “Text1”).text()
let value = try text.string()
XCTAssertEqual(value, “1”)
}
}
However, it looks kind of ugly to add testing ‘features’ to your production code. It can make a complicated view even more complicated and it a lot of redundant code that needs to be copy/pasted in each view that you want to test.
Therefore, I tried to find something cleaner, that would add a bit of overhead to the testing code, but leave the actual view implementation untouched.
First I implemented a simple wrapper view, that could add the inspection functionality needed by ViewInspector
:
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)
}
}
}extension TestWrapperView: Inspectable{}
With this wrapper view, the testing of the view is possible, using the original implementation of ContentView
:
func testContentView() throws{
let sut = TestWrapperView(wrapped: ContentView())
_ = 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(ContentView.self)
.actualView()
.numClicks
XCTAssertEqual(numClicks, 1)
let text = try wrapped.find(viewWithId: “Text1”).text()
let value = try text.string()
XCTAssertEqual(value, “1”)
}
}
I hope you like this small trick, that makes the testing of SwiftUI views a bit easier.