Stanby Tech Blog

求人検索エンジン「スタンバイ」を運営するスタンバイの開発組織やエンジニアリングについて発信するブログです。

ViewInspectorを用いたユーザー操作の再現とログ送信テストの実装方法

はじめに

こんにちは、スタンバイのアプリチームでiOS開発を担当している小村祐輝と申します。

私たちスタンバイのiOSチームでは、SwiftUIやCombine、Concurrencyなどのモダンな技術を用いて日々開発を進めています。

その中で、直近で浮上した課題の1つが「UIテスト」です。

この記事では、私たちスタンバイのiOSチームがどのようにしてUIテストを構築し、運用しているのかをご紹介します。

UIテスト導入の背景や、その必要性、具体的な手法やツールについても解説していきます。

UIテストの必要性とログ送信テスト導入の背景

スタンバイは、求職者と企業をつなぐプラットフォームとして展開しており、数多くの求人サイトを一括して検索・比較できる「アグリゲーションサービス」です。

アプリのデザインは求人の検索から応募までを簡単に行えるようになっており、月間アクティブユーザー数も急増しています。

ですが、アプリの規模が大きくなるにつれて、手動テストだけでは限界を感じる場面が増えてきたのもまた事実でした。

特にユーザー行動の分析が重要なスタンバイにおいては、送信されるログが正確かつ適切なタイミングで送られているかの確認が必須です。

そこで、まずはログ送信テストを重点的に行うUIテストの導入を開始しました。

ログ送信テストにUIテストを導入する理由

とはいえ、ログ送信のテストと聞くと中には、

「ユニットテストで担保できるのでは?」

と思われる方もいらっしゃるのではないでしょうか。

確かに、ログの内容だけであればユニットテストでも十分です。

しかし、ユーザー操作を伴うログ送信は、実際のユースケースに基づいたUIテストの方が堅牢性は高まります。

私たちは、この点を重要視し、UIテストを選択しました。

UIテスト構築の課題とViewInspectorの採用

しかし、iOSチームにはUIテスト構築の経験者がいませんでした。

XCUITestは学習コストが高く、UIテストを構築するには大きな壁が立ちはだかったわけです。

そんな中で出会ったのが、ViewInspectorというライブラリです。

ViewInspectorとは?

ViewInspectorとは、SwiftUIで構築されたViewに対して、プログラムから直接アクセスし、その状態や動作を確認できるライブラリです。

ViewInspector

SwiftUIのViewはその構造上、内部の状態を直接参照したり、検証したりすることが難しくなっています。

XCUITestの学習コストもそうですが、これもXCUITestを導入する際の障壁の1つでした。

ViewInspectorは、この問題を解決するために作られたツールであり、開発者がSwiftUIのViewを簡単にテストできる環境を提供してくれています。

具体的には、Viewの階層構造にアクセスし、

  • 特定のViewやそのプロパティ
  • 表示されるテキスト
  • ボタンのアクション

などをプログラム的に検証できます。

これにより、手動でのUIテストに頼ることなく、ログ送信のような重要な機能に対しても、実際のユーザー操作を模倣したテストを効率的に行うことが可能になるわけです。

また、ViewInspectorは直感的なAPIを提供しているため、SwiftUIを使っている開発者であれば、比較的容易に導入できる点も大きなメリットに他なりません。

私たちiOSチームも、UIテストの複雑さや学習コストの高さに悩まされていた中で、このViewInspectorを採用することで、テスト環境の構築をスムーズに進めることができました。

基本的な使い方と例

この次の項目からスタンバイのiOSプロジェクトでどのようにViewInspectorを利用しているのかを解説しますが、それに先立ってまずはViewInspectorの基本的な使い方を説明します。

ここでは3つのテストパターンを用意したので、それぞれ具体的に掘り下げていきます。

1. テキスト表示の検証

まず、Text が正しく表示されているかを確認する基本的なテストを例に説明していきます。

import SwiftUI
import ViewInspector
import XCTest

// テスト対象のView
struct SimpleTextView: View {
    var body: some View {
        Text("Hello, ViewInspector!")
    }
}

class SimpleTextViewTests: XCTestCase {
    
    func testTextIsDisplayedCorrectly() throws {
        let view = SimpleTextView()
        
        // ViewInspectorでTextの内容を取得
        let text = try view.inspect().find(text: "Hello, ViewInspector!").text().string()
        
        // 検証
        XCTAssertEqual(text, "Hello, ViewInspector!")
    }
}

ViewInspectorで特定のViewにアクセスする際、まずは view.inspect() と宣言した上で、Viewを階層的に参照していく必要があります。

view.inspect()

これにより、内部的に参照対象のRootViewが取得され、そこからさらに指定した要素へアクセスできるようになります。

