Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

6dc67d02d6b322ee317cece9b045317d?s=47 Aikawa
September 18, 2021

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

iOSDC2021 で発表したスライドになります。

6dc67d02d6b322ee317cece9b045317d?s=128

Aikawa

September 18, 2021
Tweet

Transcript

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

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

    2
  3. アジェンダ ViewModel + Combine の基本的なコード そのコードをテストするための基本的な方法 combine-schedulers でテストコードの時間を操る方法 combine-schedulers の仕組み

    3 Combine とテストについて、こちらの流れで話していきます
  4. 説明で利用するアプリ https://github.com/kalupas226/iOSDC2021SampleApp 4

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

  6. 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" }, // ... ] }
  7. Model のコード( API 通信部分 ) 7 GitHubAPIClient.swift // interface struct

    GitHubAPIClient { var searchRepository: (String) -> AnyPublisher<GitHubRepositoryList, Never> } // 実装部分 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() } ) }
  8. ViewModel のコード 8 GitHubViewModel.swift final class GitHubViewModel: ObservableObject { private

    let gitHubAPIClient: GitHubAPIClient private var cancellables: Set<AnyCancellable> = [] @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) } }
  9. View のコード 9 GitHubListView.swift struct GitHubListView: View { @ObservedObject private

    var viewModel: GitHubViewModel init(viewModel: GitHubViewModel) { self.viewModel = viewModel } var body: some View { VStack { ... } .padding() } }
  10. 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() }
  11. SearchButton を押した時のテストコード 11 iOSDC2021SampleAppTests.swift func testSearchButtonTapped() throws { var cancellables:

    Set<AnyCancellable> = [] 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... }
  12. SearchButton を押した時のテストコード 12 iOSDC2021SampleAppTests.swift let viewModel = GitHubViewModel(...) // ViewModel

    の @Published repositories の変化を監視する viewModel.$repositories .sink { actualRepositories = $0 } .store(in: &cancellables) func testSearchButtonTapped() throws { var cancellables: Set<AnyCancellable> = [] 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) }
  13. 簡単なテストが成功しましたが、 コードに問題があるため修正します

  14. publish values from the main thread の警告 14 GitHubViewModel.swift final

    class GitHubViewModel: ObservableObject { private let gitHubAPIClient: GitHubAPIClient private var cancellables: Set<AnyCancellable> = [] @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) } }
  15. 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<AnyCancellable> = [] @Published var searchWord = "" @Published var repositories: [GitHubRepository] = [] init(gitHubAPIClient: GitHubAPIClient) { self.gitHubAPIClient = gitHubAPIClient } func searchButtonTapped() { gitHubAPIClient .searchRepository(searchWord) .store(in: &cancellables) } }
  16. しかし、今度はテストが壊れてしまいます

  17. 壊れてしまったテスト 17 iOSDC2021SampleAppTests.swift func testSearchButtonTapped() throws { var cancellables: Set<AnyCancellable>

    = [] 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) }
  18. 一定時間 wait するようにして修正 18 iOSDC2021SampleAppTests.swift _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout:

    0.1) // 0.1 秒は適当 func testSearchButtonTapped() throws { var cancellables: Set<AnyCancellable> = [] 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) }
  19. 現時点で微妙なテスト感が漂ってきましたが、 もう少しアプリの機能を実用的にしてみましょう

  20. インクリメンタルサーチ機能の追加 20 GitHubViewModel.swift final class GitHubViewModel: ObservableObject { private let

    gitHubAPIClient: GitHubAPIClient private var cancellables: Set<AnyCancellable> = [] @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) } }
  21. ついでにローディングを検知するための状態も追加 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) } }
  22. 完成したインクリメンタルサーチ機能 22

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

  24. エラーが発生しているコード 24 iOSDC2021SampleAppTests.swift viewModel.searchButtonTapped() // searchButtonTapped を削除したためコンパイルエラーが発生 func testSearchButtonTapped() throws

    { var cancellables: Set<AnyCancellable> = [] 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) }
  25. 問題のある部分をコメントアウトして修正 25 iOSDC2021SampleAppTests.swift func testInputSearchWords() throws { // 命名を修正 //

    viewModel.searchButtonTapped() ここをコメントアウト var cancellables: Set<AnyCancellable> = [] 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) }
  26. 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)
  27. この変更によって再びテストが壊れる 27 iOSDC2021SampleAppTests.swift func testInputSearchWords() throws { var cancellables: Set<AnyCancellable>

    = [] 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) }
  28. wait する秒数を伸ばすとテストが成功する 28 _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.33) func

    testInputSearchWords() throws { var cancellables: Set<AnyCancellable> = [] 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) }
  29. テストの問題点😕 それらしい秒数を待つようにしていること debounce で 0.3s 、 receive(on: DispatchQueue.main) も指定しているので、 とりあえず

    0.33s という適当な時間 wait している CI など実行環境によっては、テストが失敗する可能性もある wait で指定している秒数は実際に時間を消費するものであること 0.33s wait するのであれば、テストの実行時間も同じくらい増える 小さな問題かもしれないが、チリツモでテストの総実行時間が長くなる 29 ` ` ` ` ` ` ` `
  30. combine-schedulers を使って改善していきます 30

  31. combine-schedulers とは? Point-Free が提供するライブラリ Combine の処理をよりテストしやすく、より汎用性の高いものにするための Scheduler Combine が提供する Scheduler

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

    protocol に準拠する Scheduler 群が用意されている AnyScheduler TestScheduler ImmediateScheduler Animated schedulers FailingScheduler UIScheduler Publishers.Timer 32 ` `
  33. 先ほどのコードの ViewModel に combine- schedulers を導入して、使い方を見ていきます (ライブラリ自体の導入方法は省略します)

  34. 元々の ViewModel 34 GitHubViewModel.swift final class GitHubViewModel: ObservableObject { private

    let gitHubAPIClient: GitHubAPIClient private var cancellables: Set<AnyCancellable> = [] @Published var searchWord = "" // ... init(gitHubAPIClient: GitHubAPIClient) { self.gitHubAPIClient = gitHubAPIClient $searchWord .sink { [weak self] in // API 通信処理など } .store(in: &cancellables) } }
  35. combine-schedulers の Scheduler を導入 35 GitHubViewModel.swift import CombineSchedulers final class

    GitHubViewModel: ObservableObject { private let gitHubAPIClient: GitHubAPIClient // combine-schedulers の AnyScheduler については詳しく説明しないが、これにより不要な Generics を導入する必要がなくなっている private let scheduler: AnySchedulerOf<DispatchQueue> private var cancellables: Set<AnyCancellable> = [] @Published var searchWord = "" init(gitHubAPIClient: GitHubAPIClient, scheduler: AnySchedulerOf<DispatchQueue>) { self.gitHubAPIClient = gitHubAPIClient self.scheduler = scheduler $searchWord .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .sink { [weak self] in // API 通信処理など } .store(in: &cancellables) } }
  36. 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)
  37. 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)
  38. 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<DispatchQueue> の型になるように eraseToAnyScheduler する // Combine の eraseToAnyPublisher などと同じようなもので、Scheduler 版の type eraser だと思えば問題ない scheduler: DispatchQueue.main.eraseToAnyScheduler() ) ) } }
  39. 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() } ), ) ) } }
  40. これで今までと同じようにアプリ本体が動作します (エントリーポイントのコードの修正は省略)

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

  42. 修正前のテストコード 42 iOSDC2021SampleAppTests.swift func testInputSearchWords() throws { var cancellables: Set<AnyCancellable>

    = [] 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() } ) ) // ... }
  43. 引数不足によりテストが動作しなくなっている 43 iOSDC2021SampleAppTests.swift func testInputSearchWords() throws { var cancellables: Set<AnyCancellable>

    = [] 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 ) // ... }
  44. テストコードに scheduler を追加していく 44 ` ` iOSDC2021SampleAppTests.swift import CombineScheduler func

    testInputSearchWords() throws { var cancellables: Set<AnyCancellable> = [] var actualRepositories: [GitHubRepository] = [] let expectedRepositories: [GitHubRepository] = (1...3).map { .init(id: $0, fullName: "Repository \($0)") } let scheduler = DispatchQueue.test // 型は TestSchedulerOf<DispatchQueue> let viewModel = GitHubViewModel( gitHubAPIClient: .init( searchRepository: { _ in Just( GitHubRepositoryList(items: expectedRepositories) ) .eraseToAnyPublisher() } }, scheduler: scheduler.eraseToAnyScheduler // AnyScheduler 型になるように type eraser する ) // ... }
  45. 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) }
  46. 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) }
  47. 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) }
  48. 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) }
  49. テストの実行時間も改善🎉 49

  50. combine-schedulers 導入前のテストの問題点 それらしい秒数を待つようにしていること debounce で 0.3s 、 receive(on: DispatchQueue.main) も指定しているので、

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

    とりあえず 0.33s という適当な時間 wait している scheduler.advance(by:) で正確な時間を進めることができるようになった wait で指定している秒数は実際に時間を消費するものであること 0.33s wait するのであれば、テストの実行時間も同じくらい増える 小さな問題かもしれないが、チリツモでテストの総実行時間が長くなる scheduler.advance(by:) でテストの実際の実行時間に影響を与えることなく時間を 進めることができるようになった 51 ` ` ` ` ` ` ` ` ` ` ` `
  52. 無事テストの問題点を改善できたので、 combine-schedulers の仕組みを見ていきます 難しい部分も多いため、不明点はぜひ ask the speaker での質問もお待ちしています 🔈

  53. debounce , receive の引数の scheduler 53 ` ` ` `

    ` ` いずれも Scheduler に準拠した S という型を scheduler の引数としている ` ` ` ` ` ` func debounce<S>( scheduler: S, ) -> Publishers.Debounce<Self, S> where S : Scheduler for dueTime: S.SchedulerTimeType.Stride, options: S.SchedulerOptions? = nil func receive<S>( on scheduler: S, ) -> Publishers.ReceiveOn<Self, S> where S : Scheduler options: S.SchedulerOptions? = nil
  54. 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#>)
  55. 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 }
  56. 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 ` `
  57. 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 }
  58. now と minimumTolerance now Scheduler の現在の時間を表す minimumTolerance Scheduler が行う処理のスケジューリングにどのくらいの余裕を持たせるかを表す 58

    ` ` ` ` var now: Self.SchedulerTimeType { get } ` ` var minimumTolerance: Self.SchedulerTimeType.Stride ` `
  59. 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 } }
  60. 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
  61. これらを満たして Scheduler protocol に準拠した Scheduler を作成すれば、 recevie(on:) などで 独自の Scheduler

    を利用できるようになる ` ` ` `
  62. 基本的には独自に Scheduler は定義しない DispatchQueue ImmediateScheduler RunLoop OperationQueue 62 ` `

    Apple が提供する Scheduler に準拠する型 ` `
  63. DispatchQueue の例 63 利用方法 // 受け取った処理をすぐにスケジューリングする schedule() DispatchQueue.main.schedule { print("

    できるだけ早く実行する function") } // // ある処理が遅れて実行されるようにスケジューリングする schedule(after:) DispatchQueue.main.schedule(after: .init(.now() + 1)) { print(" 少し遅らせて実行する function") } var cancellables: Set<AnyCancellable> = [] // 繰り返しの interval で実行される処理をスケジューリングする schedule(after:interval) DispatchQueue.main.schedule(after: .init(.now()), interval: 1) { print("interval で実行する function") } .store(in: &cancellables)
  64. テストの時間を操れるような 独自の Scheduler を定義して combine-shcedulers の仕組みを探っていく

  65. Test 用 Scheduler 作成の目標と道のり どんな Scheduler を作成したいか Test 内の時間を自由に操れる combine-schedulers

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

    のようなもの 道のり Scheduler protocol に準拠した MyTestScheduler を作成する 2 つの associatedtype 2 つの property を満たす 3 つの function を満たす 受け取った処理をすぐにスケジューリングするためのもの ある処理が遅れて実行されるようにスケジューリングするためのもの 繰り返しの interval で実行される処理をスケジューリングするためのもの 66 ` ` ` `
  67. 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 }
  68. Generics で associatedtype と property を満たす 普段使う Scheduler は様々なものがあるが、それらに依存するものにはしたくない DispatchQueue

    ImmediateScheduler RunLoop OperationQueue 様々な Scheduler に対応する TestScheduler を作れるように Generics を導入する 68 final class MyTestScheduler<SchedulerTimeType, SchedulerOptions>: Scheduler where SchedulerTimeType: Strideable, SchedulerTimeType.Stride: SchedulerTimeIntervalConvertible { var minimumTolerance: SchedulerTimeType.Stride var now: SchedulerTimeType }
  69. initializer がないというエラーに対応する 69 MyTestScheduler.swift final class MyTestScheduler<SchedulerTimeType, SchedulerOptions>: Scheduler where

    SchedulerTimeType: Strideable, SchedulerTimeType.Stride: SchedulerTimeIntervalConvertible { // TestScheduler ではどれくらいスケジューリングに余裕を持たせるかは考えなくて良いため 0 とすることができる var minimumTolerance: SchedulerTimeType.Stride = 0 var now: SchedulerTimeType init(now: SchedulerTimeType) { self.now = now } }
  70. 1 つ目の function の作成 1 つ目の function はすぐに処理をスケジューリングできるようなものにしたい 70 MyTestScheduler.swift

    // わかりやすさのために簡潔にしています final class MyTestScheduler<SchedulerTimeType, SchedulerOptions> { func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void) { <#code#> } }
  71. 不要な引数を省略できるようにする 71 MyTestScheduler.swift final class MyTestScheduler<SchedulerTimeType, SchedulerOptions> { // options

    は特に使用しないため `_` を内部引数名にして省略することができる func schedule(options _: SchedulerOptions?, _ action: @escaping () -> Void) { <#code#> } }
  72. すぐにスケジューリングするような処理を考えてみる schedule(options:action:) が呼ばれた瞬間に action が実行されてしまう -> これだと、開発者が任意のタイミングで schedule された action

    を実行させることができない 72 MyTestScheduler.swift final class MyTestScheduler<SchedulerTimeType, SchedulerOptions> { func schedule(options _: SchedulerOptions?, _ action: @escaping () -> Void) { // すぐにスケジューリングできるような function にしたいため、すぐに action を実行するようにすれば良さそう? action() } } ` ` ` ` ` `
  73. MyTestScheduler で実現したいこと 実際には Combine で流れてくる一連の処理を蓄積して、任意のタイミングで処理を実行できるようにしたい 73 MyTestScheduler.swift final class MyTestScheduler<SchedulerTimeType,

    SchedulerOptions> { // 処理を蓄積するための変数を用意 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() } }
  74. 早速定義した schedule と advance を使ってみる 74 iOSDC2021SampleAppTests.swift func testImmediateScheduledAction() {

    let testScheduler = MyTestScheduler<DispatchQueue.SchedulerTimeType, DispatchQueue.SchedulerOptions>( // 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) }
  75. MyTestScheduler を容易に利用できるようにする 75 MyTestScheduler.swift // DispatchQueue の extension として定義することにより、利用側からは DispatchQueue.myTest

    という形で利用できる extension DispatchQueue { // typealias を利用し長い Generics が簡潔になるようにしている static var myTest: MyTestSchedulerOf<DispatchQueue> { // uptimeNanoseconds: 0 にすると .now() 扱いになってしまいテストが不安定になるため、 // 0 にできるだけ近い 1 という値を利用し、処理に一貫性がもたらされるように工夫している .init(now: .init(.init(uptimeNanoseconds: 1))) } } typealias MyTestSchedulerOf<Scheduler> = MyTestScheduler< Scheduler.SchedulerTimeType, Scheduler.SchedulerOptions > where Scheduler: Combine.Scheduler
  76. 作成した 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) }
  77. Publisher なコードを MyTestScheduler でテストする 77 iOSDC2021SampleAppTests.swift func testImmediatePublisherScheduledAction() { let

    testScheduler = DispatchQueue.myTest var result: [Int] = [] var cancellables: Set<AnyCancellable> = [] Just(1) .receive(on: testScheduler) .sink { result.append($0) } .store(in: &cancellables) XCTAssertEqual(result, []) testScheduler.advance() XCTAssertEqual(result, [1]) }
  78. 2 つ目の function の作成 2 つ目の function は、ある処理が遅れて実行されるようにスケジューリングできるようにしたい 78 MyTestScheduler.swift

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

    class MyTestScheduler<SchedulerTimeType, SchedulerOptions> { func schedule( after date: SchedulerTimeType, _ action: @escaping () -> Void ) { <#code#> } }
  80. date を有効活用できるようにコードを修正する 80 MyTestScheduler.swift final class MyTestScheduler<SchedulerTimeType, SchedulerOptions> { 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)) } }
  81. advance も date を活用できる形に修正する 81 MyTestScheduler.swift final class MyTestScheduler<SchedulerTimeType, SchedulerOptions>

    { 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 }) } }
  82. 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) }
  83. 非常に長い時間(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) }
  84. テスト時間はもちろん一瞬 👏 84

  85. 3 つ目の function の作成 3 つ目の function は、キャンセルされるまでの間繰り返しの interval で処理を実行できるものにしたい

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

    func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, _ action: @escaping () -> Void, tolerance _: SchedulerTimeType.Stride, options _: SchedulerOptions? ) -> Cancellable { <#code#> } }
  87. 再帰的な処理によって interval な処理を実現する 87 final class MyTestScheduler<SchedulerTimeType, SchedulerOptions> { 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 {} } }
  88. 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<SchedulerTimeType, SchedulerOptions> { 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) { ... }
  89. 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<SchedulerTimeType, SchedulerOptions> { 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
  90. 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<SchedulerTimeType, SchedulerOptions> { 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) { ... }
  91. 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<SchedulerTimeType, SchedulerOptions> { 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) { ... }
  92. 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<SchedulerTimeType, SchedulerOptions> { 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 {} } } // 利用コード
  93. 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<SchedulerTimeType, SchedulerOptions> { 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) { ... }
  94. 説明したものをコードで表すと以下のようになる 94 iOSDC2021SampleAppTests.swift func testIntervalScheduledAction() { let testScheduler = DispatchQueue.myTest

    var executeCount = 0 var cancellables: Set<AnyCancellable> = [] 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) }
  95. しかし一気に秒数を進めると失敗してしまう 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<AnyCancellable> = [] 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)) }
  96. interval のテストが失敗する原因は advance にある 96 ` ` MyTestScheduler.swift final class

    MyTestScheduler<SchedulerTimeType, SchedulerOptions> { 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 }) } }
  97. scheduled を追跡する index を用意して修正 97 ` ` ` ` MyTestScheduler.swift

    final class MyTestScheduler<SchedulerTimeType, SchedulerOptions> { // ... 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 } } } }
  98. 一気に秒数を進める interval テストは通るようになる 98 iOSDC2021SampleAppTests.swift func testIntervalScheduledAction() { let testScheduler

    = DispatchQueue.myTest var executeCount = 0 var cancellables: Set<AnyCancellable> = [] 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) }
  99. 同時実行の 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"]) } ` `
  100. 順序が保証されない理由を異なるテストで探ってみる 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]) } ` ` ` `
  101. この理由は advance の実装内容から明らかである 101 ` ` MyTestScheduler.swift final class MyTestScheduler<SchedulerTimeType,

    SchedulerOptions> { 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 } } } }
  102. 時間を徐々に進められる 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() } } }
  103. 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]) }
  104. 同時実行の 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"]) } ` `
  105. sequence を scheduled に持たせて順序を保証 105 ` ` ` ` MyTestScheduler.swift

    final class MyTestScheduler<SchedulerTimeType, SchedulerOptions> { 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) } // 他の処理に変更はないため省略 } } }
  106. 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)) }
  107. 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 {} }
  108. 追加された順序が保証されるようになる 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"]) }
  109. interval 実行をキャンセルできない最後のバグ 109 iOSDC2021SampleAppTests.swift func testCancelSchedule() { let testScheduler =

    DispatchQueue.myTest var executeCount = 0 var cancellables: Set<AnyCancellable> = [] 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) }
  110. 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<AnyCancellable> = [] 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) }
  111. 原因は適当な 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 {} }
  112. 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)) }
  113. テストが無事に通るようになる 113 iOSDC2021SampleAppTests.swift func testCancelSchedule() { let testScheduler = DispatchQueue.myTest

    var executeCount = 0 var cancellables: Set<AnyCancellable> = [] 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) }
  114. 以上が combine-schedulers の実装方法になります。 複雑な実装はありましたが、ライブラリの実装が そこまで厚いものではないことを 理解して頂けていたら嬉しいです🙏

  115. まとめ Combine は時間を伴うような operator を使い始めるとテストがしにくくなる delay , timeout , throttle

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