こちらの記事は、YUMEMI.swift #9 ~テストと自動化~で発表した内容をもとに書いたものです。
サンプルプロジェクトはこちらにあります。
SwiftUIには公式のテスト用APIが用意されていない
UIKitなら例えば以下のようにテストを行うことができます。
let viewController = ViewController()
// viewDidLoadを呼ぶためのメソッド
viewController.loadViewIfNeeded()
// subviewsからボタンを検索
let button = viewController
.view
.subviews
.first(where: { $0 is UIButton })
// 存在確認
XCTAssertNotNil(button)
// タップイベントを発火
button!.sendActions(for: .touchUpInside)
SwiftUIのViewがユニットテストできないなんて誰が言ったの?
検索してみると……
Who said we cannot unit test SwiftUI views?1
という記事がヒットしました。どうやらこの記事を書いた人がViewInspectorというテスト用のフレームワークを作成してくれているそうです。
現状では、SwiftUIのユニットテストができる唯一無二のフレームワークで、公式のフレームワークが出るまではスタンダードになっていきそうな予感がします。
ViewInspector
こんな感じのAPIが用意されています。
// ボタンタップを実行
let button = try view
.inspect()
.hStack()
.button(1)
try button.tap()
// onAppearをシミュレート
let list = try view
.inspect()
.list()
try list[5]
.view(RowItemView.self)
.callOnAppear()
実際に使ってみる
サンプルのプロジェクトはよくある、GitHubのリポジトリを検索してくるやつです。
こんな感じのUIで、Listの下に"Search More Swift"というButtonが表示されています。これが表示されているかどうかをユニットテストで確認していきます。
準備:DIのためにprotocol経由で参照を持たせる
テストしやすいようにViewにprotocol経由でViewModelを持たせます。
// View
struct SearchRepositoriesView<ViewModel: ViewModelProtocol>: View {
// ObservableObject protocolがassociatedtypeを持っているため
// protocolのまま型指定できないので、Generic Typeを利用する
@ObservedObject private var viewModel: ViewModel
...
}
// ViewModel
protocol ViewModelProtocol: ObservableObject {
var lastQuery: String? { get }
var state: ViewModelState { get }
// Binding<Bool>として使うのでsetが必要
var shouldShowErrorAlert: Bool { get set }
var repositoryList: [GitHubRepo] { get }
var moreSearchResultsExist: Bool { get }
func send(event: SearchRepositoriesViewModelEvent)
}
ViewInspectorを使って要素を取得する
注意点として、「子要素を複数持つ可能性のある要素」から子要素を取得するときには、子要素(例えばVStack)に対応したメソッド(vStack
)に、そのindex(親要素の中で何番目にあるか)を渡す必要があります。
// 対象のViewにInspectable protocolを追加
extension SearchRepositoriesView: Inspectable {}
...
// ViewModelのスタブを作成
stubViewModel = SearchRepositoriesViewModelStub()
// 依存性注入
view = SearchRepositoriesView(viewModel: stubViewModel)
stubViewModel.lastQuery = "Swift"
stubViewModel.state = .ready
stubViewModel.repositoryList = ...
stubViewModel.moreResultsExist = true
let text = try view!.inspect()
.zStack()
.vStack(0) // Zstackのindex:0の位置
.list(1) // Vstackのindex:1の位置
.button(1) // Listのindex:1の位置
.text()
XCTAssertEqual(try text.string(), "Search More Swift")
ZStack {
VStack { // index: 0
SearchBar(...) // index: 0
List { // index: 1
ForEach(...) { ... } // index: 0
if ... {
Text("No results found...")
} else if self.viewModel.moreResultsExist {
Button(action: { ... }) { // index: 1
if self.isLoading {
ActivityIndicator()
} else {
// Target!!!
(Text("Search More ") +
Text(self.lastQuery)
.font(.headline)
.bold()
.italic()
)
}
}
}
}
}
if isLoading && isListEmpty {
ActivityIndicatorWithRectangle()
}
}
カスタムViewの存在確認
今度はTextではなく、カスタムViewが表示されていることを確認します。
先ほどの"Search More Swift" Buttonをタップすると、インディケーターが周りだします。
stubViewModel.state = .loading
stubViewModel.repositoryList = ...
stubViewModel.moreResultsExist = true
let button = try view!.inspect()
.zStack()
.vStack(0) // Zstackのindex:0の位置
.list(1) // Vstackのindex:1の位置
.button(1) // Listのindex:1の位置
// カスタムViewを取り出す
// viewメソッドにstructのselfを渡したあと、actualViewメソッドで
// ActivityIndicatorのインスタンスが取得できる
XCTAssertNoThrow(
try button
.view(ActivityIndicator.self)
.actualView()
)
// (おまけ)Textが入っていないことも確認
XCTAssertThrowsError(try button.text())
struct ActivityIndicator: UIViewRepresentable {
func makeUIView(
context: Context
) -> UIActivityIndicatorView {
let indicator
= UIActivityIndicatorView()
indicator.startAnimating()
return indicator
}
...
}
Button(action: { ... }) {
if self.isLoading {
ActivityIndicator() // Target!
} else {
(Text("Search More ") +
Text(self.lastQuery)
.font(.headline)
.bold()
.italic()
)
}
}
SwiftUIのユニットテスト → やりやすい
SwiftUIではViewが状態の関数であることが強制されるおかげで、何をテストすべきかがわかりやすいため、ユニットテストが書きやすいです。
Combineのユニットテスト
CombineのユーティリティフレームワークであるEntwineを使います。
テスト補助用のクラスとしてTestSchedulerが用意されているので、これを使ってViewModelのテストをやってみます。
protocol ViewModelProtocol: ObservableObject {
// 最後に使ったクエリを取得できる
var lastQuery: String? { get }
// View側からイベント通知をするためのメソッド
func send(event: SearchRepositoriesViewModelEvent)
...
}
class ViewModel: ViewModelProtocol {
// サーバーとの通信を行うModel
private let reposModel: GitHubReposModelProtocol
// 最後に使ったクエリは@Publishedをつけて、SwiftUI Viewの監視対象
@Published private(set) var lastQuery: String?
...
}
スケジューラーにイベントの通知をスケジューリングしていきます。
stubModel = GitHubReposModelStub()
viewModel = SearchRepositoriesViewModel(model: stubModel)
let scheduler = TestScheduler.init(initialClock: 0)
// t = 100に "Swift" という queryを発行
scheduler.schedule(after: 100) {
self.viewModel.send(event: .didSearchButtonClicked(query: "Swift"))
}
// t = 200に "Python" という queryを発行
scheduler.schedule(after: 200) {
self.viewModel.send(event: .didSearchButtonClicked(query: "Python"))
}
テスト用のsubscriberを作成して、テスト対象であるviewModel.$lastQueryにsubscribeします。
// テスト用のsubscriberを作成
let subscriber = scheduler.createTestableSubscriber(String?.self, Never.self)
viewModel.$lastQuery.subscribe(subscriber)
スケジューラーを起動すると、テスト用のsubscriberがイベントを受け取り、TestSequenceとして記録してくれます。
// スケジューラを起動
scheduler.resume()
let expected: TestSequence<String?, Never> = [
(000, .subscription),
(000, .input(nil)),
(100, .input("Swift")),
(200, .input("Python")),
]
// 受け取ったイベントと期待されるイベントを比較
XCTAssertEqual(subscriber.recordedOutput, expected)
おわりに
意外と簡単にユニットテストが書けたのではないでしょうか?
どんどんテストを書いていきましょう!
参考
UIViewControllerをXCTestでUnitテストする
Who said we cannot unit test SwiftUI views?
ViewInspector
Entwine
-
階層化されたSwiftUIのViewから、どうやって中身を取り出していくかという苦労が綴られていて面白いです。 ↩