次に、特定のテキストを持つViewにアクセスするためには、以下のようにfind(text:)メソッドを使います。

view.inspect().find(text: "Hello, ViewInspector!")

これで、"Hello, ViewInspector!"というテキストを持つViewが取得できます。

さらに今回は、このテキストの正確性をテストするため、文字列そのものを取得する必要があります。

そのために、text()メソッドを使用して次のように記述します。

view.inspect().find(text: "Hello, ViewInspector!").text()

これで、テキスト要素の文字列データにアクセスできました。

次に、この文字列をstring()メソッドで取得し、テストの期待値と比較します。

let text = try view.inspect().find(text: "Hello, ViewInspector!").text().string()
XCTAssertEqual(text, "Hello, ViewInspector!")

ここで、XCTAssertEqualを使って、取得したテキストが期待される内容"Hello, ViewInspector!"であるかどうかを検証します。

2. ボタンのタップと状態変更の検証

次に、ボタンをタップして、内部状態が更新される動作テストを解説していきます。

import SwiftUI
import ViewInspector
import XCTest

// テスト対象のView
struct CounterView: View {
    @State private var count = 0
    
    var body: some View {
        VStack {
            Text("\(count)")
            Button("Increment") {
                count += 1
            }
        }
    }
}

class CounterViewTests: XCTestCase {
    
    func testButtonTapIncrementsCounter() throws {
        let view = CounterView()
        let sut = try view.inspect()
        
        // 初期状態を確認
        XCTAssertEqual(try sut.find(text: "0").text().string(), "0")
        
        // ボタンをタップ
        try sut.find(button: "Increment").tap()
        
        // タップ後、カウンターが1に増えていることを検証
        XCTAssertEqual(try sut.find(text: "1").text().string(), "1")
    }
}

まず、このテストでは CounterView というカウンター機能を持ったシンプルなSwiftUIビューの動作を検証しています。

特定のボタンをタップした際に、カウントが正しくインクリメントされているかを確認するテストです。

XCTAssertEqual(try sut.find(text: "0").text().string(), "0")

ここでは、CounterViewが持つTextの初期状態が0という事を確認しています。

view.inspect()でRootViewにアクセスした後、find(text:)を使ってText("0")を検索しています。

このTextはカウントを表示する部分です。

次に、text()メソッドを使ってText要素の中身(文字列データ)を取得し、string()メソッドでその内容を文字列として取得します。

最終的に、XCTAssertEqualを使用して、取得した文字列が初期状態で文字列"0"であるかどうかを確認します。

その上で、ボタンをタップしてカウンターの値をインクリメントする処理を以下で実行しています。

try sut.find(button: "Increment").tap()

ここでは、find(button:)メソッドを使用して、"Increment"というテキストを持つButtonを検索しています。

tap()メソッドを使うことで、ViewInspectorはそのボタンを実際にタップし、@Stateで管理されているカウンターの状態を更新できるわけです。

XCTAssertEqual(try sut.find(text: "1").text().string(), "1")

タップ操作の後、カウントが1に増えていることを確認する箇所が上記です。

再度、find(text:)を使って更新されたText("1")を検索すると共に、その内容をtext().string()で取得し、期待通り取得した値が"1"であることを検証しています。

3. ネストされたビューの検証

最後に、ネストされたViewの中で特定のViewにアクセスし、その状態を検証する方法を解説します。

import SwiftUI
import ViewInspector
import XCTest

// テスト対象のView
struct ParentView: View {
    var body: some View {
        VStack {
            ChildView()
        }
    }
}

struct ChildView: View {
    var body: some View {
        Text("child view")
    }
}

class ParentViewTests: XCTestCase {
    
    func testNestedViewText() throws {
        let view = ParentView()
        let sut = try view.inspect()
        
        // ネストされたChildViewのTextを確認
        let text = try sut.find(ChildView.self).find(text: "child view").text().string()
        
        // 検証
        XCTAssertEqual(text, "child view")
    }
}

このテストケースでは、ParentViewという親ビューの中にChildViewという子ビューがあり、その中で表示されるテキストが正しいかどうかを確認しています。

ParentViewVStackの中にChildViewを含んでおり、ChildView では"child view"という固定のテキストが表示されています。

struct ParentView: View {
    var body: some View {
        VStack {
            ChildView()
        }
    }
}

その上で以下テストコードにもあるように、testNestedViewTextというメソッドでは、最初にParentViewのインスタンスを作成し、それをview.inspect()を使って検証の対象(sut)として設定します。

class ParentViewTests: XCTestCase {
    
