Slide 1

Slide 1 text

Combine を使ったコードの テストを Scheduler で 操る方法とその仕組み iOSDC Japan 2021

Slide 2

Slide 2 text

自己紹介 アイカワ iOS アプリエンジニア & 社会人2 年目 Sansan @kalupas0930 最近飼ったポメラニアンを溺愛 2

Slide 3

Slide 3 text

アジェンダ ViewModel + Combine の基本的なコード そのコードをテストするための基本的な方法 combine-schedulers でテストコードの時間を操る方法 combine-schedulers の仕組み 3 Combine とテストについて、こちらの流れで話していきます

Slide 4

Slide 4 text

説明で利用するアプリ https://github.com/kalupas226/iOSDC2021SampleApp 4

Slide 5

Slide 5 text

Search ボタンをタップしたら検索するアプリ 5

Slide 6

Slide 6 text

Model のコード( Response の構造体 ) 6 GitHubRepository.swift / Response body struct GitHubRepository: Decodable, Identifiable, Equatable { let id: Int let fullName: String } extension GitHubRepository { private enum CodingKeys: String, CodingKey { case id case fullName = "full_name" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(Int.self, forKey: .id) fullName = try container.decode(String.self, forKey: .fullName) } } struct GitHubRepositoryList: Decodable { let items: [GitHubRepository] } { "items": [ { "id": 3081286, "full_name": "iOSDC" }, { "id": 3081287, "full_name": "iOSDC2021" }, // ... ] }

Slide 7

Slide 7 text

Model のコード( API 通信部分 ) 7 GitHubAPIClient.swift // interface struct GitHubAPIClient { var searchRepository: (String) -> AnyPublisher } // 実装部分 extension GitHubAPIClient { // static で作成することによって、GitHubAPIClient を必要とするコード部分で `.live` という形式で扱えるようになる // ex) GitHubListView(viewModel: GitHubViewModel(gitHubAPIClient: .live)) static let live = Self( searchRepository: { searchWord in URLSession.shared.dataTaskPublisher( for: URL(string: "https://api.github.com/search/repositories?q=\(searchWord)")! ) .map { $0.data } .decode(type: GitHubRepositoryList.self, decoder: JSONDecoder()) .replaceError(with: .init(items: [])) // エラーハンドリングは本質ではないため、空の配列を返すようにする .eraseToAnyPublisher() } ) }

Slide 8

Slide 8 text

ViewModel のコード 8 GitHubViewModel.swift final class GitHubViewModel: ObservableObject { private let gitHubAPIClient: GitHubAPIClient private var cancellables: Set = [] @Published var searchWord = "" @Published var repositories: [GitHubRepository] = [] init(gitHubAPIClient: GitHubAPIClient) { self.gitHubAPIClient = gitHubAPIClient } func searchButtonTapped() { gitHubAPIClient .searchRepository(searchWord) .sink { [weak self] in self?.repositories = $0.items } .store(in: &cancellables) } }

Slide 9

Slide 9 text

View のコード 9 GitHubListView.swift struct GitHubListView: View { @ObservedObject private var viewModel: GitHubViewModel init(viewModel: GitHubViewModel) { self.viewModel = viewModel } var body: some View { VStack { ... } .padding() } }

Slide 10

Slide 10 text

View のコード( body 部分 ) 10 GitHubListView.swift var body: some View { VStack { HStack { TextField("Search repository", text: $viewModel.searchWord) .autocapitalization(.none) .textFieldStyle(RoundedBorderTextFieldStyle()) Button("Search") { viewModel.searchButtonTapped() } } List { ForEach(viewModel.repositories) { repository in Text(repository.fullName) } } Spacer() } .padding() }

Slide 11

Slide 11 text

SearchButton を押した時のテストコード 11 iOSDC2021SampleAppTests.swift func testSearchButtonTapped() throws { var cancellables: Set = [] var actualRepositories: [GitHubRepository] = [] let expectedRepositories: [GitHubRepository] = (1...3).map { .init(id: $0, fullName: "Repository \($0)") } let viewModel = GitHubViewModel( gitHubAPIClient: .init( searchRepository: { _ in Just( GitHubRepositoryList(items: expectedRepositories) ) .eraseToAnyPublisher() } ) ) // assertion... }

Slide 12

Slide 12 text

SearchButton を押した時のテストコード 12 iOSDC2021SampleAppTests.swift let viewModel = GitHubViewModel(...) // ViewModel の @Published repositories の変化を監視する viewModel.$repositories .sink { actualRepositories = $0 } .store(in: &cancellables) func testSearchButtonTapped() throws { var cancellables: Set = [] var actualRepositories: [GitHubRepository] = [] let expectedRepositories: [GitHubRepository] = (1...3).map { .init(id: $0, fullName: "Repository \($0)") } XCTAssertEqual(actualRepositories, []) viewModel.searchWord = "search word" viewModel.searchButtonTapped() XCTAssertEqual(actualRepositories, expectedRepositories) }

Slide 13

Slide 13 text

簡単なテストが成功しましたが、 コードに問題があるため修正します

Slide 14

Slide 14 text

publish values from the main thread の警告 14 GitHubViewModel.swift final class GitHubViewModel: ObservableObject { private let gitHubAPIClient: GitHubAPIClient private var cancellables: Set = [] @Published var searchWord = "" @Published var repositories: [GitHubRepository] = [] init(gitHubAPIClient: GitHubAPIClient) { self.gitHubAPIClient = gitHubAPIClient } func searchButtonTapped() { gitHubAPIClient .searchRepository(searchWord) // Publishing changes from background threads is not allowed; // make sure to publish values from the main thread (via operators like receive(on:)) on model updates. .sink { [weak self] in self?.repositories = $0.items } .store(in: &cancellables) } }

Slide 15

Slide 15 text

DispatchQueue.main で receive するようにして修正 15 GitHubViewModel.swift .receive(on: DispatchQueue.main) .sink { [weak self] in self?.repositories = $0.items } final class GitHubViewModel: ObservableObject { private let gitHubAPIClient: GitHubAPIClient private var cancellables: Set = [] @Published var searchWord = "" @Published var repositories: [GitHubRepository] = [] init(gitHubAPIClient: GitHubAPIClient) { self.gitHubAPIClient = gitHubAPIClient } func searchButtonTapped() { gitHubAPIClient .searchRepository(searchWord) .store(in: &cancellables) } }

Slide 16

Slide 16 text

しかし、今度はテストが壊れてしまいます

Slide 17

Slide 17 text

壊れてしまったテスト 17 iOSDC2021SampleAppTests.swift func testSearchButtonTapped() throws { var cancellables: Set = [] var actualRepositories: [GitHubRepository] = [] let expectedRepositories: [GitHubRepository] = (1...3).map { .init(id: $0, fullName: "Repository \($0)") } let viewModel = GitHubViewModel(...) viewModel.$repositories .sink { actualRepositories = $0 } .store(in: &cancellables) XCTAssertEqual(actualRepositories, []) viewModel.searchWord = "search word" viewModel.searchButtonTapped() // XCTAssertEqual failed: ("[]") is not equal to // ("[GitHubRepository(id: 1, fullName: "Repository 1"), ...]) XCTAssertEqual(actualRepositories, expectedRepositories) }

Slide 18

Slide 18 text

一定時間 wait するようにして修正 18 iOSDC2021SampleAppTests.swift _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.1) // 0.1 秒は適当 func testSearchButtonTapped() throws { var cancellables: Set = [] var repositories: [GitHubRepository] = [] let expectedRepositories: [GitHubRepository] = (1...3).map { .init(id: $0, fullName: "Repository \($0)") } let viewModel = GitHubViewModel(...) viewModel.$repositories .sink { repositories = $0 } .store(in: &cancellables) XCTAssertEqual(repositories, []) viewModel.searchWord = "search word" viewModel.searchButtonTapped() XCTAssertEqual(repositories, expectedRepositories) }

Slide 19

Slide 19 text

現時点で微妙なテスト感が漂ってきましたが、 もう少しアプリの機能を実用的にしてみましょう

Slide 20

Slide 20 text

インクリメンタルサーチ機能の追加 20 GitHubViewModel.swift final class GitHubViewModel: ObservableObject { private let gitHubAPIClient: GitHubAPIClient private var cancellables: Set = [] @Published var searchWord = "" @Published var repositories: [GitHubRepository] = [] init(gitHubAPIClient: GitHubAPIClient) { self.gitHubAPIClient = gitHubAPIClient $searchWord // イニシャライザ内で searchWord を監視し、文字が入力されたら API 通信を行う .sink { [weak self] in guard let self = self else { return } gitHubAPIClient .searchRepository($0) .receive(on: DispatchQueue.main) .sink { self.repositories = $0.items } .store(in: &self.cancellables) } .store(in: &cancellables) } }

Slide 21

Slide 21 text

ついでにローディングを検知するための状態も追加 21 GitHubViewModel.swift final class GitHubViewModel: ObservableObject { // この isLoading を View で利用して、true の場合はローディング用の Text を表示するようにする @Published var isLoading = false init(gitHubAPIClient: GitHubAPIClient) { self.gitHubAPIClient = gitHubAPIClient $searchWord .sink { [weak self] in // ここで isLoading を変更する } .store(in: &cancellables) } }

Slide 22

Slide 22 text

完成したインクリメンタルサーチ機能 22

Slide 23

Slide 23 text

機能を変更したためテストコードで エラーが発生します

Slide 24

Slide 24 text

エラーが発生しているコード 24 iOSDC2021SampleAppTests.swift viewModel.searchButtonTapped() // searchButtonTapped を削除したためコンパイルエラーが発生 func testSearchButtonTapped() throws { var cancellables: Set = [] var actualRepositories: [GitHubRepository] = [] let expectedRepositories: [GitHubRepository] = (1...3).map { .init(id: $0, fullName: "Repository \($0)") } let viewModel = GitHubViewModel(...) viewModel.$repositories .sink { actualRepositories = $0 } .store(in: &cancellables) XCTAssertEqual(actualRepositories, []) viewModel.searchWord = "search word" _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.1) XCTAssertEqual(actualRepositories, expectedRepositories) }

Slide 25

Slide 25 text

問題のある部分をコメントアウトして修正 25 iOSDC2021SampleAppTests.swift func testInputSearchWords() throws { // 命名を修正 // viewModel.searchButtonTapped() ここをコメントアウト var cancellables: Set = [] var actualRepositories: [GitHubRepository] = [] let expectedRepositories: [GitHubRepository] = (1...3).map { .init(id: $0, fullName: "Repository \($0)") } let viewModel = GitHubViewModel(...) viewModel.$repositories .sink { actualRepositories = $0 } .store(in: &cancellables) XCTAssertEqual(actualRepositories, []) viewModel.searchWord = "search word" _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.1) XCTAssertEqual(actualRepositories, expectedRepositories) }

Slide 26

Slide 26 text

0.3s 以上入力されなかったら検索するようにする 26 GitHubViewModel.swift $searchWord .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .sink { [weak self] in guard let self = self else { return } self.isLoading = true gitHubAPIClient .searchRepository($0) .receive(on: DispatchQueue.main) .sink { self.repositories = $0.items self.isLoading = false } .store(in: &self.cancellables) } .store(in: &cancellables)

Slide 27

Slide 27 text

この変更によって再びテストが壊れる 27 iOSDC2021SampleAppTests.swift func testInputSearchWords() throws { var cancellables: Set = [] var actualRepositories: [GitHubRepository] = [] let expectedRepositories: [GitHubRepository] = (1...3).map { .init(id: $0, fullName: "Repository \($0)") } let viewModel = GitHubViewModel(...) viewModel.$repositories .sink { actualRepositories = $0 } .store(in: &cancellables) XCTAssertEqual(actualRepositories, []) viewModel.searchWord = "search word" _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.1) XCTAssertEqual(actualRepositories, expectedRepositories) }

Slide 28

Slide 28 text

wait する秒数を伸ばすとテストが成功する 28 _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.33) func testInputSearchWords() throws { var cancellables: Set = [] var actualRepositories: [GitHubRepository] = [] let expectedRepositories: [GitHubRepository] = (1...3).map { .init(id: $0, fullName: "Repository \($0)") } let viewModel = GitHubViewModel(...) viewModel.$repositories .sink { actualRepositories = $0 } .store(in: &cancellables) XCTAssertEqual(actualRepositories, []) viewModel.searchWord = "search word" XCTAssertEqual(actualRepositories, expectedRepositories) }

Slide 29

Slide 29 text

テストの問題点😕 それらしい秒数を待つようにしていること debounce で 0.3s 、 receive(on: DispatchQueue.main) も指定しているので、 とりあえず 0.33s という適当な時間 wait している CI など実行環境によっては、テストが失敗する可能性もある wait で指定している秒数は実際に時間を消費するものであること 0.33s wait するのであれば、テストの実行時間も同じくらい増える 小さな問題かもしれないが、チリツモでテストの総実行時間が長くなる 29 ` ` ` ` ` ` ` `

Slide 30

Slide 30 text

combine-schedulers を使って改善していきます 30

Slide 31

Slide 31 text

combine-schedulers とは? Point-Free が提供するライブラリ Combine の処理をよりテストしやすく、より汎用性の高いものにするための Scheduler Combine が提供する Scheduler protocol に準拠する Scheduler 群が用意されている AnyScheduler TestScheduler ImmediateScheduler Animated schedulers FailingScheduler UIScheduler Publishers.Timer 31 ` `

Slide 32

Slide 32 text

combine-schedulers とは? Point-Free が提供するライブラリ Combine の処理をよりテストしやすく、より汎用性の高いものにするための Scheduler Combine が提供する Scheduler protocol に準拠する Scheduler 群が用意されている AnyScheduler TestScheduler ImmediateScheduler Animated schedulers FailingScheduler UIScheduler Publishers.Timer 32 ` `

Slide 33

Slide 33 text

先ほどのコードの ViewModel に combine- schedulers を導入して、使い方を見ていきます (ライブラリ自体の導入方法は省略します)

Slide 34

Slide 34 text

元々の ViewModel 34 GitHubViewModel.swift final class GitHubViewModel: ObservableObject { private let gitHubAPIClient: GitHubAPIClient private var cancellables: Set = [] @Published var searchWord = "" // ... init(gitHubAPIClient: GitHubAPIClient) { self.gitHubAPIClient = gitHubAPIClient $searchWord .sink { [weak self] in // API 通信処理など } .store(in: &cancellables) } }

Slide 35

Slide 35 text

combine-schedulers の Scheduler を導入 35 GitHubViewModel.swift import CombineSchedulers final class GitHubViewModel: ObservableObject { private let gitHubAPIClient: GitHubAPIClient // combine-schedulers の AnyScheduler については詳しく説明しないが、これにより不要な Generics を導入する必要がなくなっている private let scheduler: AnySchedulerOf private var cancellables: Set = [] @Published var searchWord = "" init(gitHubAPIClient: GitHubAPIClient, scheduler: AnySchedulerOf) { self.gitHubAPIClient = gitHubAPIClient self.scheduler = scheduler $searchWord .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .sink { [weak self] in // API 通信処理など } .store(in: &cancellables) } }

Slide 36

Slide 36 text

ViewModel で scheduler を利用している部分 36 ` ` GitHubViewModel.swift $searchWord .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .sink { [weak self] in guard let self = self else { return } self.isLoading = true gitHubAPIClient .searchRepository($0) .receive(on: DispatchQueue.main) .sink { self.repositories = $0.items self.isLoading = false } .store(in: &self.cancellables) } .store(in: &cancellables)

Slide 37

Slide 37 text

combine-shcedulers の Scheduler を適用 37 GitHubViewModel.swift .debounce(for: .milliseconds(300), scheduler: scheduler) .receive(on: scheduler) $searchWord .sink { [weak self] in guard let self = self else { return } self.isLoading = true gitHubAPIClient .searchRepository($0) .sink { self.repositories = $0.items self.isLoading = false } .store(in: &self.cancellables) } .store(in: &cancellables)

Slide 38

Slide 38 text

Preview の ViewModel initialize 部分を修正 38 GitHubListView.swift struct GitHubListView_Previews: PreviewProvider { static var previews: some View { GitHubListView( viewModel: GitHubViewModel( gitHubAPIClient: .init( searchRepository: { _ in Just( GitHubRepositoryList( items: (1...40).map { .init(id: $0, fullName: "Repository \($0)") } ) ) .eraseToAnyPublisher() } ), // AnySchedulerOf の型になるように eraseToAnyScheduler する // Combine の eraseToAnyPublisher などと同じようなもので、Scheduler 版の type eraser だと思えば問題ない scheduler: DispatchQueue.main.eraseToAnyScheduler() ) ) } }

Slide 39

Slide 39 text

scheduler 指定部分をより簡潔なものに修正 39 ` ` GitHubListView.swift // combine-schedulers に DispatchQueue.main.eraseToAnyScheduler() を表す static なプロパティがある scheduler: .main struct GitHubListView_Previews: PreviewProvider { static var previews: some View { GitHubListView( viewModel: GitHubViewModel( gitHubAPIClient: .init( searchRepository: { _ in Just( GitHubRepositoryList( items: (1...40).map { .init(id: $0, fullName: "Repository \($0)") } ) ) .eraseToAnyPublisher() } ), ) ) } }

Slide 40

Slide 40 text

これで今までと同じようにアプリ本体が動作します (エントリーポイントのコードの修正は省略)

Slide 41

Slide 41 text

次に combine-schedulers を導入した 最大の目的であるテストを修正していきます

Slide 42

Slide 42 text

修正前のテストコード 42 iOSDC2021SampleAppTests.swift func testInputSearchWords() throws { var cancellables: Set = [] var actualRepositories: [GitHubRepository] = [] let expectedRepositories: [GitHubRepository] = (1...3).map { .init(id: $0, fullName: "Repository \($0)") } let viewModel = GitHubViewModel( gitHubAPIClient: .init( searchRepository: { _ in Just( GitHubRepositoryList(items: expectedRepositories) ) .eraseToAnyPublisher() } ) ) // ... }

Slide 43

Slide 43 text

引数不足によりテストが動作しなくなっている 43 iOSDC2021SampleAppTests.swift func testInputSearchWords() throws { var cancellables: Set = [] var actualRepositories: [GitHubRepository] = [] let expectedRepositories: [GitHubRepository] = (1...3).map { .init(id: $0, fullName: "Repository \($0)") } let viewModel = GitHubViewModel( gitHubAPIClient: .init( searchRepository: { _ in Just( GitHubRepositoryList(items: expectedRepositories) ) .eraseToAnyPublisher() } ) // Missing argument for parameter 'scheduler' in call ) // ... }

Slide 44

Slide 44 text

テストコードに scheduler を追加していく 44 ` ` iOSDC2021SampleAppTests.swift import CombineScheduler func testInputSearchWords() throws { var cancellables: Set = [] var actualRepositories: [GitHubRepository] = [] let expectedRepositories: [GitHubRepository] = (1...3).map { .init(id: $0, fullName: "Repository \($0)") } let scheduler = DispatchQueue.test // 型は TestSchedulerOf let viewModel = GitHubViewModel( gitHubAPIClient: .init( searchRepository: { _ in Just( GitHubRepositoryList(items: expectedRepositories) ) .eraseToAnyPublisher() } }, scheduler: scheduler.eraseToAnyScheduler // AnyScheduler 型になるように type eraser する ) // ... }

Slide 45

Slide 45 text

TestScheduler を使ってテストコードを改善する 45 iOSDC2021SampleAppTests.swift func testInputSearchWords() throws { let scheduler = DispatchQueue.test let viewModel = GitHubViewModel( gitHubAPIClient: .init(...), scheduler: scheduler.eraseToAnyScheduler() ) viewModel.$repositories .sink { actualRepositories = $0 } .store(in: &cancellables) XCTAssertEqual(actualRepositories, []) viewModel.searchWord = "search word" _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.33) XCTAssertEqual(actualRepositories, expectedRepositories) }

Slide 46

Slide 46 text

TestScheduler を使ってテストコードを改善する 46 iOSDC2021SampleAppTests.swift // _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.33) func testInputSearchWords() throws { let scheduler = DispatchQueue.test let viewModel = GitHubViewModel( gitHubAPIClient: .init(...), scheduler: scheduler.eraseToAnyScheduler() ) viewModel.$repositories .sink { actualRepositories = $0 } .store(in: &cancellables) XCTAssertEqual(actualRepositories, []) viewModel.searchWord = "search word" XCTAssertEqual(actualRepositories, expectedRepositories) }

Slide 47

Slide 47 text

scheduler.advance で正確な秒数を進める 47 ` ` iOSDC2021SampleAppTests.swift // _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.33) scheduler.advance(by: 0.3) // ⏰ 正確な時間を指定することができる func testInputSearchWords() throws { let scheduler = DispatchQueue.test let viewModel = GitHubViewModel( gitHubAPIClient: .init(...), scheduler: scheduler.eraseToAnyScheduler() ) viewModel.$repositories .sink { actualRepositories = $0 } .store(in: &cancellables) XCTAssertEqual(actualRepositories, []) viewModel.searchWord = "search word" XCTAssertEqual(actualRepositories, expectedRepositories) }

Slide 48

Slide 48 text

debounce(0.3s) を満たせないようにする 48 ` ` iOSDC2021SampleAppTests.swift // _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.33) scheduler.advance(by: 0.29) func testInputSearchWords() throws { let scheduler = DispatchQueue.test let viewModel = GitHubViewModel( gitHubAPIClient: .init(...), scheduler: scheduler.eraseToAnyScheduler() ) viewModel.$repositories .sink { actualRepositories = $0 } .store(in: &cancellables) XCTAssertEqual(actualRepositories, []) viewModel.searchWord = "search word" XCTAssertEqual(actualRepositories, expectedRepositories) }

Slide 49

Slide 49 text

テストの実行時間も改善🎉 49

Slide 50

Slide 50 text

combine-schedulers 導入前のテストの問題点 それらしい秒数を待つようにしていること debounce で 0.3s 、 receive(on: DispatchQueue.main) も指定しているので、 とりあえず 0.33s という適当な時間 wait している wait で指定している秒数は実際に時間を消費するものであること 0.33s wait するのであれば、テストの実行時間も同じくらい増える 小さな問題かもしれないが、チリツモでテストの総実行時間が長くなる 50 ` ` ` ` ` ` ` `

Slide 51

Slide 51 text

combine-schedulers でテストの問題点が改善された それらしい秒数を待つようにしていること debounce で 0.3s 、 receive(on: DispatchQueue.main) も指定しているので、 とりあえず 0.33s という適当な時間 wait している scheduler.advance(by:) で正確な時間を進めることができるようになった wait で指定している秒数は実際に時間を消費するものであること 0.33s wait するのであれば、テストの実行時間も同じくらい増える 小さな問題かもしれないが、チリツモでテストの総実行時間が長くなる scheduler.advance(by:) でテストの実際の実行時間に影響を与えることなく時間を 進めることができるようになった 51 ` ` ` ` ` ` ` ` ` ` ` `

Slide 52

Slide 52 text

無事テストの問題点を改善できたので、 combine-schedulers の仕組みを見ていきます 難しい部分も多いため、不明点はぜひ ask the speaker での質問もお待ちしています 🔈

Slide 53

Slide 53 text

debounce , receive の引数の scheduler 53 ` ` ` ` ` ` いずれも Scheduler に準拠した S という型を scheduler の引数としている ` ` ` ` ` ` func debounce( scheduler: S, ) -> Publishers.Debounce where S : Scheduler for dueTime: S.SchedulerTimeType.Stride, options: S.SchedulerOptions? = nil func receive( on scheduler: S, ) -> Publishers.ReceiveOn where S : Scheduler options: S.SchedulerOptions? = nil

Slide 54

Slide 54 text

Scheduler は多数の operator で利用されている 54 ` ` 時間やスレッドを伴うような Combine operator で利用されている Just(1) .subscribe(on: <#T##Scheduler#>) .receive(on: <#T##Scheduler#>) .delay(for: ..., scheduler: <#T##Scheduler#>) .timeout(..., scheduler: <#T##Scheduler#>) .throttle(for: ..., scheduler: <#T##Scheduler#>, latest: ...) .debounce(for: ..., scheduler: <#T##Scheduler#>)

Slide 55

Slide 55 text

Scheduler protocol の中身 55 ` ` public protocol Scheduler { associatedtype SchedulerTimeType : Strideable where Self.SchedulerTimeType.Stride : SchedulerTimeIntervalConvertible associatedtype SchedulerOptions var now: Self.SchedulerTimeType { get } var minimumTolerance: Self.SchedulerTimeType.Stride { get } func schedule(options: Self.SchedulerOptions?, _ action: @escaping () -> Void) func schedule( after date: Self.SchedulerTimeType, tolerance: Self.SchedulerTimeType.Stride, options: Self.SchedulerOptions?, _ action: @escaping () -> Void ) func schedule( after date: Self.SchedulerTimeType, interval: Self.SchedulerTimeType.Stride, tolerance: Self.SchedulerTimeType.Stride, options: Self.SchedulerOptions?, _ action: @escaping () -> Void ) -> Cancellable }

Slide 56

Slide 56 text

SchedulerTimeType と SchedulerOptions SchedulerTimeType Scheduler で 時間を測定するために利用される型 時間を操るために Strideable に準拠している必要がある Strideable に準拠している型(例は Double )は stride(from:to:by) などで利用できる stride(from: 0.0, to: .pi * 2, by: .pi / 2) SchedulerOptions Scheduler が受け付ける option を表すタイプ Scheduler ごとに自由に定義することができる 56 ` ` ` ` associatedtype SchedulerTimeType : Strideable where Self.SchedulerTimeType.Stride : SchedulerTimeIntervalConvertible ` ` ` ` ` ` ` ` ` ` ` ` associatedtype SchedulerOptions ` `

Slide 57

Slide 57 text

Scheduler protocol の中身 57 ` ` public protocol Scheduler { var now: Self.SchedulerTimeType { get } var minimumTolerance: Self.SchedulerTimeType.Stride { get } associatedtype SchedulerTimeType : Strideable where Self.SchedulerTimeType.Stride : SchedulerTimeIntervalConvertible associatedtype SchedulerOptions func schedule(options: Self.SchedulerOptions?, _ action: @escaping () -> Void) func schedule( after date: Self.SchedulerTimeType, tolerance: Self.SchedulerTimeType.Stride, options: Self.SchedulerOptions?, _ action: @escaping () -> Void ) func schedule( after date: Self.SchedulerTimeType, interval: Self.SchedulerTimeType.Stride, tolerance: Self.SchedulerTimeType.Stride, options: Self.SchedulerOptions?, _ action: @escaping () -> Void ) -> Cancellable }

Slide 58

Slide 58 text

now と minimumTolerance now Scheduler の現在の時間を表す minimumTolerance Scheduler が行う処理のスケジューリングにどのくらいの余裕を持たせるかを表す 58 ` ` ` ` var now: Self.SchedulerTimeType { get } ` ` var minimumTolerance: Self.SchedulerTimeType.Stride ` `

Slide 59

Slide 59 text

Scheduler protocol の中身 59 ` ` public protocol Scheduler { func schedule(options: Self.SchedulerOptions?, _ action: @escaping () -> Void) func schedule( after date: Self.SchedulerTimeType, tolerance: Self.SchedulerTimeType.Stride, options: Self.SchedulerOptions?, _ action: @escaping () -> Void ) func schedule( after date: Self.SchedulerTimeType, interval: Self.SchedulerTimeType.Stride, tolerance: Self.SchedulerTimeType.Stride, options: Self.SchedulerOptions?, _ action: @escaping () -> Void ) -> Cancellable associatedtype SchedulerTimeType : Strideable where Self.SchedulerTimeType.Stride : SchedulerTimeIntervalConvertible associatedtype SchedulerOptions var now: Self.SchedulerTimeType { get } var minimumTolerance: Self.SchedulerTimeType.Stride { get } }

Slide 60

Slide 60 text

Scheduler が保持する3 つの function 60 ` ` // 受け取った処理をすぐにスケジューリングするためのもの func schedule(options: Self.SchedulerOptions?, _ action: @escaping () -> Void) // ある処理が遅れて実行されるようにスケジューリングするためのもの func schedule( after date: Self.SchedulerTimeType, tolerance: Self.SchedulerTimeType.Stride, options: Self.SchedulerOptions?, _ action: @escaping () -> Void ) // 繰り返しの interval で実行される処理をスケジューリングするためのもの func schedule( after date: Self.SchedulerTimeType, interval: Self.SchedulerTimeType.Stride, tolerance: Self.SchedulerTimeType.Stride, options: Self.SchedulerOptions?, _ action: @escaping () -> Void ) -> Cancellable

Slide 61

Slide 61 text

これらを満たして Scheduler protocol に準拠した Scheduler を作成すれば、 recevie(on:) などで 独自の Scheduler を利用できるようになる ` ` ` `

Slide 62

Slide 62 text

基本的には独自に Scheduler は定義しない DispatchQueue ImmediateScheduler RunLoop OperationQueue 62 ` ` Apple が提供する Scheduler に準拠する型 ` `

Slide 63

Slide 63 text

DispatchQueue の例 63 利用方法 // 受け取った処理をすぐにスケジューリングする schedule() DispatchQueue.main.schedule { print(" できるだけ早く実行する function") } // // ある処理が遅れて実行されるようにスケジューリングする schedule(after:) DispatchQueue.main.schedule(after: .init(.now() + 1)) { print(" 少し遅らせて実行する function") } var cancellables: Set = [] // 繰り返しの interval で実行される処理をスケジューリングする schedule(after:interval) DispatchQueue.main.schedule(after: .init(.now()), interval: 1) { print("interval で実行する function") } .store(in: &cancellables)

Slide 64

Slide 64 text

テストの時間を操れるような 独自の Scheduler を定義して combine-shcedulers の仕組みを探っていく

Slide 65

Slide 65 text

Test 用 Scheduler 作成の目標と道のり どんな Scheduler を作成したいか Test 内の時間を自由に操れる combine-schedulers のようなもの 道のり Scheduler protocol に準拠した MyTestScheduler を作成する 2 つの associatedtype 2 つの property を満たす 3 つの function を満たす 受け取った処理をすぐにスケジューリングするためのもの ある処理が遅れて実行されるようにスケジューリングするためのもの 繰り返しの interval で実行される処理をスケジューリングするためのもの 65 ` ` ` `

Slide 66

Slide 66 text

Test 用 Scheduler 作成の目標と道のり どんな Scheduler を作成したいか Test 内の時間を自由に操れる combine-schedulers のようなもの 道のり Scheduler protocol に準拠した MyTestScheduler を作成する 2 つの associatedtype 2 つの property を満たす 3 つの function を満たす 受け取った処理をすぐにスケジューリングするためのもの ある処理が遅れて実行されるようにスケジューリングするためのもの 繰り返しの interval で実行される処理をスケジューリングするためのもの 66 ` ` ` `

Slide 67

Slide 67 text

Scheduler 型に準拠する MyTestScheduler を作成 Scheduler に準拠させようとすると 、typealias の定義を提案される 67 final class MyTestScheduler: Scheduler { // #type# には何を入れるべきなのか? typealias SchedulerTimeType = <#type#> typealias SchedulerOptions = <#type#> // 以下2 つのプロパティは上記 typealias が定義できれば自動的に満たすことができる var minimumTolerance: Self.SchedulerTimeType.Stride var now: Self.SchedulerTimeType }

Slide 68

Slide 68 text

Generics で associatedtype と property を満たす 普段使う Scheduler は様々なものがあるが、それらに依存するものにはしたくない DispatchQueue ImmediateScheduler RunLoop OperationQueue 様々な Scheduler に対応する TestScheduler を作れるように Generics を導入する 68 final class MyTestScheduler: Scheduler where SchedulerTimeType: Strideable, SchedulerTimeType.Stride: SchedulerTimeIntervalConvertible { var minimumTolerance: SchedulerTimeType.Stride var now: SchedulerTimeType }

Slide 69

Slide 69 text

initializer がないというエラーに対応する 69 MyTestScheduler.swift final class MyTestScheduler: Scheduler where SchedulerTimeType: Strideable, SchedulerTimeType.Stride: SchedulerTimeIntervalConvertible { // TestScheduler ではどれくらいスケジューリングに余裕を持たせるかは考えなくて良いため 0 とすることができる var minimumTolerance: SchedulerTimeType.Stride = 0 var now: SchedulerTimeType init(now: SchedulerTimeType) { self.now = now } }

Slide 70

Slide 70 text

1 つ目の function の作成 1 つ目の function はすぐに処理をスケジューリングできるようなものにしたい 70 MyTestScheduler.swift // わかりやすさのために簡潔にしています final class MyTestScheduler { func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void) { <#code#> } }

Slide 71

Slide 71 text

不要な引数を省略できるようにする 71 MyTestScheduler.swift final class MyTestScheduler { // options は特に使用しないため `_` を内部引数名にして省略することができる func schedule(options _: SchedulerOptions?, _ action: @escaping () -> Void) { <#code#> } }

Slide 72

Slide 72 text

すぐにスケジューリングするような処理を考えてみる schedule(options:action:) が呼ばれた瞬間に action が実行されてしまう -> これだと、開発者が任意のタイミングで schedule された action を実行させることができない 72 MyTestScheduler.swift final class MyTestScheduler { func schedule(options _: SchedulerOptions?, _ action: @escaping () -> Void) { // すぐにスケジューリングできるような function にしたいため、すぐに action を実行するようにすれば良さそう? action() } } ` ` ` ` ` `

Slide 73

Slide 73 text

MyTestScheduler で実現したいこと 実際には Combine で流れてくる一連の処理を蓄積して、任意のタイミングで処理を実行できるようにしたい 73 MyTestScheduler.swift final class MyTestScheduler { // 処理を蓄積するための変数を用意 private var scheduled: [() -> Void] = [] // ... func schedule(options _: SchedulerOptions?, _ action: @escaping () -> Void) { // schedule が呼ばれたら処理を蓄積していくようにする scheduled.append(action) } // 任意のタイミングで scheduled に蓄積された処理を実行するための function func advance() { // advance が呼ばれたら蓄積された処理を全て実行して、削除する for action in scheduled { action() } scheduled.removeAll() } }

Slide 74

Slide 74 text

早速定義した schedule と advance を使ってみる 74 iOSDC2021SampleAppTests.swift func testImmediateScheduledAction() { let testScheduler = MyTestScheduler( // now: DispatchQueue.SchedulerTimeType(DispatchTime(uptimeNanoseconds: 1)) now: .init(.init(uptimeNanoseconds: 0)) ) var isExecuted = false testScheduler.schedule { isExecuted = true } XCTAssertEqual(isExecuted, false) testScheduler.advance() XCTAssertEqual(isExecuted, true) }

Slide 75

Slide 75 text

MyTestScheduler を容易に利用できるようにする 75 MyTestScheduler.swift // DispatchQueue の extension として定義することにより、利用側からは DispatchQueue.myTest という形で利用できる extension DispatchQueue { // typealias を利用し長い Generics が簡潔になるようにしている static var myTest: MyTestSchedulerOf { // uptimeNanoseconds: 0 にすると .now() 扱いになってしまいテストが不安定になるため、 // 0 にできるだけ近い 1 という値を利用し、処理に一貫性がもたらされるように工夫している .init(now: .init(.init(uptimeNanoseconds: 1))) } } typealias MyTestSchedulerOf = MyTestScheduler< Scheduler.SchedulerTimeType, Scheduler.SchedulerOptions > where Scheduler: Combine.Scheduler

Slide 76

Slide 76 text

作成した DispatchQueue.myTest を利用してみる 76 iOSDC2021SampleAppTests.swift let testScheduler = DispatchQueue.myTest func testImmediateScheduledAction() { var isExecuted = false testScheduler.schedule { isExecuted = true } XCTAssertEqual(isExecuted, false) testScheduler.advance() XCTAssertEqual(isExecuted, true) }

Slide 77

Slide 77 text

Publisher なコードを MyTestScheduler でテストする 77 iOSDC2021SampleAppTests.swift func testImmediatePublisherScheduledAction() { let testScheduler = DispatchQueue.myTest var result: [Int] = [] var cancellables: Set = [] Just(1) .receive(on: testScheduler) .sink { result.append($0) } .store(in: &cancellables) XCTAssertEqual(result, []) testScheduler.advance() XCTAssertEqual(result, [1]) }

Slide 78

Slide 78 text

2 つ目の function の作成 2 つ目の function は、ある処理が遅れて実行されるようにスケジューリングできるようにしたい 78 MyTestScheduler.swift final class MyTestScheduler { func schedule( after date: SchedulerTimeType, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void ) { <#code#> } }

Slide 79

Slide 79 text

不要な引数の内部引数名を省略する 79 MyTestScheduler.swift tolerance _: SchedulerTimeType.Stride, options _: SchedulerOptions?, final class MyTestScheduler { func schedule( after date: SchedulerTimeType, _ action: @escaping () -> Void ) { <#code#> } }

Slide 80

Slide 80 text

date を有効活用できるようにコードを修正する 80 MyTestScheduler.swift final class MyTestScheduler { private var scheduled: [(action: () -> Void, date: SchedulerTimeType)] = [] var now: SchedulerTimeType func schedule( options _: SchedulerOptions?, _ action: @escaping () -> Void ) { // 受け取った処理をすぐにスケジューリングする function は now (今すぐ) を利用すれば修正できる scheduled.append((action, now)) } func schedule( after date: SchedulerTimeType, tolerance _: SchedulerTimeType.Stride, options _: SchedulerOptions?, _ action: @escaping () -> Void ) { // 引数の date を使えば、指定された時間にスケジューリングされる挙動を実現できる scheduled.append((action, date)) } }

Slide 81

Slide 81 text

advance も date を活用できる形に修正する 81 MyTestScheduler.swift final class MyTestScheduler { var now: SchedulerTimeType // どれくらい時間を進めるかを表す stride は SchedulerTimeType が Strideable なので SchedulerTimeType.Stride で表す // デフォルト引数として .zero を取ることによって、単に現在時間(now) と比較した際の処理を行うことができるようになる func advance(by stride: SchedulerTimeType.Stride = .zero) { // MyTestScheduler の現在時間 (now) を stride 分進める now = now.advanced(by: stride) // date が現在時間 (now) よりも過去の時間であれば実行すべき処理なので、action を実行する for (action, date) in scheduled { if date <= now { action() } } // 実行し終わったものは削除しておく scheduled.removeAll(where: { $0.date <= now }) } }

Slide 82

Slide 82 text

2 つ目の function を利用したテストを書いてみる 82 iOSDC2021SampleAppTests.swift func testDelayScheduledAction() { let testScheduler = DispatchQueue.myTest var isExecuted = false testScheduler.schedule(after: testScheduler.now.advanced(by: 1)) { isExecuted = true } XCTAssertEqual(isExecuted, false) testScheduler.advance(by: .milliseconds(500)) XCTAssertEqual(isExecuted, false) testScheduler.advance(by: .milliseconds(500)) XCTAssertEqual(isExecuted, true) }

Slide 83

Slide 83 text

非常に長い時間(5000s) を伴うテストも書いてみる 83 iOSDC2021SampleAppTests.swift func testLongLongDelayScheduledAction() { let testScheduler = DispatchQueue.myTest var isExecuted = false testScheduler.schedule(after: testScheduler.now.advanced(by: 5000)) { isExecuted = true } XCTAssertEqual(isExecuted, false) testScheduler.advance(by: 4999) XCTAssertEqual(isExecuted, false) testScheduler.advance(by: 1) XCTAssertEqual(isExecuted, true) }

Slide 84

Slide 84 text

テスト時間はもちろん一瞬 👏 84

Slide 85

Slide 85 text

3 つ目の function の作成 3 つ目の function は、キャンセルされるまでの間繰り返しの interval で処理を実行できるものにしたい 85 MyTestScheduler.swift final class MyTestScheduler { func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void ) -> Cancellable { <#code#> } }

Slide 86

Slide 86 text

こちらも同様に不要な引数は省略できるようにする 86 MyTestScheduler.swift final class MyTestScheduler { // 正しい引数の順番ではないが、簡潔にするため使用する引数と使用しない引数で改行しておいた func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, _ action: @escaping () -> Void, tolerance _: SchedulerTimeType.Stride, options _: SchedulerOptions? ) -> Cancellable { <#code#> } }

Slide 87

Slide 87 text

再帰的な処理によって interval な処理を実現する 87 final class MyTestScheduler { func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, _ action: @escaping () -> Void, tolerance _: SchedulerTimeType.Stride, options _: SchedulerOptions? ) -> Cancellable { // 再帰的な処理を行うための function を定義 func scheduleAction(for date: SchedulerTimeType) -> () -> Void { return { [weak self] in // interval 分空いた時間を取得しておく let nextDate = date.advanced(by: interval) // interval 分空いた時間(nextDate) に実行される予定の処理 (scheduleAction = 自分自身) を append する self?.scheduled.append((scheduleAction(for: nextDate), nextDate)) // schedule(after:interval:action) の引数として指定されている action を実行 action() } } // これを実行することによって、interval 間隔で無限に実行されることになる scheduleAction が append される scheduled.append((scheduleAction(for: date), date)) // 不完全な実装とはなるが、一気に説明すると話が複雑になるため一旦空の AnyCancellable を返却しておく return AnyCancellable {} } }

Slide 88

Slide 88 text

schedule(after:interval) 実行時の流れを追う MyTestScheduler.scheduled の流れ s.schedule(after: scheduler.now, interval: 1) s.advance() s.advance(.milliseconds(500)) s.advance(.milliseconds(500)) [] [scheduleAction(for: now), now)] [scheduleAction(for: now + 1), now + 1)] [scheduleAction(for: now + 1), now + 1)] [scheduleAction(for: now + 2), now + 2)] 88 ` ` final class MyTestScheduler { func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, _ action: @escaping () -> Void, tolerance _: SchedulerTimeType.Stride, options _: SchedulerOptions? ) -> Cancellable { func scheduleAction(for date: SchedulerTimeType) -> () -> Void { return { [weak self] in let nextDate = date.advanced(by: interval) self?.scheduled.append((scheduleAction(for: nextDate), nextDate)) action() } } scheduled.append((scheduleAction(for: date), date)) return AnyCancellable {} } } // 利用コード let scheduler = DispatchQueue.test scheduler.schedule(after: scheduler.now, interval: 1) { ... }

Slide 89

Slide 89 text

schedule(after: now, interval: 1) の実行前 MyTestScheduler.scheduled の流れ s.schedule(after: scheduler.now, interval: 1) s.advance() s.advance(.milliseconds(500)) s.advance(.milliseconds(500)) [] [scheduleAction(for: now), now)] [scheduleAction(for: now + 1), now + 1)] [scheduleAction(for: now + 1), now + 1)] [scheduleAction(for: now + 2), now + 2)] 89 ` ` scheduler.schedule(after: scheduler.now, interval: 1) { ... } final class MyTestScheduler { func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, _ action: @escaping () -> Void, tolerance _: SchedulerTimeType.Stride, options _: SchedulerOptions? ) -> Cancellable { func scheduleAction(for date: SchedulerTimeType) -> () -> Void { return { [weak self] in let nextDate = date.advanced(by: interval) self?.scheduled.append((scheduleAction(for: nextDate), nextDate)) action() } } scheduled.append((scheduleAction(for: date), date)) return AnyCancellable {} } } // 利用コード let scheduler = DispatchQueue.test

Slide 90

Slide 90 text

scheduled に再帰的な関数が append される MyTestScheduler.scheduled の流れ s.schedule(after: scheduler.now, interval: 1) s.advance() s.advance(.milliseconds(500)) s.advance(.milliseconds(500)) [] [scheduleAction(for: now), now)] [scheduleAction(for: now + 1), now + 1)] [scheduleAction(for: now + 1), now + 1)] [scheduleAction(for: now + 2), now + 2)] 90 ` ` ` ` scheduled.append((scheduleAction(for: date), date)) final class MyTestScheduler { func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, _ action: @escaping () -> Void, tolerance _: SchedulerTimeType.Stride, options _: SchedulerOptions? ) -> Cancellable { func scheduleAction(for date: SchedulerTimeType) -> () -> Void { return { [weak self] in let nextDate = date.advanced(by: interval) self?.scheduled.append((scheduleAction(for: nextDate), nextDate)) action() } } return AnyCancellable {} } } // 利用コード let scheduler = DispatchQueue.test scheduler.schedule(after: scheduler.now, interval: 1) { ... }

Slide 91

Slide 91 text

advance により scheduled に append される MyTestScheduler.scheduled の流れ s.schedule(after: scheduler.now, interval: 1) s.advance() s.advance(.milliseconds(500)) s.advance(.milliseconds(500)) [] [scheduleAction(for: now), now)] [scheduleAction(for: now + 1), now + 1)] [scheduleAction(for: now + 1), now + 1)] [scheduleAction(for: now + 2), now + 2)] 91 ` ` ` ` ` ` let nextDate = date.advanced(by: interval) self?.scheduled.append((scheduleAction(for: nextDate), nextDate)) action() final class MyTestScheduler { func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, _ action: @escaping () -> Void, tolerance _: SchedulerTimeType.Stride, options _: SchedulerOptions? ) -> Cancellable { func scheduleAction(for date: SchedulerTimeType) -> () -> Void { return { [weak self] in } } scheduled.append((scheduleAction(for: date), date)) return AnyCancellable {} } } // 利用コード let scheduler = DispatchQueue.test scheduler.schedule(after: scheduler.now, interval: 1) { ... }

Slide 92

Slide 92 text

0.5s だけ advance しても1s に満たないため変化な し MyTestScheduler.scheduled の流れ s.schedule(after: scheduler.now, interval: 1) s.advance() s.advance(.milliseconds(500)) s.advance(.milliseconds(500)) [] [scheduleAction(for: now), now)] [scheduleAction(for: now + 1), now + 1)] [scheduleAction(for: now + 1), now + 1)] [scheduleAction(for: now + 2), now + 2)] 92 ` ` final class MyTestScheduler { func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, _ action: @escaping () -> Void, tolerance _: SchedulerTimeType.Stride, options _: SchedulerOptions? ) -> Cancellable { func scheduleAction(for date: SchedulerTimeType) -> () -> Void { return { [weak self] in let nextDate = date.advanced(by: interval) self?.scheduled.append((scheduleAction(for: nextDate), nextDate)) action() } } scheduled.append((scheduleAction(for: date), date)) return AnyCancellable {} } } // 利用コード

Slide 93

Slide 93 text

1s の interval が経過すると再度 append される MyTestScheduler.scheduled の流れ s.schedule(after: scheduler.now, interval: 1) s.advance() s.advance(.milliseconds(500)) s.advance(.milliseconds(500)) [] [scheduleAction(for: now), now)] [scheduleAction(for: now + 1), now + 1)] [scheduleAction(for: now + 1), now + 1)] [scheduleAction(for: now + 2), now + 2)] 93 ` ` ` ` let nextDate = date.advanced(by: interval) self?.scheduled.append((scheduleAction(for: nextDate), nextDate)) action() final class MyTestScheduler { func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, _ action: @escaping () -> Void, tolerance _: SchedulerTimeType.Stride, options _: SchedulerOptions? ) -> Cancellable { func scheduleAction(for date: SchedulerTimeType) -> () -> Void { return { [weak self] in } } scheduled.append((scheduleAction(for: date), date)) return AnyCancellable {} } } // 利用コード let scheduler = DispatchQueue.test scheduler.schedule(after: scheduler.now, interval: 1) { ... }

Slide 94

Slide 94 text

説明したものをコードで表すと以下のようになる 94 iOSDC2021SampleAppTests.swift func testIntervalScheduledAction() { let testScheduler = DispatchQueue.myTest var executeCount = 0 var cancellables: Set = [] testScheduler.schedule(after: testScheduler.now, interval: 1) { executeCount += 1 } .store(in: &cancellables) XCTAssertEqual(executeCount, 0) testScheduler.advance() XCTAssertEqual(executeCount, 1) testScheduler.advance(by: .milliseconds(500)) XCTAssertEqual(executeCount, 1) testScheduler.advance(by: .milliseconds(500)) XCTAssertEqual(executeCount, 2) }

Slide 95

Slide 95 text

しかし一気に秒数を進めると失敗してしまう 95 iOSDC2021SampleAppTests.swift XCTAssertEqual(executeCount, 2) testScheduler.advance(by: 4) XCTAssertEqual(executeCount, 6) // 4 秒経過しているので、count も2 から6 になっていて欲しいが失敗する(結果は3 ) func testIntervalScheduledAction() { let testScheduler = DispatchQueue.myTest var executeCount = 0 var cancellables: Set = [] testScheduler.schedule(after: testScheduler.now, interval: 1) { executeCount += 1 } .store(in: &cancellables) XCTAssertEqual(executeCount, 0) testScheduler.advance() XCTAssertEqual(executeCount, 1) testScheduler.advance(by: .milliseconds(500)) XCTAssertEqual(executeCount, 1) testScheduler.advance(by: .milliseconds(500)) }

Slide 96

Slide 96 text

interval のテストが失敗する原因は advance にある 96 ` ` MyTestScheduler.swift final class MyTestScheduler { func advance(by stride: SchedulerTimeType.Stride = .zero) { now = now.advanced(by: stride) // schedule(after:interval) の場合、action が実行されることによって scheduled に新たな scheduleAction が追加されていく // ここでは scheduled に全ての action が追加されていないタイミングのものでループを回してしまっているため、意図した結果にならない for (action, date) in scheduled { if date <= now { action() // この action が実行されることによって、scheduled に新たな scheduleAction が追加されていく } } scheduled.removeAll(where: { $0.date <= now }) } }

Slide 97

Slide 97 text

scheduled を追跡する index を用意して修正 97 ` ` ` ` MyTestScheduler.swift final class MyTestScheduler { // ... func advance(by stride: SchedulerTimeType.Stride = .zero) { now = now.advanced(by: stride) var index = 0 // index で scheduled の配列の先頭から走査していくことによって、 // interval action により scheduled に追加されていく action にも対応できる while index < scheduled.count { let work = scheduled[index] if work.date <= now { work.action() scheduled.remove(at: index) } else { index += 1 } } } }

Slide 98

Slide 98 text

一気に秒数を進める interval テストは通るようになる 98 iOSDC2021SampleAppTests.swift func testIntervalScheduledAction() { let testScheduler = DispatchQueue.myTest var executeCount = 0 var cancellables: Set = [] testScheduler.schedule(after: testScheduler.now, interval: 1) { executeCount += 1 } .store(in: &cancellables) XCTAssertEqual(executeCount, 0) testScheduler.advance() XCTAssertEqual(executeCount, 1) testScheduler.advance(by: .milliseconds(500)) XCTAssertEqual(executeCount, 1) testScheduler.advance(by: .milliseconds(500)) XCTAssertEqual(executeCount, 2) testScheduler.advance(by: 4) XCTAssertEqual(executeCount, 6) }

Slide 99

Slide 99 text

同時実行の interval では失敗してしまう 実行した順序( ["frist", "first", "second"] )になっていて欲しいが、順序が保証されていない 99 iOSDC2021SampleAppTests.swift func testTwoIntervalsScheduledAction() { let testScheduler = DispatchQueue.myTest var values: [String] = [] // 0s --> 1s: 実行 --> 2s: 実行 let firstInterval = testScheduler.schedule(after: testScheduler.now.advanced(by: 1), interval: 1) { values.append("first") } // 0s --> 2s: 実行 --> 4s: 実行 let secondInterval = testScheduler.schedule(after: testScheduler.now.advanced(by: 2), interval: 2) { values.append("second") } XCTAssertEqual(values, []) testScheduler.advance(by: 2) // XCTAssertEqual failed: ("["first", "second", "first"]") is not equal to ("["first", "first", "second"]") XCTAssertEqual(values, ["first", "first", "second"]) } ` `

Slide 100

Slide 100 text

順序が保証されない理由を異なるテストで探ってみる testScheduler.now の値がどの時点で実行しても最終的な値( 3_000_000_001 )になってしまっている 100 iOSDC2021SampleAppTests.swift func testScheduleNow() { let testScheduler = DispatchQueue.myTest var times: [UInt64] = [] let interval = testScheduler.schedule(after: testScheduler.now, interval: 1) { // testScheduler 実行時の `now.dispatchTime.uptimeNanoseconds`( ナノ秒) を蓄積していく times.append(testScheduler.now.dispatchTime.uptimeNanoseconds) } XCTAssertEqual(times, []) testScheduler.advance(by: 3) // XCTAssertEqual failed: ("[3_000_000_001, 3_000_000_001, 3_000_000_001, 3_000_000_001]") // is not equal to ("[1, 1_000_000_001, 2_000_000_001, 3_000_000_001]") XCTAssertEqual(times, [1, 1_000_000_001, 2_000_000_001, 3_000_000_001]) } ` ` ` `

Slide 101

Slide 101 text

この理由は advance の実装内容から明らかである 101 ` ` MyTestScheduler.swift final class MyTestScheduler { func advance(by stride: SchedulerTimeType.Stride = .zero) { // 即座に指定された最終的な値まで now を進めてしまっている now = now.advanced(by: stride) var index = 0 while index < scheduled.count { let work = scheduled[index] if work.date <= now { work.action() scheduled.remove(at: index) } else { index += 1 } } } }

Slide 102

Slide 102 text

時間を徐々に進められる advance に修正する 102 ` ` MyTestScheduler.swift func advance(by stride: SchedulerTimeType.Stride = .zero) { // 最終的に advance される Date をあらかじめ取得しておく let finalDate = now.advanced(by: stride) // finalDate より now が過去である限り処理を行う while now <= finalDate { // date 順で scheduled を sort する scheduled.sort { $0.date < $1.date } // scheduled の先頭の date を nextDate として格納し、nextDate が finalDate より過去のものであれば guard を抜ける guard let nextDate = scheduled.first?.date, nextDate <= finalDate else { // nextDate が finalDate より未来である場合は、実行する action がないため早期 return する now = finalDate return } now = nextDate // scheduled の先頭を取り出し続けて、date と nextDate が一致する action を実行していく(削除も行う) while let (action, date) = scheduled.first, date == nextDate { scheduled.removeFirst() action() } } }

Slide 103

Slide 103 text

times に append するテストは通るようになる 103 ` ` ` ` iOSDC2021SampleAppTests.swift func testScheduleNow() { let testScheduler = DispatchQueue.myTest var times: [UInt64] = [] let interval = testScheduler.schedule(after: testScheduler.now, interval: 1) { times.append(testScheduler.now.dispatchTime.uptimeNanoseconds) } XCTAssertEqual(times, []) testScheduler.advance(by: 3) XCTAssertEqual(times, [1, 1_000_000_001, 2_000_000_001, 3_000_000_001]) }

Slide 104

Slide 104 text

同時実行の interval は失敗したまま 失敗している原因は、 advance 内で実行順序を決める際に Date のみでソートしており、 interval な schedule function の実行順序は考慮されていないこと 104 iOSDC2021SampleAppTests.swift func testTwoIntervalsScheduledAction() { let testScheduler = DispatchQueue.myTest var values: [String] = [] let firstInterval = testScheduler.schedule(after: testScheduler.now.advanced(by: 1), interval: 1) { values.append("first") } let secondInterval = testScheduler.schedule(after: testScheduler.now.advanced(by: 2), interval: 2) { values.append("second") } XCTAssertEqual(values, []) testScheduler.advance(by: 2) XCTAssertEqual(values, ["first", "first", "second"]) } ` `

Slide 105

Slide 105 text

sequence を scheduled に持たせて順序を保証 105 ` ` ` ` MyTestScheduler.swift final class MyTestScheduler { private var lastSequence: UInt = 0 private var scheduled: [(sequence: UInt, action: () -> Void, date: SchedulerTimeType)] = [] // これを3 つの function 内の scheduled に append している部分で利用する // ex) scheduled.append((nextSequence(), action, now)) private func nextSequence() -> UInt { lastSequence += 1 return lastSequence } func advance(by stride: SchedulerTimeType.Stride = .zero) { let finalDate = now.advanced(by: stride) while now <= finalDate { // date と sequence で sort することによって、date と sequence(append タイミング) が先のものが先頭に来るようになる scheduled.sort { ($0.date, $0.sequence) < ($1.date, $1.sequence) } // 他の処理に変更はないため省略 } } }

Slide 106

Slide 106 text

1 つ目、2 つ目の function で sequence を利用する 106 ` ` MyTestScheduler.swift func schedule( options _: SchedulerOptions?, _ action: @escaping () -> Void ) { scheduled.append((nextSequence(), action, now)) } func schedule( after date: SchedulerTimeType, tolerance _: SchedulerTimeType.Stride, options _: SchedulerOptions?, _ action: @escaping () -> Void ) { scheduled.append((nextSequence(), action, date)) }

Slide 107

Slide 107 text

3 つ目の function でも sequence を利用する 107 ` ` MyTestScheduler.swift func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void ) -> Cancellable { let sequence = nextSequence() func scheduleAction(for date: SchedulerTimeType) -> () -> Void { return { [weak self] in let nextDate = date.advanced(by: interval) self?.scheduled.append((sequence, scheduleAction(for: nextDate), nextDate)) action() } } self.scheduled.append((sequence, scheduleAction(for: date), date)) return AnyCancellable {} }

Slide 108

Slide 108 text

追加された順序が保証されるようになる 108 iOSDC2021SampleAppTests.swift func testTwoIntervalsScheduledAction() { let testScheduler = DispatchQueue.myTest var values: [String] = [] let firstInterval = testScheduler.schedule(after: testScheduler.now.advanced(by: 1), interval: 1) { values.append("first") } let secondInterval = testScheduler.schedule(after: testScheduler.now.advanced(by: 2), interval: 2) { values.append("second") } XCTAssertEqual(values, []) testScheduler.advance(by: 2) XCTAssertEqual(values, ["first", "first", "second"]) }

Slide 109

Slide 109 text

interval 実行をキャンセルできない最後のバグ 109 iOSDC2021SampleAppTests.swift func testCancelSchedule() { let testScheduler = DispatchQueue.myTest var executeCount = 0 var cancellables: Set = [] testScheduler.schedule(after: testScheduler.now, interval: 1) { executeCount += 1 }.store(in: &cancellables) XCTAssertEqual(executeCount, 0) testScheduler.advance() XCTAssertEqual(executeCount, 1) testScheduler.advance(by: 1) XCTAssertEqual(executeCount, 2) // cancellables を全て削除 cancellables.removeAll() testScheduler.advance(by: 1) // cancellables を全て削除しているため interval 分の1 秒が進んでも executeCount は2 のままでいて欲しい XCTAssertEqual(executeCount, 2) }

Slide 110

Slide 110 text

interval 実行をキャンセルできない最後のバグ 110 iOSDC2021SampleAppTests.swift // XCTAssertEqual failed: ("3") is not equal to ("2") XCTAssertEqual(executeCount, 2) func testCancelSchedule() { let testScheduler = DispatchQueue.myTest var executeCount = 0 var cancellables: Set = [] testScheduler.schedule(after: testScheduler.now, interval: 1) { executeCount += 1 }.store(in: &cancellables) XCTAssertEqual(executeCount, 0) testScheduler.advance() XCTAssertEqual(executeCount, 1) testScheduler.advance(by: 1) XCTAssertEqual(executeCount, 2) cancellables.removeAll() testScheduler.advance(by: 1) }

Slide 111

Slide 111 text

原因は適当な AnyCancellable を返却していること 111 MyTestScheduler.swift func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void ) -> Cancellable { let sequence = nextSequence() func scheduleAction(for date: SchedulerTimeType) -> () -> Void { return { [weak self] in let nextDate = date.advanced(by: interval) self?.scheduled.append((sequence, scheduleAction(for: nextDate), nextDate)) action() } } self.scheduled.append((sequence, scheduleAction(for: date), date)) return AnyCancellable {} }

Slide 112

Slide 112 text

sequence を利用すれば簡単に修正できる 112 ` ` MyTestScheduler.swift let sequence = nextSequence() return AnyCancellable { self.scheduled.removeAll(where: { $0.sequence == sequence }) } func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void ) -> Cancellable { func scheduleAction(for date: SchedulerTimeType) -> () -> Void { return { [weak self] in let nextDate = date.advanced(by: interval) self?.scheduled.append((sequence, scheduleAction(for: nextDate), nextDate)) action() } } self.scheduled.append((sequence, scheduleAction(for: date), date)) }

Slide 113

Slide 113 text

テストが無事に通るようになる 113 iOSDC2021SampleAppTests.swift func testCancelSchedule() { let testScheduler = DispatchQueue.myTest var executeCount = 0 var cancellables: Set = [] testScheduler.schedule(after: testScheduler.now, interval: 1) { executeCount += 1 }.store(in: &cancellables) XCTAssertEqual(executeCount, 0) testScheduler.advance() XCTAssertEqual(executeCount, 1) testScheduler.advance(by: 1) XCTAssertEqual(executeCount, 2) cancellables.removeAll() // 正常な AnyCancellable を削除できるようになった testScheduler.advance(by: 1) XCTAssertEqual(executeCount, 2) }

Slide 114

Slide 114 text

以上が combine-schedulers の実装方法になります。 複雑な実装はありましたが、ライブラリの実装が そこまで厚いものではないことを 理解して頂けていたら嬉しいです🙏

Slide 115

Slide 115 text

まとめ Combine は時間を伴うような operator を使い始めるとテストがしにくくなる delay , timeout , throttle , debounce など… XCTWaiter など一定時間経過するのを待ってテストを成功させる方法はあるが、デメリットがある 何となくそれっぽい秒数 wait しなければならない wait した分だけテスト実行時間が増加する combine-schedulers を利用すると上記の問題を解決できる combine-schedulers の実装は薄いものであるため、ライブラリ依存の心配もある程度緩和される(はず) Scheduler Protocol に準拠させた TestScheduler その他いくつかの Scheduler Test を便利にするものや animation を制御できるもの Generics を複雑にしないようにするための AnyScheduler 115 ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` `

Slide 116

Slide 116 text

参考 GitHub pointfreeco / combine-schedulers tcldr / Entwine Point-Free Combine Schedulers: Testing Time Combine Schedulers: Controlling Time Combine Schedulers: Erasing Time Apple Developer Documentation Scheduler 116

Slide 117

Slide 117 text

117