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

はじめに

この記事では、WWDC 2017でのセッション Engineering for Testabilityの内容をまとめます。
このセッションでは、テストがしやすいコードの書き方(前半で紹介)や、保守・スケールしやすいテストコードの書き方(後半で紹介)などが紹介されました。
また、テストコードの大切さについても話されています。
図はAppleが公開しているスライドから引用しました。
記事の内容に誤りなどがございましたら、気軽に、優しく、コメントしていただけると嬉しいです。
よろしければ後半もお読みください。
こちらに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
テストコードと一緒にレビューするのではなく、テストコード自体をレビューしよう

つまり、

スクリーンショット 2017-06-20 19.48.57.png

ではなく、

スクリーンショット 2017-06-20 19.49.13.png

である。

まとめ

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

Structure of a Unit Test

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

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

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

スクリーンショット 2017-06-20 18.09.10.png

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

@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すれば良いのかわからなくなってしまう。

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で管理するようにリファクタリングしておく。

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も修正する。

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(_:)が使用できるようになる。

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

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

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

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

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

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

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

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

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をそれに準拠させる。

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>となるので、次のようにテストすることができるようになります。

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クラスのキャッシュ削除のためのメソッドを次のように修正しておくのを忘れずに。

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

後半へ続く

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


『 Swift 』Article List