    func testNestedViewText() throws {
        let view = ParentView()
        let sut = try view.inspect()
        
        // ネストされたChildViewのTextを確認
        let text = try sut.find(ChildView.self).find(text: "child view").text().string()
        
        // 検証
        XCTAssertEqual(text, "child view")
    }
}

次に、sutを通じて、ParentViewの内部にあるChildViewへアクセスします。

ここではfind(ChildView.self)を使用して、親ビュー内にあるChildViewを見つけ出しています。

そして、ChildViewにアクセスした後、次に行うのは、その中に表示されているテキストの確認です。

ここは既に説明した通り、find(text: "child view")を用いて、ChildView内のテキストを見つけ出します。

その後、text()メソッドでText要素自体を取得し、さらにstring()メソッドを使ってその文字列内容を取得します。

その上で、比較対象の検証するだけです。

XCTAssertEqual(text, "child view")

補足:ViewInspectorによる標準Viewへの階層アクセス方法

ここではfind()を利用してChildViewにアクセスしましたが、シーンによってはSwiftUI標準のViewにアクセスしたい場合もあるのではないでしょうか。

そのような独自の型を定義していない場合は、基本的にあらかじめViewInspector側で用意してある以下のようなメソッドを用いてViewの階層を掘っていくことが可能です。

try sut.vStack().hStack().geometryReader().zStack().group()...

これらのメソッドを利用することで、Viewの階層構造を1つずつ掘り下げながら目的のViewに到達できます。

一方で、以下のようにfind()を用いれば、直接目的のChildViewにアクセス可能です。

let text = try sut.vStack().find(ChildView.self)

find()を利用すると、階層をたどる手間を省けるため、特定のViewにアクセスするケースでは非常に便利です。

スタンバイにおけるViewInspectorの具体的な活用例

ViewInspectorの基本的な使い方は前述の通りで、複雑なケースでない限り、これだけで多くの動作をシミュレートできます。

その上で、冒頭で触れた通り、スタンバイのiOSアプリでは、このViewInspectorを用いてログ送信のテストを実装しているわけです。

ここでは実際のプロダクトにおける利用方法を元に、具体的なViewInspectorの使い方を解説していきます。

ログアナリティクスのモック化

まず、外部サービスに依存することなく、ログ送信をテストするために、ログ送信の代わりにモッククラスを作成します。

これにより、実際のネットワーク通信やサーバーの状態に左右されず、ログ送信が正しく行われるかどうかを検証できます。

class MockAnalyticsService: AnalyticsServiceProtocol {
    var events: [MockAnalyticsEvent] = []
    var didFinish: (() -> Void)?

    func logEvent(_ name: String, parameters: [String: Any]?) {
        let event = MockAnalyticsEvent(name: name, parameters: parameters)
        events.append(event)
        didFinish?()
    }
}

ログ送信のテストコード

次に、具体的なテストケースとして、ボタンタップにより送信されるログが正しいかどうかを検証するコードの紹介です。

func test_サンプル画面でのログ送信テスト() {
    // モックのViewModelに必要な情報を設定
    viewModel.isLoading = false
    viewModel.items = [.stub(id: "item1", name: "itemName1", code: "itemCode1")]

    let sut = SampleView(viewModel: self.viewModel)
    let exp = analyticsExp(mockAnalyticsService: mockAnalyticsService)

    // ボタンをタップしてイベントをトリガー
    do {
        try sut.inspect()
            .find(SampleCell.self)       // SampleViewの中のSampleCellを見つける
            .find(CellButton.self)       // SampleCellの中のCellButtonを見つける
            .find(button: "")            // CellButtonの中のButtonを見つける(テキストなし)
            .tap()                       // 見つけたボタンをタップする
    } catch {
        XCTFail("failed with: \(error)")
    }

    wait(for: exp, timeout: 2.0)

    // 送信されるべきイベントを定義
    let tapEvent = MockAnalyticsEvent(
        name: "tap_item",
        parameters: ["info1": "sample_screen", ..., ...]
    )

    // 実際に送信されたイベントと期待されるイベントを比較
    verify(actual: mockAnalyticsService.events, expected: [tapEvent])
}

このコードでは、SampleViewで特定のアイテムをタップしたときに送信されるログが正しいかどうかを検証しています。

まず、viewModelに必要なアイテム情報を設定し、モックのログアナリティクスサービスを使ってログ送信イベントを捕捉します。

その上で、まずは画面描画に必要な設定値をプロパティにアサインしているのが以下の箇所です。

viewModel.isLoading = false
viewModel.items = [.stub(id: "item1", name: "itemName1", code: "itemCode1")]

そして、以下のSampleViewをご覧になって頂くと、isLoadingの状態に応じてViewを切り替えているので、今回Itemを表示するためにあらかじめfalseをセットしています。

import SwiftUI

struct SampleView: View {
    @StateObject var viewModel: SampleViewModel

