【XCTest | Combine】ノンストレスな非同期テストの書き方

`XCTest`の`Combine`を使った非同期テストをたくさん書いてきました
「非同期テストだり〜な〜」と思いながらも、
覚えたやり方で脳死で書けるようになったので
改善しようという発想にはなかなか至らなかった訳ですが

ふと「絶対もっと簡単に書けるよな、これ」と我に返って
遂にリファクタリングに着手しました

その記録です

# ●今までの書き方

例えばこのような
Publisher`count` を0.5秒後にインクリするメソッド`delayIncrement()`を持つ
ViewModelがあったとして
```swift
import Foundation

class HogeViewModel: ObservableObject {
    @Published var count: Int = 0
    
    func delayIncrement() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            self.count += 1
        }
    }
}
```

そのテストはこんな感じで書いてました
```swift
@testable import HogeApp
import XCTest
import Combine

class HogeViewModelTests: XCTestCase {
    var cancellables = Set<AnyCancellable>()

    func test_プラスを1回押すとcountが1になる() {
        let viewModel = HogeViewModel()
        
        viewModel.increment()
        
        let expectation = XCTestExpectation()
        viewModel.$count
            .dropFirst()
            .sink { count in
                XCTAssertEqual(1, count)
                expectation.fulfill()
            }
            .store(in: &cancellables)
        wait(for: [expectation], timeout: 1)
    }
}
```
## 何がダルいか
- `cancellables`をclassごとにいちいち定義する
- `expectation`をテストごとにいちいち定義する
- `expectation`を`fulfill`する記述
- `wait`で`fulfill`するまでを待つ記述

この4つが非常にダルいです

# ●脳死で書ける事と良いコードを書ける事は別
覚えると脳死で書けます。慣れて早く書くことができます。
しかしそれが当たり前になり、**改善**すべきことだと認識しにくくなります

こういう怠慢が技術的負債を増やします。危なかったまじで。

# ●ノンストレスで非同期テストを書く

今の所、一番しっくりきてる改善例です

まず肝となるclass`AsyncTest`を定義します

```swift
import XCTest
import Combine

class AsyncTest: XCTestCase {
    private var cancellables = Set<AnyCancellable>()
    private var dropCount: Int = 0
    
    func sink<T>(
        _ publisher: Published<T>.Publisher,
        timeout seconds: Double = 1,
        isInverted: Bool = false,
        _ assertions: @escaping (_ receiveValue: T) -> Void
    ) {
        let expectation = XCTestExpectation()
        expectation.isInverted = isInverted
        
        publisher
            .dropFirst(dropCount)
            .sink { receiveValue in
                assertions(receiveValue)
                expectation.fulfill()
            }
            .store(in: &cancellables)
        
        wait(for: [expectation], timeout: seconds)
    }
    
    func dropFirst(_ count: Int = 1) -> AsyncTest {
        dropCount = count
        return self
    }
}

```

## まずは使い方
こんな感じで使います

```swift
class HogeTests: XCTestCase {
    let asyncTest = AsyncTest()

    func test_プラスを1回押すとcountが1インクリされる() {
        let viewModel = HogeViewModel()
        
        viewModel.increment()
        
        asyncTest
            .dropFirst()
            .sink(viewModel.$count) { count in
                XCTAssertEqual(1, count)
            }
    }
}
```

# ●AsyncTestの解説

まずはclassに`cancellables`と
`dropFirst`する`Int`を保存するプロパティ`dropCount`を定義します
```swift
    private var cancellables = Set<AnyCancellable>()
    private var dropCount: Int = 0
```

メソッド`sink()`は
- 変更を監視したい`publisher`
- `timeout`の時間を設定する`Double`（デフォルト1）
- `expectation`の挙動を反転させるか否かのBool `inInverted`（デフォルトfalse）
- アサーションを実行するクロージャー`assertions`

の4つを引数として受け取ります

