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

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

Aikawa
September 18, 2021

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

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

Aikawa

September 18, 2021
Tweet

More Decks by Aikawa

Other Decks in Technology

Transcript

  1. Combine
    を使ったコードの

    テストを Scheduler


    操る方法とその仕組み
    iOSDC Japan 2021

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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"
    },

    // ...

    ]

    }

    View Slide

  7. 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()

    }

    )

    }

    View Slide

  8. 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)
    }
    }

    View Slide

  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()
    }
    }

    View Slide

  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()
    }

    View Slide

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

    View Slide

  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 = []
    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)
    }

    View Slide

  13. 簡単なテストが成功しましたが、

    コードに問題があるため修正します

    View Slide

  14. 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)
    }
    }

    View Slide

  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 = []
    @Published var searchWord = ""
    @Published var repositories: [GitHubRepository] = []
    init(gitHubAPIClient: GitHubAPIClient) {
    self.gitHubAPIClient = gitHubAPIClient
    }
    func searchButtonTapped() {
    gitHubAPIClient
    .searchRepository(searchWord)
    .store(in: &cancellables)
    }
    }

    View Slide

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

    View Slide

  17. 壊れてしまったテスト
    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)
    }

    View Slide

  18. 一定時間 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)
    }

    View Slide

  19. 現時点で微妙なテスト感が漂ってきましたが、

    もう少しアプリの機能を実用的にしてみましょう

    View Slide

  20. インクリメンタルサーチ機能の追加
    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)
    }
    }

    View Slide

  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)
    }
    }

    View Slide

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

    View Slide

  23. 機能を変更したためテストコードで

    エラーが発生します

    View Slide

  24. エラーが発生しているコード
    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)
    }

    View Slide

  25. 問題のある部分をコメントアウトして修正
    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)
    }

    View Slide

  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)

    View Slide

  27. この変更によって再びテストが壊れる
    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)
    }

    View Slide

  28. 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)
    }

    View Slide

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

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  33. 先ほどのコードの ViewModel
    に combine-
    schedulers
    を導入して、使い方を見ていきます

    (ライブラリ自体の導入方法は省略します)

    View Slide

  34. 元々の 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)
    }
    }

    View Slide

  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
    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)
    }
    }

    View Slide

  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)

    View Slide

  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)

    View Slide

  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
    の型になるように eraseToAnyScheduler
    する
    // Combine
    の eraseToAnyPublisher
    などと同じようなもので、Scheduler
    版の type eraser
    だと思えば問題ない
    scheduler: DispatchQueue.main.eraseToAnyScheduler()
    )
    )
    }
    }

    View Slide

  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()
    }
    ),
    )
    )
    }
    }

    View Slide

  40. これで今までと同じようにアプリ本体が動作します

    (エントリーポイントのコードの修正は省略)

    View Slide

  41. 次に combine-schedulers
    を導入した

    最大の目的であるテストを修正していきます

    View Slide

  42. 修正前のテストコード
    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()
    }
    )
    )
    // ...
    }

    View Slide

  43. 引数不足によりテストが動作しなくなっている
    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
    )
    // ...
    }

    View Slide

  44. テストコードに 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
    する
    )
    // ...
    }

    View Slide

  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)
    }

    View Slide

  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)
    }

    View Slide

  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)
    }

    View Slide

  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)
    }

    View Slide

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

    View Slide

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

    とりあえず 0.33s
    という適当な時間 wait
    している
    wait
    で指定している秒数は実際に時間を消費するものであること
    0.33s wait
    するのであれば、テストの実行時間も同じくらい増える
    小さな問題かもしれないが、チリツモでテストの総実行時間が長くなる
    50
    ` ` ` `
    ` `
    ` `

    View Slide

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

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


    進めることができるようになった
    51
    ` ` ` `
    ` `
    ` `
    ` `
    ` `

    View Slide

  52. 無事テストの問題点を改善できたので、

    combine-schedulers
    の仕組みを見ていきます



    難しい部分も多いため、不明点はぜひ

    ask the speaker
    での質問もお待ちしています 🔈

    View Slide

  53. 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

    View Slide

  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#>)

    View Slide

  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
    }

    View Slide

  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
    ` `

    View Slide

  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
    }

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  61. これらを満たして Scheduler protocol
    に準拠した

    Scheduler
    を作成すれば、 recevie(on:)
    などで

    独自の Scheduler
    を利用できるようになる
    ` `
    ` `

    View Slide

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

    View Slide

  63. 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)

    View Slide

  64. テストの時間を操れるような

    独自の Scheduler
    を定義して

    combine-shcedulers
    の仕組みを探っていく

    View Slide

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

    View Slide

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

    View Slide

  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
    }

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  72. すぐにスケジューリングするような処理を考えてみる
    schedule(options:action:)
    が呼ばれた瞬間に action
    が実行されてしまう

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

    View Slide

  73. 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()
    }
    }

    View Slide

  74. 早速定義した 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)
    }

    View Slide

  75. 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

    View Slide

  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)
    }

    View Slide

  77. 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])
    }

    View Slide

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

    View Slide

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

    View Slide

  80. 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))
    }
    }

    View Slide

  81. 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 })
    }
    }

    View Slide

  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)
    }

    View Slide

  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)
    }

    View Slide

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

    View Slide

  85. 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#>
    }
    }

    View Slide

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

    View Slide

  87. 再帰的な処理によって 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 {}
    }
    }

    View Slide

  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 {
    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) { ... }

    View Slide

  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 {
    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

    View Slide

  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 {
    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) { ... }

    View Slide

  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 {
    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) { ... }

    View Slide

  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 {
    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 {}
    }
    }
    //
    利用コード

    View Slide

  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 {
    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) { ... }

    View Slide

  94. 説明したものをコードで表すと以下のようになる
    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)
    }

    View Slide

  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 = []
    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))
    }

    View Slide

  96. 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 })
    }
    }

    View Slide

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

    View Slide

  98. 一気に秒数を進める 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)
    }

    View Slide

  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"])
    }
    ` `

    View Slide

  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])
    }
    ` ` ` `

    View Slide

  101. この理由は 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
    }
    }
    }
    }

    View Slide

  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()
    }
    }
    }

    View Slide

  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])
    }

    View Slide

  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"])
    }
    ` `

    View Slide

  105. 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) }
    //
    他の処理に変更はないため省略
    }
    }
    }

    View Slide

  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))
    }

    View Slide

  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 {}
    }

    View Slide

  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"])
    }

    View Slide

  109. 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)
    }

    View Slide

  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 = []
    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)
    }

    View Slide

  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 {}
    }

    View Slide

  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))
    }

    View Slide

  113. テストが無事に通るようになる
    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)
    }

    View Slide

  114. 以上が combine-schedulers
    の実装方法になります。

    複雑な実装はありましたが、ライブラリの実装が

    そこまで厚いものではないことを

    理解して頂けていたら嬉しいです🙏

    View Slide

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

    View Slide

  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

    View Slide

  117. 117

    View Slide