    var body: some View {
        Group {
            if viewModel.isLoading {
                LoadingView()
            } else if viewModel.jobs.isEmpty {
                EmptyCell()
            } else {
                List {
                    ForEach(0 ..< viewModel.items.count, id: \.self) { index in
                        let item = viewModel.items[index]
                        JobCell(item: item, didTap: {
                            viewModel.showDetail(item: item)
                        })
                    }
                }
            }
        }
    }
}

以下のコードでは設定したViewModelを用いてViewを初期化し、内部的にレンダリングを実行している箇所です。

let sut = SampleView(viewModel: self.viewModel)

上記のようにViewを初期化することで初めて、ViewInspectorによるViewの参照や検証が可能になります。

初期化により返されるViewをsutとし、それを用いてこれまでに解説してきた方法で目当てのViewまで掘り下げていくのが以下の実装です。

do {
    try sut.inspect()
        .find(SampleCell.self)       // SampleViewの中のSampleCellを見つける
        .find(CellButton.self)       // SampleCellの中のCellButtonを見つける
        .find(button: "")            // CellButtonの中のButtonを見つける(テキストなし)
        .tap()                       // 見つけたボタンをタップする
} catch {
    XCTFail("failed with: \(error)")
}

コメントにもある通り、独自で作成した型を明示的に指定して対象となるButtonまで掘り下げていきます。

その上で.tap()を実行することで、内部的にユーザーが特定のItemをタップしたのと同じ動きを実現しているということです。

ここまで来ればあとは簡単で、.tap()により実行されるログ送信イベントを補足し、あらかじめexpectとして用意したデータと比較することで簡単にテストできます。

let tapEvent = MockAnalyticsEvent(
    name: "tap_item",
    parameters: ["info1": "sample_screen",
                 ...
                 ...]
)

// 実際に送信されたイベントと期待されるイベントを比較
verify(actual: mockAnalyticsService.events, expected: [tapEvent])

ちなみに、以下のようなタイムアウト処理を入れているのは、非同期に実行されるログ送信処理が完了するのを待つためです。

wait(for: exp, timeout: 2.0)

そのため遅延時間を指定しているのですが、ここに関してはあまり参考にしてほしくはなく、テスト実行環境次第では普通に落ちてしまうこともあります...

ここは解消したい課題ではありますが、ほとんどの環境ではあまり気にすることもないので、現在のプロダクトコードでは上記のような形でも特段問題はありません。

ここまでで解説した手順で当初目的としていた、

  • イベント内容の正確性チェック
  • 適切なタイミングでログが送信されているかのチェック

を満たすことができました。

あとは、テストが必要な箇所で上記のように実装するだけであり、慣れたら非常に簡単です。

実際、UIテストやViewInspectorを利用したことがないメンバーに新規イベントのテストを着手してもらいましたが、非常に短時間で実装できたので、手軽にUIのテストを構築したい場合は非常に価値あるもになるはずです。

ViewInspectorのデメリット

ここまでViewInspectorのメリットに焦点を当ててきましたが、実際に利用してみると、いくつかのデメリットもあります。

  • 一連のUI動作や画面遷移のテストが難しい
  • 複雑なアニメーションやジェスチャー操作には対応していない
  • テスト実行時にWarningが出ることもあり、原因が不明確

特に、一連のUI動作をテストする場合、ViewInspectorでは難しいと感じることがあります。

なぜなら、ViewInspectorは主にビューの内部状態やプロパティを直接検証するために設計されており、ユーザーの操作を再現したアプリ全体の流れや画面遷移、アニメーションといった動的な動作を包括的にテストするには向いていないからです。

実際、ViewInspectorはUI全体の振る舞いをテストするというより、個別のコンポーネントが正しく動作するかを確認するためのユニットテスト向けのライブラリです。

そのため、アプリ全体のユーザーインターフェースや複雑なジェスチャー、アニメーションの動作を検証したい場合は、標準のXCUITestなど他のツールを併用する必要があります。

とはいえ、特定の画面やコンポーネントに焦点を当てたテストを素早く構築したい場合、ViewInspectorは非常に有用です。

導入コストも低く、コストパフォーマンスという面でも優れているため、使い方によっては十分な価値があると感じています。

まとめ

UIテストでは、iOSにおいてXCUITestが理想的ですが、ナレッジや経験が十分でないチームも多いのが現状です。

そんなチームにとって、ViewInspectorは手軽に導入できる有力な選択肢になるのではないでしょうか。

また、私たちは他にもViewInspectorを使ったテストを書いています。

それについてはまた別の機会にご紹介するつもりなので、引き続きお読みいただければ幸いです。

ここまでご精読いただき、ありがとうございました。

スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com