そして`Publisher`とクロージャーの引数`receiveValue`は
どんな型のパブリッシャーだろうと受け取るようにするために
**Generics`<T>`** を適用しています

```swift
    func sink<T>(
        _ publisher: Published<T>.Publisher,
        timeout seconds: Double = 1,
        isInverted: Bool = false,
        _ assertions: @escaping (_ receiveValue: T) -> Void
    ) {
```

パブリッシュを初回から何回無視するかを設定するメソッド`dropFirst()`を定義します
先ほど定義したプロパティ`dropCount`に値を保存し
`self`をreturnしてメソッドチェーンを可能にします
```swift
    func dropFirst(_ count: Int = 1) -> AsyncTest {
        dropCount = count
        return self
    }
```
メソッド`sink()`内では`expectation`を定義し
`expectation.isInverted`に受け取ったBoolを代入

受け取った監視対象の`publisher`に対して、
先ほどの`dropCount`を引数に与えて`Combine`の`dropFirst()`を実行
さらに`Combine`の`sink`メソッドをチェーンさせて`publisher`を監視します

`cancellables`への`store`と
`expectation`が`fulfill`されるまで待つ`wait`もここで記述します

```swift
    let expectation = XCTestExpectation()
    expectation.isInverted = isInverted
    
    publisher
        .dropFirst(dropCount)
        .sink { receiveValue in
            assertions(receiveValue)
            expectation.fulfill()
        }
        .store(in: &cancellables)
    
    wait(for: [expectation], timeout: timeout)
```

::: note warn 
**AsyncTestの初期化はTestのclassごとに**
　他のテストからstoreされるパブリッシャーとはあまり混在させたくないので
　各テストのclass内で初期化して使うのがベターな気がします
```swift
class HogeTest: XCTestCase {
    let asyncTest = AsyncTest()
    ...
}
class PiyoTest: XCTestCase {
    let asyncTest = AsyncTest()
    ...
}
```
:::

::: note 
**expectation.isInvertedについて**
デフォルトは`false`で、「**fulfillされればテストが通る**」という挙動になり
`true`にすると「**fulfillされなければテストが通る**」という逆の挙動になります
つまり「**パブリッシュされないこと**」のテストができます
:::


# ●何が嬉しいか

## 今までより簡単だけど、違和感なく書ける
`dropFirst`と`sink`のチェーンの書き方は
今までとあまり変化なく書けるので個人的に嬉しい

::: note warn
変化なくとは言いつつも、`dropFirst`も`sink`も
`Combine`ではなく`AsyncTest`のメソッドなので完全に別物です
:::

## クロージャーの引数を好きに変えれるので読みやすい
例えば`title`というパブリッシャーに対するテストの場合は
クロージャーの引数を`title`とできるので、可読性も今までと変わらず書ける
（当たり前と言えば当たりまえ）

```swift
    .sink(viewModel.$title) { title in
        XCTAssertEqual(1, title)
    }
```

# ●懸念点

## timeoutのエラー文がTest側に表示されない
アサーションでテストが落ちた場合は
Assert文自体はクロージャーの中身のためTestに記述されているので
ちゃんとテスト側に赤いメッセージが出るのですが
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2633746/8a46e57b-72f7-9a65-348f-a9adbf5a9b21.png)
timeoutでテストが落ちた場合
`wait`の記述がAsyncTest側なのでテスト側には何も表示されない
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2633746/91f29a6b-a77f-3f6c-37c7-2db6ebe84a62.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2633746/2a4e9e58-1793-4289-1bdf-8372e3473f9c.png)
まあ、アサーションで落ちてなかったらよっぽどタイムアウトなんですが
なんかパッと見分からないのが嫌ですね。。。

アイデアあったらぜひコメントください！！

# ●おわりに
スッキリ改善できたけど`timeout`のところはなんとかしたいですね

ここまで読んでいただいてありがとうございました！
誰かの役に立てれば幸いです


さっ、今までの非同期テストぜーんぶ直すか。。。