テストがなかった無法地帯にテストを導入して開発速度を1.7倍にした話

テストがなかった無法地帯のプロジェクトに自動テストを導入して、開発速度を1.7倍にした話をします。

自動テストがなぜないのか

自動テストのないプロジェクトには、そうなる理由が必ず存在します。よくみる理由は、「時間がないから1」「テストの書き方がわからないから」「無理やりテストを書いたつらい経験があったから2」といったものです。今回のプロジェクトの場合は、以下の2点でした:

  • 自動テストの書き方がわからないから
  • レビューがテスト代わりだったから

まず、チーム編成が変わって私ともう一人がチームに加わるまで、実装者の中に自動テストの経験者はいませんでした。このような状況では、自動テストは困難になります。なぜなら、何をどうやってどこまでテストするかを決めるには、多少の慣れが必要だからです。この慣れがないと、何をしたらいいかわからないという状態に陥りがちで、結果として自動テストが後回しにされてしまいます。

また、最低限の品質担保はコードレビューでしていたそうで、開発者はそのような状況に慣れていました。つまり、見かけの上では現場に自動テストが必要なかったのです。

このような開発プロセスをとるチームは、知らずのうちにいくつかの問題に直面しています。その問題をみていきしょう。

自動テストがないと何が起こるのか

まず、自動テスト代わりのコードレビューには効率性の問題があります。コードレビューに過剰に時間がかかるようになるのです。

その理由を説明しましょう。コードレビューで品質を担保するとなると、バグの見逃しの責任の大部分がレビューアにのしかかります。すると、レビューアは過剰に慎重になり、コードをくまなく目視で検査するようになります。結果としてコードレビューに多くの時間が費やされることになるのです。

さらに、レビューアの責任の重さは別の問題も引き起こします。責任の重さによってレビューアの心理的な抵抗が高くなり、レビューの着手を遅らせてしまうのです3。結果として、ただでさえ遅いレビューがさらに遅くなるという悪い相乗効果が発生します。

また、自動テストがないプロジェクトでは、破滅的な設計がよくみられます。この破滅的な設計とは、たとえば複雑であったり、モジュール同士が密接合していたり、不安全な実装方法であったりといった、悪い設計のことです。このような設計はデバッグ時間の増大や、保守性の悪化を引き起こします。一見、自動テストと破滅的な設計は、関係のない事柄に見えます。これにはきちんとした関連があるので、後ほど紹介します。

この破滅的な設計の例として、状態をもつ Singleton があります。このような悪性の Singleton はとても凶悪で、自動テストにおいては天敵ともいえる存在です。この理由を理解するためには、自動テストの基本的な流れをおさえておく必要があります。

// 状態をもつ悪性の Singleton の例
class ExampleService {
    static let shared = ExampleService()

    // この内部変数が状態になっている。
    private var number: Int = 0

    func getCount() {
        return self.number
    }

    func increment() {
        self.number += 1
    }

    func reset() {
        self.number = 0
    }

    private init() {}
}

自動テストの基本的な流れは、適当な入力を与えてその出力を検証するといったものです。したがって、テストコードによる入力の制御がとても重要です。このとき、悪性の Singleton が入力にあると、テストコードから Singleton を意図した状態へもっていく必要がでてきます。すると、テストコードの実行前に Singleton の状態に応じた分岐を書く必要がでてきます。これはテストの本質とは関係のないコードであるため、テストコードの記述量を余計に増やす上に、見通しをとても悪くさせます:

// 悪性の Singleton を入力にもつコンポーネント。
class ExampleServiceUser {

    func doSomething() -> Bool {
        // 悪性の Singleton の状態に依存して処理が実行される。
        guard ExampleService.shared.getCount() == 5 { return }

        // テストしたいコード

        // NOTE: Singleton を操作しなければ、テストしたいコードが実行されない。
    }
}
import XCTest

// 上のコンポーネントのテストコード。
class ExampleServiceTest {

    func testIncrement() {

        // Singleton の状態が期待通りでなければ、意図した状態へもっていく。
        if ExampleService.shared.getCount() != 5 {
            ExampleService.shared.reset()
            for _ in 0..<5 { ExampleService.shared.increment() }
        }

        let example = ExampleServiceUser()
        let result = example.doSomething()

        // 結果を検証する。
        XCTAssertTrue(result)

        // NOTE: さらに悪性の Singleton の入力が増えたらもっと見通しが悪くなっていく。
    }
}

さらに、悪性の Singleton には感染性があります。悪性の Singleton に依存するコンポーネントも、また同じ性質を帯びやすくなるのです。まとめると、自動テストがない環境では、ますます自動テストを困難にする設計が生まれてくるのです。

