🗂

ViewInspectorによるSwiftUIのユニットテスト

2023/01/26に公開

はじめに

参考

GitHub

導入

  • ViewInspectorはSwift Package Managerを使って追加します。
  • 本体のTargetではなく、GuidTestsなどテストを対象に追加します。

image

基本的な使い方

  • 例としてユニットテストの対象のビューを下記とします。
struct DynamicQueryWithFindView1: View {
    var body: some View {
        VStack {
            HStack {
                Text("Hi")
                Text("Ok")
            }

            Button {
                print("xyz button pressed")
            } label: {
                Text("xyz")
            }
            
            Text("viewWithId: 7")
                .id(7)
            
            Text("viewWithTag: Home")
                .tag("Home")
            
            Button {
                print("play button pressed")
            } label: {
                Text("Play")
            }
            .accessibilityLabel("Play button")
            .accessibilityIdentifier("play_button")            
        }
    }
}

  • 例としてText("Ok")の"Ok"の文字列を確認するテストは、以下の流れで書くことができます。
    • view.inspect()に対してfindを呼ぶ。
    • 最初に見つかったHStackが取得される。
    • text(1)により2番目のText要素であるText("Ok")が取得される。
    • string()により"Ok"の文字列が取得できるので、これの値を確認する。
  • また各箇所にtryがついているように、View内にHStackが見つからない場合はそこでエラーが投げられます。
func testDynamicQueryWithFindView1() throws {
    let sut = DynamicQueryWithFindView1()
    let okText = try sut.inspect().find(ViewType.HStack.self).text(1)  // find
    XCTAssertEqual(try okText.string(), "Ok")
    ...
}
  • findの機能は色々と用意されていて、値やクラス以外にもaccessibilityIdentifierなど用途に合わせて取得方法を選ぶことができます。
// findの機能の例
let xyzText = try sut.inspect().find(text: "xyz")
let xyzButton = try sut.inspect().find(button: "xyz")
let viewWithId = try sut.inspect().find(viewWithId: 7)
let viewWithTag = try sut.inspect().find(viewWithTag: "Home")
let hStack = try sut.inspect().find(ViewType.HStack.self)
let playButton01 = try sut.inspect().find(viewWithAccessibilityLabel: "Playbutton")
let playButton02 = try sut.inspect().find(viewWithAccessibilityIdentifier:"play_button")
  • 子ビューから親ビューに向かって検索も可能です。
let vStack = try okText.find(ViewType.VStack.self, relation: .parent)
  • findAllで複数の要素を取得することもできます。
let textsInHStack = try sut.inspect().findAll(ViewType.Text.self)
XCTAssertEqual(try textsInHStack[0].string(), "Hi")
XCTAssertEqual(try textsInHStack[1].string(), "Ok")

@Binding/@ObservedObjectのプロパティを持つビューのテスト

  • @Bindingのプロパティを持つビューを考えます。
struct ViewsUsingBindingAndObservedObjectView1: View {    
    @Binding var isOn: Bool    
    var body: some View {
        VStack {
            HStack {
                Text("Status: ")
                if isOn {
                    Text("ON")
                } else {
                    Text("OFF")
                }
            }
            .padding(.bottom, 8)
            
            Button {
                isOn.toggle()
            } label: {
                Text("Toggle status")
            }
        }
    }
}

  • @Bindingとして渡す用の変数をテスト内で作成しViewに渡します。
  • tap()で渡した変数の値が更新されるので、前後で値が変更されていることをテストします。
    • 親Viewの@Stateのプロパティを子Viewに@Bindingとして渡して、そこで更新された値が親ビューに反映されるのと同じイメージでしょうか。
func testInspectableAttributesView1() throws {
    let flag = Binding<Bool>(wrappedValue: false)
    let sut = ViewsUsingBindingAndObservedObjectView1(isOn: flag)
    XCTAssertFalse(flag.wrappedValue)
    try sut.inspect().find(button: "Toggle status").tap()
    XCTAssertTrue(flag.wrappedValue)
}

@State/@Environment/@EnvironmentObjectのプロパティを持つビューのテスト

  • @Stateのプロパティを持つビューを考えます。
  • この場合は少し下準備が必要となります。
struct ViewsUsingStateAndEnvironmentOrEnvironmentObjectView2: View {
    
    @State var flag: Bool = false
    
    var body: some View {
        Button {
            flag.toggle()
        } label: {
            Text(flag ? "True" : "False")
        }
    }
}
  • ビルドターゲットに下記内容のInspection.swiftを追加します。
import Combine
import SwiftUI

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)
        }
    }
}
  • そして先程のビューに下記を追加します。
    • (この辺はおまじないですね)
struct ViewsUsingStateAndEnvironmentOrEnvironmentObjectView2: View {
    
    @State var flag: Bool = false
+   internal let inspection = Inspection<Self>()  // For ViewInspector Tests
    
    var body: some View {
        Button {
            flag.toggle()
        } label: {
            Text(flag ? "True" : "False")
        }
+       .onReceive(inspection.notice, perform: { output in
+           inspection.visit(self, output)  // For ViewInspector Tests
+       })
    }
}
  • またテストターゲット側に下記内容のInspection+Model.swiftを追加します。
extension Inspection: InspectionEmissary { }
  • テストは以下のように書くことができます。
  • 標準の非同期テストで使われるwait(for:timeout:)を使います。
  • プロパティの取得にview.actualView().flagのようにactualView()を挟む点や、sutviewの区別を意識するのがポイントでしょうか。
func testButtonTogglesFlag() throws {
    let sut = ViewsUsingStateAndEnvironmentOrEnvironmentObjectView2()
    let exp = sut.inspection.inspect { view in
        XCTAssertFalse(try view.actualView().flag)
        try view.button().tap()
        XCTAssertTrue(try view.actualView().flag)
    }
    ViewHosting.host(view: sut)
    wait(for: [exp], timeout: 0.1)
}

その他

  • その他StylesGesturesAlertなどのポップアップ系などに対応しておりREADMEが用意されています。
  • テスト初回は時間がかかりますが、2回目以降は短時間で済むのも嬉しい点です。

まとめ

  • 以上のようにViewInspectorを使ってSwiftUIでUIのユニットテストが書くことができます。
  • 具体的なテスト内容として以下のようなテストを書くといいのかなと思っています。
    • 1つのViewに対してプロパティやViewModelを設定し、それに応じて期待するViewクラスやデザインが表示されているか
    • ボタンを押すなどアクション前後のプロパティの値やUIを確認

Discussion