ViewInspectorでSwiftUIのUnitTest入門
こんにちは、スペースマーケットでモバイルエンジニアをしている村田です。
スペースマーケットのゲストiOSプロジェクトはUnitTestが不十分(In/Outが分かりやすいヘルパー的なクラスのテストが若干あるだけ)で、カバレッジがかなり低い状況です。VPoE成原さん協力のもと品質担保するためにUnitTestちゃんと書いていこうぜ!ということでチャプター活動の時間を使いUT環境整備/ライブラリ選定/モブプロでUT実装を行なっています。
UI(SwiftUI)テストの為のライブラリとしてViewInspectorをプロジェクトへ試しに導入し、個人的に学習した基礎的な内容についての記事になります。
導入経緯
現状SwiftUIのUnitテスト標準フレームワークがなく(先日のWWDCでも特に発表なかったですね...)、AccessibilityIdentifierで一意のIDを付与してテスト対象のViewを取得する必要がありそうでした。ただ、テストのためにプロダクションコード汚したくないよね...という話になり有志のドキュメントをちらほら見かけるViewInspectorを試しに導入することにしました。
ViewInspectorとは
ViewInspectorとは、SwiftUIベースのUIテストを行うためのライブラリです。ViewInspectorを使用することで、テスト中にSwiftUIビューの状態やプロパティを取得/操作することができます。
テスト対象サンプル
サンプルとして以下のViewをテストします
onAppearなし | onAppear | ボタン等操作 |
---|---|---|
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が絡んだテストについて記事が書ければ🙏
最後に
スペースマーケットでは一緒に働く仲間を絶賛募集中です!
詳しくは以下をご確認の上ご応募ください。
スペースを簡単に貸し借りできるサービス「スペースマーケット」のエンジニアによる公式ブログです。 弊社採用技術スタックはこちら -> whatweuse.dev/company/spacemarket
Discussion