では、これらの問題を解消するために、テストマン @Kuniwak が何をしたかをお話ししていきます。

テストマンは何をしたか

私がテストマンとしてプロジェクトに加わってからやったことは、以下の6つです:

  1. レビュー目的の訂正
  2. チームにあった自動テストの目的の設定
  3. 自動テストの知識共有
  4. 自動テスト環境の整備
  5. テスト容易設計の浸透
  6. 破滅的な設計の予防

レビュー目的の訂正

まず、非効率なコードレビューをやめるため、レビュー目的の再整理をしました。今までのレビュー目的は「バグを減らすため」でしたが、再整理の結果、「知識共有と可読性の検査」へと変わりました。すると、レビューアはさらっと読んで意味がわからないところや、よりよく書けるところだけを指摘すればよくなります。結果として、コードレビューの負担は減り、さらにとても短時間で実施できるレビューになりました。

しかし、ここで疑問に思うはずです。コードレビューで担保していたぶんの品質は誰が担保するのでしょうか。これの答えは実装者自身です。

実は、レビュー目的の再整理の際に、レビューの前提に「スモークテスト4されていること」を加えました。つまり、バグバグなコードを書いたツケは、実装者自身で支払うことになります。これによって、バグの責任はレビューアから実装者へと移り、レビューアの心理的な抵抗もとても軽くなりました。つまり、レビューの効率性の問題を解消したことになります。

しかし、動作確認の責任を負うことになった実装者は大変です。自分の書いたコードがほぼ動作しうることをきちんと確認できなければならないからです。これまでの方法では動作確認にとても時間がかかります。そこで、動作確認時間を短縮させるため自動テストの導入を提案しました。

チームにあった自動テストの目的を設定

今回は、自動テストの狙いを「開発速度を上げること」に絞り5、手動動作確認よりも自動テストの方が素早く確認できる箇所にのみ自動テストを導入することに決めました。

しかし、開発速度を上げる自動テストとはどのようなものでしょうか。それは、デバッグ時間を短縮できる自動テストのことです。

一般的に、デバッグ時間の多くは原因箇所の特定に費やされています。そして、原因箇所を探す範囲が小さければ小さいほど時間はかかりません。さて、原因箇所がもっとも絞り込まれているタイミングが実装直後です。たとえば、API を呼び出す関数を実装したとして、実装直後の動作確認で異常があれば、すぐにその関数がおかしいとわかります。しかし、もしこれが手動の動作確認だと、ある程度 UI に表示できるところまで組み立ててからの動作確認になってしまいます。つまり、実装してから時間が経過するほど、原因箇所を探す範囲が増えてしまうのです。ようするに、実装直後に自動テストをかければ、原因箇所の特定に時間がかからないため効率的なのです。これこそが開発速度を上げるための自動テストです。

rect4881.png

しかし、自動テストを書けばどこででも効率があがるわけではありません。たとえば、UI のバグは自動テストでは見つからないことが多く、自動テストに適していません。このように、どこにテストを書けば開発速度を上げられるのか判断するには、一定の経験が必要です。

次の節では、このようなテストの経験を知識として共有するためにおこなったことを説明します。

テストの知識共有

これまで述べた通り、適切なテストを書くためには知識と経験が必要です。しかし、チームメンバーはまだその知識と経験に乏しかったため、この部分のフォローをする必要がありました。そこで、私がお手本となる実際のテストコードを書き、コードレビューを通して学んでもらうことにしました。さっそくコードレビューの目的の再整理が活きた形になります。

また、知識をインプットするだけでなく、実際にテストコードを書いてアウトプットする時間も設けました。結果として、チームメンバーのテストスキルはメキメキ上がり、驚くことに記事をかいてくれるまでのレベルに至りましたやさしいSwift単体テスト~テスト可能なクラス設計・前編~)。これは本人の資質に助けられた部分も多くありますが、テストを学べる今回の環境もなければ実現しえなかったことだと考えています。

また、このようなテストの啓蒙と並行して、テスト環境の整備もおこないました。なぜなら、私のプロジェクトでは自動テストが壊れていて、すぐには動かせなかったからです。そこで、次の節ではどのようにして自動テスト環境を構築したかを説明します。

自動テスト環境の整備

まずは、自動テストが動作しない原因の調査から着手しました。調査の結果、原因は自動テストにまつわるXcode の設定漏れだったことがわかりました。今回の開発プラットフォームである iOS では単体テストにまつわる設定が多く、そのうちのいくつかが何かの拍子に消えてしまっていたようでした。まずはこれを修正し、手元でテストが動く状態までもっていきました。

