---
title: SwiftUI/Combine ユニットテスト入門
tags:  #swift #swiftui #ios #combine #unittests  
author: [turara](https://www.docswell.com/user/turara)
site: [Docswell](https://www.docswell.com/)
thumbnail: https://bcdn.docswell.com/page/PLE1KZVJGD.jpg?width=480
description: YUMEMI.swift #9 〜テストと自動化〜（https://yumemi.connpass.com/event/183635/）で発表した資料です。 Qititaの記事版はこちら（https://qiita.com/turara/items/dd7bee391962f945256f）。
published: March 08, 21
canonical: https://www.docswell.com/s/turara/V5Q1MZ-swiftui-unittests
---
# Page. 1

![Page Image](https://bcdn.docswell.com/page/PLE1KZVJGD.jpg)

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

# Page. 2

![Page Image](https://bcdn.docswell.com/page/8GEWD93J26.jpg)

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

# Page. 3

![Page Image](https://bcdn.docswell.com/page/W47ZG9GE3Y.jpg)



# Page. 4

![Page Image](https://bcdn.docswell.com/page/KYJ6YKQ7VZ.jpg)

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

# Page. 5

![Page Image](https://bcdn.docswell.com/page/5GJ5GP2J48.jpg)

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

# Page. 6

![Page Image](https://bcdn.docswell.com/page/4LE3R3175P.jpg)

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

# Page. 7

![Page Image](https://bcdn.docswell.com/page/V8EDY4KEGD.jpg)

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

# Page. 8

![Page Image](https://bcdn.docswell.com/page/4V7P6M3J86.jpg)

テキストの中⾝を取得 // let text = try view .inspect() .vStack() .text(2) .string() XCTAssertEqual(text, &quot;3&quot;) ボタンタップを実⾏ // 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)

# Page. 9

![Page Image](https://bcdn.docswell.com/page/L2JV39NEQW.jpg)

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

# Page. 10

![Page Image](https://bcdn.docswell.com/page/Z5EG3Z57LR.jpg)

DI のために protocol 経由で ViewModel を持たせる // View struct SearchRepositoriesView&lt;ViewModel: ViewModelProtocol&gt;: 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&lt;Bool&gt; set var shouldShowErrorAlert: Bool { get set } var repositoryList: [GitHubRepo] { get } var moreSearchResultsExist: Bool { get } func send(event: SearchRepositoriesViewModelEvent) } として使うので が必要

# Page. 11

![Page Image](https://bcdn.docswell.com/page/V4JQ5LL7P5.jpg)

// テストコード 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(), &quot;Search More &quot; ) の ZStack { VStack { // index: 0 SearchBar(...) // index: 0 List { // index: 1 ForEach(...) { ... } // index: 0 if ... { Text(&quot;No results found...&quot;) } else if self.viewModel.moreResultsExist { Button(action: { ... }) { // index: 1 if self.isLoading { ActivityIndicator() } else { (Text(&quot;Search More &quot;) + // Target Text(self.lastQuery) .font(.headline) .bold() .italic() ) } } } } } if isLoading &amp;&amp; isListEmpty { ActivityIndicatorWithRectangle() } }

# Page. 12

![Page Image](https://bcdn.docswell.com/page/DK749D5E1Q.jpg)

&quot;Search More ...&quot; をタップすると Indicator が表⽰される

# Page. 13

![Page Image](https://bcdn.docswell.com/page/PLJ1KZ27GD.jpg)

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(&quot;Search More &quot;) + Text(self.lastQuery) .font(.headline) .bold() .italic() ) } } struct ActivityIndicator: UIViewRepresentable { func makeUIView( context: Context ) -&gt; UIActivityIndicatorView { let indicator = UIActivityIndicatorView() indicator.startAnimating() return indicator } ... }

# Page. 14

![Page Image](https://bcdn.docswell.com/page/8GJWD92726.jpg)

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

# Page. 15

![Page Image](https://bcdn.docswell.com/page/W4EZG94J3Y.jpg)

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

# Page. 16

![Page Image](https://bcdn.docswell.com/page/KY76YKGEVZ.jpg)

ViewInspectorが(まだ)できないこと 後ろの は 扱いで、取得できない // Text modifier let view = (Text(&quot;Search More &quot;) + 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

# Page. 17

![Page Image](https://bcdn.docswell.com/page/5G75GPX748.jpg)

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

# Page. 18

![Page Image](https://bcdn.docswell.com/page/V9J2Y6QJRX.jpg)

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? ... }

# Page. 19

![Page Image](https://bcdn.docswell.com/page/5DEYG9YEM9.jpg)

ViewModelのテスト stubModel = GitHubReposModelStub() viewModel = SearchRepositoriesViewModel(model: stubModel) let scheduler = TestScheduler.init(initialClock: 0) // t = 100 &quot;Swift&quot; query scheduler.schedule(after: 100) { self.viewModel.send(event: .didSearchButtonClicked(query: &quot;Swift&quot;)) } // t = 200 &quot;Python&quot; query scheduler.schedule(after: 200) { self.viewModel.send(event: .didSearchButtonClicked(query: &quot;Python&quot;)) } に に という という を発⾏ を発⾏

# Page. 20

![Page Image](https://bcdn.docswell.com/page/2VJNGLR78K.jpg)

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

# Page. 21

![Page Image](https://bcdn.docswell.com/page/GYE9W4ZE3P.jpg)

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

# Page. 22

![Page Image](https://bcdn.docswell.com/page/5GE8NVY7DR.jpg)

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

# Page. 23

![Page Image](https://bcdn.docswell.com/page/MLELP597R1.jpg)

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

# Page. 24

![Page Image](https://bcdn.docswell.com/page/54JM43V7W1.jpg)

ありがとうございました！

