🗂
ViewInspectorによるSwiftUIのユニットテスト
はじめに
-
ViewInspector
はSwiftUIでUIのユニットテストを行うためのフレームワークです。 - 現在は公式からSwiftUIで「UIのユニットテスト」を行うフレームワークが用意されていないため、
ViewInspector
を使ってみます。 - 今回は便宜的に「UIのユニットテスト」と「UIテスト」と呼称して区別します。
- UIのユニットテスト:
- 各Viewのインスタンスを作成して、その範囲でUIや挙動をテストする。
- 例: iOSで振る舞いを見るテストを書いて安心しながら開発を続けよう
- UIテスト:
- 実際にアプリケーションを起動して、UIや挙動をテストする
- 例: XCUITestのつらさを乗り越えて、iOSアプリにUITestを導入する
- UIのユニットテスト:
参考
-
nalexn/ViewInspector
-
guide_*.md
に各使い方の記載があります。
-
- SwiftUI Testing With ViewInspector for iOS
-
Who said we cannot unit test SwiftUI views?
- 作者の制作秘話
GitHub
導入
-
ViewInspector
はSwift Package Managerを使って追加します。 - 本体のTargetではなく、
GuidTests
などテストを対象に追加します。
基本的な使い方
- 例としてユニットテストの対象のビューを下記とします。
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
として渡して、そこで更新された値が親ビューに反映されるのと同じイメージでしょうか。
- 親Viewの
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()
を挟む点や、sut
とview
の区別を意識するのがポイントでしょうか。
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)
}
その他
- その他
Styles
やGestures
、Alert
などのポップアップ系などに対応しておりREADME
が用意されています。- 現在の対応状況に関してはViewInspector readiness listを参照のこと。
- テスト初回は時間がかかりますが、2回目以降は短時間で済むのも嬉しい点です。
まとめ
- 以上のように
ViewInspector
を使ってSwiftUIでUIのユニットテストが書くことができます。 - 具体的なテスト内容として以下のようなテストを書くといいのかなと思っています。
- 1つのViewに対してプロパティやViewModelを設定し、それに応じて期待するViewクラスやデザインが表示されているか
- ボタンを押すなどアクション前後のプロパティの値やUIを確認
Discussion