🕵️

ViewInspectorでSwiftUIのUnitTest入門

2023/06/23に公開

こんにちは、スペースマーケットでモバイルエンジニアをしている村田です。

スペースマーケットのゲストiOSプロジェクトはUnitTestが不十分(In/Outが分かりやすいヘルパー的なクラスのテストが若干あるだけ)で、カバレッジがかなり低い状況です。VPoE成原さん協力のもと品質担保するためにUnitTestちゃんと書いていこうぜ!ということでチャプター活動の時間を使いUT環境整備/ライブラリ選定/モブプロでUT実装を行なっています。

UI(SwiftUI)テストの為のライブラリとしてViewInspectorをプロジェクトへ試しに導入し、個人的に学習した基礎的な内容についての記事になります。

導入経緯

現状SwiftUIのUnitテスト標準フレームワークがなく(先日のWWDCでも特に発表なかったですね...)、AccessibilityIdentifierで一意のIDを付与してテスト対象のViewを取得する必要がありそうでした。ただ、テストのためにプロダクションコード汚したくないよね...という話になり有志のドキュメントをちらほら見かけるViewInspectorを試しに導入することにしました。

ViewInspectorとは

ViewInspectorとは、SwiftUIベースのUIテストを行うためのライブラリです。ViewInspectorを使用することで、テスト中にSwiftUIビューの状態やプロパティを取得/操作することができます。

https://github.com/nalexn/ViewInspector

テスト対象サンプル

サンプルとして以下のViewをテストします

onAppearなし onAppear ボタン等操作
SampleView.swift
final class SampleViewModel: ObservableObject {
    @Published private(set) var colorText = "黒やで"
    @Published private(set) var color = Color.black

    func onAppear() {
        colorText = "紫やで"
        color = .purple
    }

    func onRedButtonTap() {
        colorText = "赤やで"
        color = .red
    }

    func onBlueButtonTap() {
        colorText = "青やで"
        color = .blue
    }

    func onGreenButtonTap() {
        colorText = "緑やで"
        color = .green
    }

    func onYellowTextTap() {
        colorText = "黄色やで"
        color = .yellow
    }

    func onYellowTextLongPress() {
        colorText = "オレンジやで"
        color = .orange
    }
}

struct SampleView: View {
    @StateObject var viewModel: SampleViewModel

    var body: some View {
        VStack(spacing: 20) {
            Text(viewModel.colorText)
                .font(.system(size: 40, weight: .bold))

            viewModel.color
                .frame(width: 200, height: 200)

            HStack(spacing: 20) {
                Button("赤へ変更") {
                    viewModel.onRedButtonTap()
                }
                .colorChangeViewModifier(color: .red)
                Button("青へ変更") {
                    viewModel.onBlueButtonTap()
                }
                .colorChangeViewModifier(color: .blue)
                Button("緑へ変更") {
                    viewModel.onGreenButtonTap()
                }
                .colorChangeViewModifier(color: .green)
            }

            Text("黄色へ変更")
                .foregroundColor(.yellow)
                .frame(width: 240)
                .font(.system(size: 20, weight: .bold))
                .colorChangeViewModifier(color: .yellow)
                .onTapGesture {
                    viewModel.onYellowTextTap()
                }
                .onLongPressGesture {
                    viewModel.onYellowTextLongPress()
                }
        }
        .onAppear {
            viewModel.onAppear()
        }
    }
}

extension View {
    func colorChangeViewModifier(color: Color) -> some View {
        modifier(ColorChangeViewModifier(color: color))
    }
}

struct ColorChangeViewModifier: ViewModifier {
    let color: Color

    func body(content: Content) -> some View {
        content
            .font(.system(size: 16, weight: .bold))
            .padding(10)
            .accentColor(color)
            .border(color, width: 2)
    }
}

View要素取得

テスト対象のViewから任意の要素(View)を取得する方法を記載しつつ、ViewInspectorの基本的な使い方に触れます。Viewの拡張メソッドとして用意されたinspectメソッドを呼び出し、テスト対象Viewの検証を開始します。

let sut = SampleView()
sut.inspect()...

階層的に取得

まず、View階層を辿り任意の要素を取得する方法です。
inspectメソッドに続き、取得対象View同名のメソッドを連結して階層的に取得します。

  • 例えば1️⃣のコードですが、画面最上部のテキストを取得しています。VStackの1番目(index:0)の要素となるTextなので vStack().text(0) で取得できます
  • 最上層のViewはTextではなくVStackのため 2️⃣ は失敗します(例外がスローされる)
  • Button内部のTextも含まれるため 黄色へ変更テキストはVStackの5番目(index:4)の要素となり、 3️⃣のvStack().text(4) で取得可能です
  • 青へ変更 ボタンを取得するには 「VStack -> VStack3番目の要素HStack -> HStack2番目の要素Button」という階層を辿る必要があり、 4️⃣ のコード vStack().hStack(2).button(1) になります
  • 最上層のViewであるVStackにボタンは含まれないため 5️⃣ は失敗します
let sut = SampleView()

// 1️⃣ ✅ 
let text = try sut.inspect().vStack().text(0)
 XCTAssertEqual(try text.string(), "黒やで")
// 2️⃣ ❌
let text2 = try sut.inspect().text(0)
XCTAssertEqual(try text2.string(), "黒やで")
// 3️⃣ ✅
let text3 = try sut.inspect().vStack().text(3)
 XCTAssertEqual(try text3.string(), "黄色へ変更")
// 4️⃣ ✅
let button = try sut.inspect().vStack().hStack(2).button(1)
XCTAssertEqual(try button.accentColor(), Color.blue)
// 5️⃣ ❌
let button2 = try sut.inspect().vStack().button(0)
gXCTAssertEqual(try button2.accentColor(), Color.red)

