【まとめ】Engineering for Testability (前半)【WWDC 2017】

# はじめに

この記事では、WWDC 2017でのセッション [Engineering for Testability](https://developer.apple.com/videos/play/wwdc2017/414/)の内容をまとめます。
このセッションでは、テストがしやすいコードの書き方(前半で紹介)や、保守・スケールしやすいテストコードの書き方(後半で紹介)などが紹介されました。
また、テストコードの大切さについても話されています。
図は[Appleが公開しているスライド](https://devstreaming-cdn.apple.com/videos/wwdc/2017/414qr3121b1oieq/414/414_engineering_for_testability.pdf)から引用しました。
記事の内容に誤りなどがございましたら、気軽に、優しく、コメントしていただけると嬉しいです。
よろしければ[後半](http://qiita.com/MasayaHayashi724/items/1aa70b16b7163f56ab4b)もお読みください。
[こちら](http://qiita.com/MasayaHayashi724/items/72d08848e404683277f6)にXcode9からの新しいテストまわりのAPIが紹介されたセッションもまとめているので、合わせてお読みください。

# 読むのがめんどくさい方へ

結構長いので、すべて読むのがめんどくさい方はこれだけでも覚えていってください。

> Treat your test code with the same amount of care as your app code
アプリのコードと同じくらいテストコードに気を配ろう

> Code reviews for test code, not code reviews with test code
> テストコードと一緒にレビューするのではなく、テストコード自体をレビューしよう

つまり、

<img width="484" alt="スクリーンショット 2017-06-20 19.48.57.png" src="https://qiita-image-store.s3.amazonaws.com/0/149003/c2601bc7-58a3-a384-5f9b-d8fdbd8252de.png">

ではなく、

<img width="373" alt="スクリーンショット 2017-06-20 19.49.13.png" src="https://qiita-image-store.s3.amazonaws.com/0/149003/04bce4d5-d73b-96e4-bf19-ad84d172e156.png">

である。

# まとめ

## Testable app code 〜テストができるアプリコードを書く〜

### Structure of a Unit Test

例えば`sorted()`メソッドは以下のようにUnit Testすることができる。
Unit Testは **「入力を準備」→「テストされるコードの実行」→「出力を評価」** という構成になっている。

```swift
func testArraySorting() {
    let input = [1, 7, 6, 3, 10]
    let output = input.sorted()
    XCTAssertEqual(output, [1, 3, 6, 7, 10])
}
```

### Characteristic of Testable Code

テストができるコードの特徴は次の3つである。

- 入力を制御できる
- 出力に可視性がある
- 隠れた状態がない

### Testability Techniques 〜テストができるアプリコードの書き方〜

ここでは、2つのテストしにくいコードをテストできるコードに変更していく例を紹介します。
1つ目では、プロトコルとパラメータ化を利用してテストできるコードにしていきます。
2つ目では、ロジックとその効果を分離することでテストできるコードにしていきます。

#### テストしにくいコード その1

「ドキュメントを選択し、閲覧するか編集するかを選んで開く」アプリを考える。

<img width="320" alt="スクリーンショット 2017-06-20 18.09.10.png" src="https://qiita-image-store.s3.amazonaws.com/0/149003/6da20269-2a15-e7ac-b47a-ffca1fafb3b4.png">

Openボタンを押した際のアクションが次のように実装されていたとする。

```swift
@IBAction func openTapped(_ sender: Any) {
    let mode: String
    switch segmentedControl.selectedSegmentIndex {
    case 0: mode = "view"
    case 1: mode = "edit"
    default: fatalError("Impossible case")
    }
    let url = URL(string: "myappscheme://open?id=\(document.identifier)&mode=\(mode)")!
    if UIApplication.shared.canOpenURL(url) {
        UIApplication.shared.open(url, options: [:], completionHandler: nil)
    } else {
        handleURLError()
    }
}
```

そして上記のコードをテストするコードを下記のように考えると、`// ???`の部分のコードで何を`assert`すれば良いのかわからなくなってしまう。

```swift
func testOpensDocumentURLWhenButtonIsTapped() {
    let controller = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "Preview") as! PreviewViewController
    controller.loadViewIfNeeded()
    controller.segmentedControl.selectedSegmentIndex = 1
    controller.document = Document(identifier: "TheID")

    controller.openTapped(controller.button)

    // ???
}
```

#### コード その1の問題点

- `@IBAction`にすべての処理定義してしまっているため、テスト対象の入力がわからない
  - テストができるコードの特徴の1つ「**入力を制御できる**」に反する
- `segmentedControl.selectedSegmentIndex`を利用しているため、入力以外にも情報が必要
  - テストができるコードの特徴の1つ「**隠れた情報がない**」に反する
- `canOpenURL(_:)`など、返り値が予想しにくいメソッドを使用している
  - テストができるコードの特徴の1つ「**隠れた情報がない**」に反する
- ドキュメントを開く動作をどうテストしていいかわからない。
  - テストができるコードの特徴の1つ「**出力に可視性がある**」に反する

#### コード その1の改善方法

まずはドキュメントを開くクラスを分離する。
Openボタンが押されたときのアクションをテストするのではなく、このクラスをUnit Testするようにすると、入力の制御が可能になる。
ついでに「閲覧」と「編集」の状態も`enum`で管理するようにリファクタリングしておく。

```swift
class DocumentOpener {
    enum OpenMode: String {
        case view
        case edit
    }
    func open(_ document: Document, mode: OpenMode) {
        let modeString = mode.rawValue
        let url = URL(string: "myappscheme://open?id=\(document.identifier)&mode=\(modeString)")!
        if UIApplication.shared.canOpenURL(url) {
            UIApplication.shared.open(url, options: [:], completionHandler: nil)
        } else {
            handleURLError()
        }
    }
}
```

Xcode9から、アプリを複数起動してそれぞれにテストを実行する機能が登場することなどから、`UIApplication.shared`への参照は極力減らしたほうがいいので、次のようにInitializerに`UIApplication`を渡せるようにすると良い。
それに伴って、`open(_:mode:)`メソッド内の`UIApplication.shared`も修正する。

```swift
class DocumentOpener {
    let application: UIApplication
    init(application: UIApplication = UIApplication.shared) {
        self.application = application
    }

    /* … */

    func open(_ document: Document, mode: OpenMode) {
        let modeString = mode.rawValue
        let url = URL(string: "myappscheme://open?id=\(document.identifier)&mode=\(modeString)")!
        if application.canOpenURL(url) {
            application.open(url, options: [:], completionHandler: nil)
        } else {
            handleURLError()
        }
    }
}
```

次に、テストから返り値が予想しにくい`canOpenURL(_:)`などによる問題を解決するために、テスト用のモックを作成する。
そのために次のように`protocol`を定義しておく。
`canOpenURL(_:)`などは`UIApplication`には元から実装されており、モックをこのプロトコルに準拠させれば、返り値がテストから予想できる`canOpenURL(_:)`が使用できるようになる。

```swift
protocol URLOpening {
    func canOpenURL(_ url: URL) -> Bool
    func open(_ url: URL, options: [String: Any], completionHandler: ((Bool) -> Void)?)
}
extension UIApplication: URLOpening {}
```

それに伴って、先ほどの`DocumentOpener`のInitializerまわりも`application: UIApplication`→`urlOpener: URLOpener`と修正しておく。

```swift
class DocumentOpener {
    let urlOpener: URLOpening
    init(urlOpener: URLOpening = UIApplication.shared) {
        self.urlOpener = urlOpener
    }

    /* … */

    func open(_ document: Document, mode: OpenMode) {
        let modeString = mode.rawValue
        let url = URL(string: "myappscheme://open?id=\(document.identifier)&mode=\(modeString)")!
        if urlOpener.canOpenURL(url) {
            urlOpener.open(url, options: [:], completionHandler: nil)
        } else {
            handleURLError()
        }
    }
}
```

そして、上記のプロトコルを利用してテスト用のモックを次のように作成する。

```swift
class MockURLOpener: URLOpening {
    var canOpen = false
    var openedURL: URL?

    func canOpenURL(_ url: URL) -> Bool {
        return canOpen
    }

    func open(_ url: URL, options: [String: Any], completionHandler: ((Bool) -> Void)?) {
        openedURL = url
    }
}
```

これで準備完了です。
次のようにテストすることができるようになりました。

```swift
func testDocumentOpenerWhenItCanOpen() {
    let urlOpener = MockURLOpener()
    urlOpener.canOpen = true
    let documentOpener = DocumentOpener(urlOpener: urlOpener)
    documentOpener.open(Document(identifier: "TheID"), mode: .edit)
    XCTAssertEqual(urlOpener.openedURL, URL(string: "myappscheme://open?id=TheID&mode=edit"))
}
```

#### テストしにくいコード その2

ディスク上のキャッシュデータを管理する次のようなクラスが実装されていたとする。

```swift
class OnDiskCache {
    struct Item {
        let path: String
        let age: TimeInterval
        let size: Int
    }
    var currentItems: Set<Item> { /* … */ }

    /* … */

    func cleanCache(maxSize: Int) throws {
        let sortedItems = self.currentItems.sorted { $0.age < $1.age }
        var cumulativeSize = 0
        for item in sortedItems {
            cumulativeSize += item.size
            if cumulativeSize > maxSize {
                try FileManager.default.removeItem(atPath: item.path)
            }
        }
    }
}
```

#### コード その2の問題点

- 入力の1つと考えられる`currentItems`がディスク上のデータ
  - テストができるコードの特徴の1つ「**入力を制御できる**」に反する
- 出力がディスク上のデータ
  - テストができるコードの特徴の1つ「**出力に可視性がある**」に反する

#### コード その2の改善方法

次のようにプロトコルを定義し、テスト対象とする`struct`をそれに準拠させる。

```swift
protocol CleanupPolicy {
    func itemsToRemove(from items: Set<OnDiskCache.Item>) -> Set<OnDiskCache.Item>
}

struct MaxSizeCleanupPolicy: CleanupPolicy {
    let maxSize: Int
    func itemsToRemove(from items: Set<OnDiskCache.Item>) -> Set<OnDiskCache.Item> {
        var itemsToRemove = Set<OnDiskCache.Item>()
        var cumulativeSize = 0
        let sortedItems = allItems.sorted { $0.age < $1.age }
        for item in sortedItems {
            cumulativeSize += item.size
            if cumulativeSize > maxSize {
                itemsToRemove.insert(item)
            }
        }
        return itemsToRemove
    }
}
```

上記のように実装すると、入力にあったディスク上のデータが`items`となり、出力が`Set<OnDiskCache.Item>`となるので、次のようにテストすることができるようになります。

```swift
func testMaxSizeCleanupPolicy() {
    let inputItems = Set([
        OnDiskCache.Item(path: "/item1", age: 5, size: 7),
        OnDiskCache.Item(path: "/item2", age: 3, size: 2),
        OnDiskCache.Item(path: "/item3", age: 9, size: 9)
    ])
    let outputItems = MaxSizeCleanupPolicy(maxSize: 10).itemsToRemove(from: inputItems)
    XCTAssertEqual(outputItems, [OnDiskCache.Item(path: "/item3", age: 9, size: 9)])
}
```

さいごに、`OnDiskCache`クラスのキャッシュ削除のためのメソッドを次のように修正しておくのを忘れずに。

```swift
class OnDiskCache {
    /* … */
    func cleanCache(policy: CleanupPolicy) throws {
        let itemsToRemove = policy.itemsToRemove(from: self.currentItems)
        for item in itemsToRemove {
            try FileManager.default.removeItem(atPath: item.path)
        }
    }
}
```

## [後半](http://qiita.com/MasayaHayashi724/items/1aa70b16b7163f56ab4b)へ続く

最後までお読みいただき、ありがとうございました！
後半もありますので、よろしければそちらもお読みください。
後半は「保守・スケールしやすいテストの書き方」です。