24/7 twenty-four seven

iOS/OS X application programing topics.

iOSDCでテストしづらいコードをテストしやすくするための方法について話しました

speakerdeck.com

日本で開催されるもっとも大きなiOSに関するカンファレンスの1つであるTop | iOSDC Japan 2017に参加し、表題の内容で発表しました。

聴いてくださった方々からは好評のようでよかったです。発表資料は本題と関係のない話がちょこちょこ挟まったり、口頭の説明がないとわからないページがあり、スライドだけでは意図がよく伝わらない恐れがあるので、こちらで内容について補足します。

伝えたかったテーマは「依存が大きく複雑で、単体でテストしづらいコードを単体で動かしてテストできるようにするには」ということです。その題材として一般的に依存が複雑でテストしづらいコンポーネントであるビューを例として取り上げました。ですのでビューやUIをテストするということに絞った話ではなく、どのレイヤーに対しても複雑にいろいろな依存関係があってユニットテストが書けないという状況を改善するための基本的な考え方です。

このことをチームで定式化したりシステマチックにやるならMVVMやMVP、などのデザインパターンやフレームワークを適用するという考えに発展します。発表のあとで何人かと話したり私がいろいろなところで聞いている印象だと、MVVMやMVP、VIPERを使っているが特にテストは無い、というプロジェクトはそこそこあると思っています。

発表でも触れましたが、分割するということは最初の大きな一歩なのでそれを否定するものではありませんが、やはりVをVMやPに分けるということはテストを書けるようにするということが基本的なモチベーションであることと、テストが書けるかどうかはm本当に依存を切り離せて疎結合になっているかどうかを確かめるもっとも簡単な方法ですので、単純に分割しただけでは、何のためにそうしているのかという問いに答えるのは難しいのではないでしょうか。

発表内容について

発表では実装の一例として私がメンテナンスしているOSSの (SpreadsheetView)https://github.com/kishikawakatsumi/SpreadsheetViewというライブラリのlayout()メソッドをテストしやすい形にリファクタリングする様子を使いました。誤解しないでほしいことは、ビューを使ったのはあくまでも実装の一例であって、内容はビューだけに関係する話ではなく、一般的に複雑な依存を持つコードをテストしやすい形にするということをお話しています。実装なしで、単に「依存が大きく複雑なコードをテストするには依存を取り除く必要がある」と話してもピンとこないと思うのであくまでも実装の例として使ったということです。

テストしやすいコードとは

layout()メソッドは下記のようにSpreadsheetViewScrollViewに依存しています。layout()メソッドはSpreadsheetViewScrollViewのプロパティやメソッドを必要とするので、このメソッドを実行するにはこの2つのビューのインスタンスを用意して、正しく設定しなければならず、単体で実行することはできなくはないが、とても大変なので現実的ではないという状況です。

final class LayoutEngine {
    private let spreadsheetView: SpreadsheetView
    private let scrollView: ScrollView
    ...

    init(spreadsheetView: SpreadsheetView, scrollView: ScrollView) {
        self.spreadsheetView = spreadsheetView
        self.scrollView = scrollView

        intercellSpacing = spreadsheetView.intercellSpacing
        defaultGridStyle = spreadsheetView.gridStyle
        circularScrollingOptions = spreadsheetView.circularScrollingOptions
        ...
    }
    
    func layout() {
        guard startColumn != columnCount && startRow != rowCount else {
            return
        }
        
        let startRowIndex = spreadsheetView.findIndex(in: scrollView.rowRecords, for: visibleRect.origin.y - insets.y)
        cellOrigin.y = insets.y + scrollView.rowRecords[startRowIndex] + intercellSpacing.height
        
        for rowIndex in (startRowIndex + startRow)..<rowCount {
            ...

                scrollView.insertSubview(cell, at: 0)
        }
        ...
    }


