SwiftUI/Combine ユニットテスト入門

スライド概要

YUMEMI.swift #9 〜テストと自動化〜(https://yumemi.connpass.com/event/183635/)で発表した資料です。
Qititaの記事版はこちら(https://qiita.com/turara/items/dd7bee391962f945256f)。

profile-image

turara

@turara

作者について:

フリーランスエンジニア。iOS/Android/ReactNative

スライド一覧
シェア
埋め込む

作成日

2021-03-08 09:50:48

各ページのテキスト

1. SwiftUI/Combine ユニットテスト⼊⾨

2. ⾃⼰紹介 つらら ⼤阪府出⾝ ⼤学から京都。かれこれ 14 年ほど。 IT エンジニアになって約 4 年。 Twitter:@turara_engineer

3.

4. UIKitはユニットテストができる let viewController = ViewController() viewController.loadViewIfNeeded() let button = viewController .view .subviews .first(where: { $0 is UIButton }) XCTAssertNotNil(button) button!.sendActions(for: .touchUpInside)

5. SwiftUIには・・・ 公式のAPIがまだ⽤意されていない

6. Who said we cannot unit test SwiftUI views? https://nalexn.github.io/swiftui-unit-testing/

7. ViewInspector nalexn/ViewInspector Stars: 420 SwiftUI のビューをランタイムに検査してユニット テストを可能にしてくれるフレームワーク いまのところ唯⼀無⼆ 公式のフレームワークが出るまでスタンダードになりそう https://github.com/nalexn/ViewInspector

8. テキストの中⾝を取得 // let text = try view .inspect() .vStack() .text(2) .string() XCTAssertEqual(text, "3") ボタンタップを実⾏ // 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() カスタムビューを取得 // let sut = try view .inspect() ... .anyView() .view(CustomView.self) .actualView() XCTAssertTrue(sut.viewModel.isUserLoggedIn)

9. サンプル GitHub からリポジトリを検索する (よくあるやつ) https://github.com/turara/SwiftUIAndC ombineUnitTestsSample

10. DI のために 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) } として使うので が必要

11. // テストコード stubViewModel = SearchRepositoriesViewModelStub() view = SearchRepositoriesView( viewModel: stubViewModel ) stubViewModel.state = .ready stubViewModel.repositoryList = ... stubViewModel.moreResultsExist = true let text = try .zStack() .vStack(0) .list(1) .button(1) .text() view!.inspect() の の の位置 の位置 の位置 // Zstack index:0 // Vstack index:1 // List index:1 XCTAssertEqual( try text.string(), "Search More " ) の 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 { (Text("Search More ") + // Target Text(self.lastQuery) .font(.headline) .bold() .italic() ) } } } } } if isLoading && isListEmpty { ActivityIndicatorWithRectangle() } }

12. "Search More ..." をタップすると Indicator が表⽰される

13. 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 XCTAssertNoThrow( try button .view(ActivityIndicator.self) .actualView() ) (おまけ) が⼊っていないことも確認 // Text XCTAssertThrowsError( try button.text() ) Button(action: { ... }) { // index: 1 if self.isLoading { ActivityIndicator() // Target } else { (Text("Search More ") + Text(self.lastQuery) .font(.headline) .bold() .italic() ) } } struct ActivityIndicator: UIViewRepresentable { func makeUIView( context: Context ) -> UIActivityIndicatorView { let indicator = UIActivityIndicatorView() indicator.startAnimating() return indicator } ... }

14. Views are a function of state, not of a sequence of events いつかのWWDC?

15. SwiftUIではViewが状態の関数であることが 強制される → ユニットテストが書きやすい

16. ViewInspectorが(まだ)できないこと 後ろの は 扱いで、取得できない // Text modifier let view = (Text("Search More ") + Text(self.lastQuery)) let text = try view!.inspect().text() の状態を取得することができない // disabled button.disabled(true) try button.tap() XCTAssertTrue(viewModel.receivedEvents.isEmpty) が出ているかどうかを取得できない // Alert func test_alertShown_whenShouldShowErrorAlertTrue() { viewModel.state = .error viewModel.shouldShowErrorAlert = true ??? } https://github.com/nalexn/ViewInspector/blob/master/readiness.md

17. Entwine tcldr/Entwine Stars: 205 Combine のユーティリティライブラリ テスト⽤のユーティリティとして TestScheduler クラスが⽤意されている 他にもユーティリティライブラリはあるが、テスト 対応してるのは Entwine だけ https://github.com/tcldr/Entwine

18. ViewModelのテスト protocol ViewModelProtocol: ObservableObject { // var lastQuery: String? { get } // View func send(event: SearchRepositoriesViewModelEvent) ... } 最後に使ったクエリを取得できる 側からイベント通知をするためのメソッド class ViewModel: ViewModelProtocol { // Model private let reposModel: GitHubReposModelProtocol サーバーとの通信を⾏う 最後に使ったクエリは で監視対象 // @Published @Published private(set) var lastQuery: String? ... }

19. ViewModelのテスト 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")) } に に という という を発⾏ を発⾏

20. ViewModelのテスト テスト⽤の を作成 // subscriber let subscriber = scheduler.createTestableSubscriber(String?.self, Never.self) viewModel.$lastQuery.subscribe(subscriber) スケジューラを起動 // scheduler.resume() let expected: TestSequence<String?, Never> = [ (000, .subscription), (000, .input(nil)), (100, .input("Swift")), (200, .input("Python")), ] // XCTAssertEqual(subscriber.recordedOutput, expected) 受け取ったイベントと期待されるイベントを⽐較

21. お世話になっているコミュニティ

22. エンジニアと⼈⽣コミュニティ iOS エンジニアの堤修⼀さん  ( @shu223) エンジニアのための発信講座 フリーランスの裏事情 インタビュー動画 etc...

23. アプリ道場サロン iOS エンジニアのあきおさん (akio0911) iOS 特化型 読書会 課題チャレンジ etc...

24. ありがとうございました!