さて、手元でテストが実行できるだけでは自動テスト環境が整ったとはいえません。なぜなら、手動実行によるテストコードは放置されやすいためです。そこで、自動でテストを実行する環境である、継続的インテグレーション(CI; Continuous Integration)環境の整備を始めました。ただ、私自身 iOS の CI 環境を立ち上げるのは初めてだったため、自分の力だけではうまく実行できない部分がでてきました。その部分は、プロフェッショナルである @henteko に CI 環境の整備業務を委託し、その過程で技術をみてやり方を覚えたりもしました。

さて、これでめでたく自動テストが回り始めたのですが、既存のコードには数多くテストしづらいものが残っていました。これらを放置できればよかったのですが、まだ未完成の部分が多かったため、手を入れる必要がでてきてしまいました。そこで、テストしやすいようにリファクタリングするフェーズが始まりました。

テスト容易設計の浸透

テストを導入して価値のある層の一つに API 層があります。しかし、そもそもテスト容易な API クライアントが存在していなかったため、まずは API クライアントの整備からはじめました。以前の API クライアントは継承を前提としたあまりよくない設計だったため、ここの部分では作り直しを決断しました。認証部分などでは少し苦労する部分もありましたが、最終的にはとてもテストが簡単な API クライアントを設計できました(そのときの例: Swift の HTTP ライブラリで苦しまないための自作 API クライアント設計)。そして、古い API クライアントをこの新しい API クライアントで徐々に置き換えていきました。この辺りのテクニックは、書いてもきりがないので、割愛します。

また、悪性の Singleton についても、徐々に依存を切り離していき、3ヶ月ほどかけて滅ぼしました。Singleton を埋め込むのは一瞬ですが、切り離すのにはこんなにも時間がかかるのです。この悪性の Singleton の排除が終わる頃には、すみずみまでテスト容易な設計が行き渡るようになりました。

さて、ここまで苦労して自動テストを導入にしたことで、何が嬉しかったのでしょうか。最終的な結果をみてみましょう。

最終的にどうなったか

自動テストを導入した結果、以下の3つのものを得られました:

  • 行ベースで開発速度が 1.7 倍になった
  • 悪性の Singleton や FatViewController は一つもなくなった
  • 給与が上がった

まず、プロジェクトの行ベースの開発速度が 2.5 倍になりました。人数は2→3になったので、1.5 倍ぶんは差し引いてみてください(2.5 / 1.5 = およそ 1.7 倍)。また、特にコメントや改行を増やすルールを追加したわけではないので、純粋に開発速度が上がったものと思われます。

rect4677.png

また、テストを意識することでチームの設計レベルが大幅に引き上げられました。テスト容易な設計では疎結合/副作用の少なさ/単責務を強制されるためです。具体的には Singleton や FatViewController 6は一つもレビューでみなくなりました。

さらに、この成果が認められて給与が上がりました。正当に評価されることは嬉しいですね。

終わりに

このように、私たちのプロジェクトで自動テストの導入は成功裏に終わりました。テストを導入することには様々なメリットがあります。ぜひあなたのプロジェクトにもテストマンを雇用されてみてはいかがでしょうか。

補足

LoC が不適切という声を多数頂いておりますが、LoC で目標や進捗を管理しているわけではありません。エンジニアが自主的に自己モニタリングとして始めたものを活用している形です。したがって、LoC の最大の欠点である恣意的に操作可能という点は問題にならないと考えています。

なお、テストを導入するためにやったことは下の資料にまとめてあります(private を public にしただけとかじゃないですよ :relaxed: ):


  1. テストを書かないから時間がなくなるという話は実際あります。 

  2. テスト容易でない設計に無理にテストを書くと、人間はとても消耗してしまいます。また、テストの網羅率を追いかけすぎて目的と手段が逆転した現場でも、人間はとても簡単に消耗してしまいます。 

  3. この傾向は決断疲れしている時に顕著で、心理学的には決断忌避(Decision Avoidance)と呼ばれる有名な現象です。 

  4. 実際に動かしてみて、煙が出ない程度に最低限動作することを確認するテストのことです(テスト方法は手動/自動のどちらでも構いません)。 

  5. なお、この決定にあたっては、経営陣からこのプロダクトに求めていることも参考にしています。このヒアリング結果として、「バグの少なさよりも開発速度を重視する」という要望が明らかになりました。この時点で、このプロジェクトには品質を確保するための自動テストは必要ないと判断しました。 

  6. ここでは、View のライフサイクル管理に複数の責務が入り混じった ViewController のことを FatViewController としています。なぜなら、ViewController のコードが N 行以内なら OK みたいなはっきりしたことはいえないからです。