find/findAll

次に、find/findAllメソッドを使った取得方法について記載します。

findメソッドは最初にマッチするViewを見つけるまで階層を順に探索します。もし見つからなかった場合は例外をスローします。

  • 1️⃣:指定した文言にマッチするTextを検索
  • 2️⃣:1️⃣同様にTextを検索しますが、指定した文言のTextが存在しない為例外をスローします
  • 3️⃣:型指定による検索も可能で、Textの場合 ViewType.Text.self を指定します
  • 4️⃣:TextFieldは存在しない為、ViewType.TextField.self を指定した場合例外をスローします
  • 5️⃣:whereを使用して特定条件の指定が可能です。黄色へ変更テキストを特定条件で取得する場合、例えば attributes().foregroundColor()yellow と一致する事を条件に取得することが可能です
// 1️⃣ ✅ 
let text = try sut.inspect().find(text: "黒やで")
XCTAssertEqual(try text.string(), "黒やで")
// 2️⃣ ❌ 
try sut.inspect().find(text: "黄金やで")
// 3️⃣ ✅
let text3 = try sut.inspect().find(ViewType.Text.self)
XCTAssertEqual(try text3.string(), "黒やで")
// 4️⃣ ❌
try sut.inspect().find(ViewType.TextField.self)
// 5️⃣ ✅
let text5 = try sut.inspect().find(ViewType.Text.self, where: {
    try $0.attributes().foregroundColor() == .yellow
})
XCTAssertEqual(try text5.string(), "黄色へ変更")

findAllメソッドは階層全体を走査し、一致するViewを全て配列で返します。何も見つからなかった場合例外をスローせず、空の配列を返します。

  • 1️⃣:find同様に型指定による検索が可能で、型がButtonに一致する全ての要素を取得します
  • 2️⃣:TextFieldは存在しない為空配列が返されます(例外をスローしない)
  • 3️⃣:こちらもfind同様にwhereを使用して特定条件の指定が可能です
// 1️⃣
let buttonList = try sut.inspect().findAll(ViewType.Button.self)
XCTAssertEqual(try buttonList[0].accentColor(), .red)
XCTAssertEqual(try buttonList[1].accentColor(), .blue)
XCTAssertEqual(try buttonList[2].accentColor(), .green)
// 2️⃣
let textFieldList = try sut.inspect().findAll(ViewType.TextField.self)
XCTAssertEqual(textFieldList.count, 0)
// 3️⃣
let textList = try sut.inspect().findAll(ViewType.Text.self, where: {
    try $0.string().contains("変更")
})
XCTAssertEqual(textList.count, 4)

Eventシミュレート

ボタンタップのようなユーザー操作や、onAppear等のイベントを発火させることも可能です

ユーザー操作

Buttonのtapメソッドをコールすることで、タップ処理のシミュレートが可能です。
以下赤へ変更ボタンをタップし、ColorViewの色が更新されたことを確認できます。

let colorView = try sut.inspect().find(ViewType.Color.self)
// タップ前は黒であることを確認
XCTAssertEqual(try colorView.value(), .black)

// 赤へ変更ボタン取得
let redButton = try sut.inspect().find(ViewType.Button.self, where: {
    try $0.accentColor() == .red
})
// 赤へ変更ボタンタップ
try redButton.tap()

let colorView2 = try sut.inspect().find(ViewType.Color.self)
// タップ後、赤に更新されることを確認
XCTAssertEqual(try colorView2.value(), .red)

Gestureに対するイベント処理もシミュレート可能です。
以下は 黄色へ変更テキストの onTapGesture/onLongPressGestureイベント発火の確認になります。

let colorView = try sut.inspect().find(ViewType.Color.self)
// イベント処理前は黒であることを確認
XCTAssertEqual(try colorView.value(), .black)

// 黄色へ変更テキスト取得
let yellowText = try sut.inspect().find(ViewType.Text.self, where: {
    try $0.attributes().foregroundColor() == .yellow
})

// タップイベント
try yellowText.callOnTapGesture()
let colorView2 = try sut.inspect().find(ViewType.Color.self)
// タップ後、黄色に更新されることを確認
XCTAssertEqual(try colorView2.value(), .yellow)

// 長押しイベント
try yellowText.callOnLongPressGesture()
let colorView2 = try sut.inspect().find(ViewType.Color.self)
// 長押し後、オレンジに更新されることを確認
XCTAssertEqual(try colorView2.value(), .orange)

Viewイベント

Viewイベントをシミュレートすることも可能で、以下はonAppear実行を確認するコードになります。

let colorView = try sut.inspect().find(ViewType.Color.self)
// onAppear呼び出し前は黒であることを確認
XCTAssertEqual(try colorView.value(), .black)

// onAppear実行
try sut.inspect().vStack().callOnAppear()

let colorView2 = try sut.inspect().find(ViewType.Color.self)
// onAppear後、紫に更新されることを確認
XCTAssertEqual(try colorView2.value(), .purple)

感想

まだ触り始めて日が浅く今後付き合っていく中でデメリット出てくると思いますが、今の所好印象で想像していたより機能が充実している/メソッド名が直感的で扱いやすいと感じました!次はCustomViewやCustomModifierが絡んだテストについて記事が書ければ🙏

最後に

スペースマーケットでは一緒に働く仲間を絶賛募集中です!
詳しくは以下をご確認の上ご応募ください。

https://spacemarket.co.jp/recruit/engineer/

https://www.wantedly.com/projects/1061116

https://www.wantedly.com/projects/1113570

https://www.wantedly.com/projects/1113544

GitHubで編集を提案
スペースマーケット Engineer Blog

Discussion