    private func enumerateColumns(currentRow row: Int, currentRowIndex rowIndex: Int) -> Bool {
        ...
        while columnIndex < columnCount {
            ...
            scrollView.insertSubview(cell, at: 0)
        }
    }

    ...
}

つまり、単体でテスト可能にするためには依存を取り除く必要があります。

ここで依存関係には2種類あり、依存関係の状態(プロパティ)に依存している場合と、振る舞い(メソッド)に依存している場合があります。layout()メソッドは両方に依存しています。

状態の分離

状態への依存を取り除くためには状態をモデルに分離します。これは大規模であってもそれほど難しくはありません。

struct SpreadsheetViewConfiguration {
    let intercellSpacing: CGSize
    let defaultGridStyle: GridStyle
    let circularScrollingOptions: CircularScrolling.Configuration.Options
    ...
}

struct DataSourceSnapshot {
    let frozenColumns: Int
    let frozenRows: Int
    ...
}

init(spreadsheetViewConfiguration: SpreadsheetViewConfiguration,
     dataSourceSnapshot: DataSourceSnapshot,
     scrollViewConfiguration: ScrollViewConfiguration,
     scrollViewState: ScrollView.State) {
    ...
}

依存関係が持つプロパティからメソッドの実行に必要なものを抽出し、性質によって分類、3つのモデル(Struct)として分離しました。こうすることで、ビュー自体をセットアップするという複雑な手順ではなく、モデルに必要な値を設定して渡せるようになりました。

振る舞いの分離

一方、振る舞いの依存を分離するためにはモックに置き換えるという方法があります。今回はscrollView.insertSubview(cell, at: 0)というメソッドの呼び出しをモックに置き換えます。

本当のオブジェクトではなくモックを渡せるようにするには、オブジェクト自体ではなくインターフェースの依存に変更し、同じインターフェースを持つ別のオブジェクトを渡せるようにします。実装ではなくインターフェースに依存することで、別の実装を渡せるようにするということです。

scrollViewオブジェクトが持つすべてのメソッドをインターフェースに分離するのは大変で、コストが合わないかもしれないので、今回はscrollView.insertSubview(cell, at: 0)だけをモックにするというテクニックを紹介しました。

基本的なやり方は変わらず、まず共通のインターフェースを用意します。scrollViewの代わりにこのインターフェースに依存します。そうすることでこのインターフェースに適合していればscrollViewではないオブジェクトを適当に作って渡すことができます。

protocol ViewLayouter {
    mutating func layout(cell: Cell)
}

プロダクションのコードでは次のようにscrollViewを内部にもち、もともとのscrollView.insertSubview(cell, at: 0)を呼び出すオブジェクトを渡すように変更します。元の動作はまったく変わっていません。

struct Layouter: ViewLayouter {
    let scrollView: ScrollView

    func layout(cell: Cell) {
        scrollView.insertSubview(cell, at: 0)
    }
}

テストコードでは実際のビューの代わりにビューのメタデータだけを保持する別のオブジェクトを渡します。こうして依存関係を取り除き、このメソッドの実行には実際のビューを用意する必要はなくなりました。また、ビューがレイアウトされた結果をビューではなく、別のわかりやすいデータ構造で検証できるようになりました。

struct DebugLayouter: ViewLayouter {
    var cells = [CellInfo]()

    mutating func layout(cell: Cell) {
        cells.append(CellInfo(frame: cell.frame,
                              indexPath: cell.indexPath))
    }
}

まとめ

発表した内容で重要な部分をまとめました。

テストしやすいコードとは良いコードで、複雑なコードをテストしやすくするには、依存関係を分離していくことが有効です。

当たり前のことを話しているだけですが、ソフトウェア開発の複雑さに対抗する手段として、非常い広く応用できる基本的な考え方であることがおわかりいただけると思います。なんとなくMVVMやMVPを使っていたのなら、これまでよりも明確に目的を持って使えるようになると思います。

より詳しくはスライドと、後日公開される発表の録画をご覧ください。質問や批評などがありましたらいつでも連絡してください。