テストコードのないプロジェクトにテストを根付かせる


はじめに

テストコードがない、あるいはテストコードが存在はするがあまり機能していないプロジェクトに途中から参画したとき、あなたならどうしますか?「テストを書きましょう」と言うだけではチームは中々動きません。

テストコードが浸透していないコードベースには必ず理由があります。テストを書く時間がない、テストの必要性が共有されていない、あるいはやむを得ない事情で書いていないなど。まずは既存コードとそれを書いてきたチームに敬意を払い、背景を丁寧にヒアリングすることが出発点です。

本記事ではテストを導入するための技術的なステップはもちろん、チームの合意を得るための対話の進め方を共有します。

※本記事で言及するテストとはJavaのJUnitやTSのVitestなどのテストフレームワークによって実装されるテストコードを指しています。

現状分析

まずはコードベースをgitからcloneし、テストコードの実装状況を確認するところから始めます。
テストが全くのゼロの状態の場合、残念ながらコードベースからわかることはあまりありません。テストコードがない代わりに他にどんなテストを行っているのかを確認します。Playwright, Selenium, マニュアルテストなど何かしらのE2Eテストを行っていることが多いはずです。
テストがある場合、そのテストが正しく実装・運用されているかどうかを確認します。これはテストコード自体はあるものの、何かしらの問題点が残っているケースを指します。

実際に私が経験したパターンは以下です

  • 一部の機能にだけテストコードがあるが大部分の機能にはテストがない
  • テストはあるが実行してみるとエラーになる
  • Unit Testは実装されているがIntegration Testは実装されていない
  • テストは存在するがCIで自動実行する基盤がない

テストがない/少ない原因の解明

現状がわかったら次は原因を考えます。

よくあるケースは、

  • A:テストを書くという発想がない
  • B:テストを書きたいけど理由があって書いていない

です。
Aはレガシーな現場でありがちなケースです。この場合、「テストコードとは何か?」という話を1から啓蒙していく必要があります。

Bの場合、なぜテストを書けていないのですかと聞いて返ってきがちな回答は「テストを書く時間がないから」です。これは一見その通りに聞こえるかもしれない理屈ですが、少し考え方を変えてみて欲しいです。テストを書くから時間がなくなるのではなく、「早く開発するためにテストを書く」という感覚を持つと良いです。

テストの強化を提案する

現状分析と原因がわかったら、いよいよテストの強化に入ります。
ですが、ここ単にで「テストを書きましょう!」と発言するだけではいけません。ここでどういう言い方をするかが一番重要かつ慎重に振る舞うべきポイントです。

既存コードベースに敬意を払う

今回のテストの例に限った話ではありませんが、コードベースに対する改善提案を出す場合の前提のマインドとして「既存コードに敬意を払う」というところを自分は大事にしています。現在のコードベースがどんなにひどい状態だったとしても、そのコードベースを何ヶ月・何年も面倒をみてきたメンバーを批判する理由にはなりません。(批判したくなる気持ちはとてもわかります)ビジネスサイドの事情や納期の事情など様々な理由で、ベストプラクティスから逸れた実装をせざるをえないシーンはよくあります。

まずは既存メンバーに対して感謝の気持ちを伝えましょう。
彼が過去にどんな苦痛を味わってきたのか。コミットログ・チケット・slackの履歴などから情報を得て、コードの歴史を知りましょう。

立ち回りのポイント

  • 最初から全機能のテストを描こうとしない
    • ハッピーパスだけに絞ってテストを書く
    • 異常系・エッジケースは時間と共にじわじわ増やすつもりで考える
  • テストの実装は自分もやる、むしろ自分がやる
    • 「実装してください」と言うだけでは弱い
    • 自ら実装し効果を確かめる

テストを実装する

テスト強化の合意がチーム内で取れたら、いよいよテストの実装に入ります。
もし既存のテストコードがある場合、そのテストが正しく動いているかをまず確認しましょう。テスト実行結果はOK/NGどちらか、OKだったとしてテストはプロダクトコードを正しく検証できているかを確認します。あまりにひどいテストだったら思い切って削除してしまうのも一つの手だと思います。

テストが全くない場合、まずはハッピーパスを満たすテストを一本書きましょう。細かいエッジケースは後からで良いです。

最初に書くテストは古典学派寄りのUnit Testから

※「古典学派」が何かというのは後ほど解説します。
最初に書くテストは、極力モックを使わず、振る舞いをベースとしたテストケースに対して実装することをお勧めします。以下、SpringBootで例えます。

以下のようなSomeController.javaがあったとします。

@RestController
@RequiredArgsConstructor
public class SomeController {

    private final HogeService hogeService;

    public ResponseEntity<Void> index() {
        hogeService.methodA();
        hogeService.methodB();
        hogeService.methodC();
        return ResponseEntity.ok().build();
    }
}

このコントローラはHogeServiceのメソッドmethodA ~ methodCを呼び出すものとします。こSomeController.index()に対するテストケースを考えます。

Unit Testと聞くと、関数単位で実行するてすとをイメージする方が多いかもしれません。今回の場合だとmethodA, methodB, methodCそれぞれのテストを個別に実装し、SomeController.index()のテストではmethodA, methodB, methodCをMockitoなどでモックにした上でテストを書く、といった感じです。

しかし、ここで私が述べている「振る舞いベースのテストケース」ではこのような関数単位のUnitTestは書きません。index()メソッドが実行する全ての処理をテストの中でも同じように実行します。モックは極力使いません。methodA, methodB, methodCは全て本物のHogeServiceから実際にコールします。もしmethodA ~ CのなかでDBへのアクセスがある場合は、テストように本物のDBを用意して、実際にDBへアクセスします。ただし例外として、外部サービスのAPIや、AWSなどのクラウドリソースを利用する処理がある場合はモックを利用します。

このように、テスト対象メソッドの振る舞いを可能な限り全て実行するスタイルを「古典学派」のUnit Testと呼びます。
※古典学派に対して、ロンドン学派という考え方もあります。ロンドン学派はモックを積極的に利用して関数単位でのUnitTestを書きます。どちらが良い・悪いという話ではなく、状況に応じて使い分けるべき考え方です。

今回古典学派のテストを書いた理由は、古典学派の方がロンドン学派よりもテストの効能を感じやすいからです。
ロンドン学派のテストでは、関数単位でUTが出来上がるだけで、巨大なコードベースに対して数個の関数UTができたとてあまり恩恵はありません。また、モックを多用する関数単位のUTは実装の詳細に依存しやすい傾向があり、偽陽性のテストを産みやすくなります。

一方で古典学派のテストは、振る舞いをベースとしたテストを書きます。INにはテスト対象メソッドの引数を受け取り、OUTにはメソッドの戻り値やDBに書き込まれたレコードを取ります。このIN/OUTはリファクタリングに強く、ロンドン学派のように偽陽性を生むリスクが低くなります。

また、Controllerのメソッドの単位で振る舞いベースのテストを書くことで、今までローカル環境・検証環境で行っていたマニュアルテストを自動化できる可能性が高いです。この自動化は自動テストの恩恵として明快で、効能を感じやすいです。

余談:テストカバレッジにこだわりすぎない

テストの実装方法についての話になったので、テストカバレッジについても補足的に触れておきます。
カバレッジはほどほどに気にするのが良いと自分は考えています。具体的には85~90%程度を数値目標として掲げるべきで、間違っても100%を目指す必要はないと思います。理由は大きく二つあります。

  • 90%から100%への道のりが険しいかつ旨みがない
    • 90->100%への過程を経験したことのある方にはわかると思いますが、この作業は割と退屈なことが多いです。
    • IOExceptionのcatch節、業務上通ることのないelseルート、コアロジックとは関係のない箇所のカバレッジ
    • このような箇所に対してテストを書いていくことになります。
    • 100%であること自体は見栄えは良いかもしれませんが、
  • カバレッジは「自分が認知しているケース」にしか効かない
    • カバレッジが100%だからと言って全てのテストケースに対応できている保証にはなりません
    • 例えば先ほどmethodA, methodB, methodCを実装しましたが、実は業務的にはmethodDが必要だった!なんてことはよくあります。
    • この場合、テストコードはmethodDをカバーしていないため全てのケースを網羅できていないことになりますが、テストカバレッジとしてはABCをカバーしているので100%となります。
    • つまり、カバレッジとは自分が認知しているケースに対してしか効力を発揮できないのです

CIによる自動化は必須

テストが完成したら、そのテストが自動で実行される基盤を実装しましょう。
CIによるテストの自動実行は必須要件です。タイミングはチームの方針にもよりますが、一般的にはPRが作成された時点で自動で実行されるべきです。

テストは、自動実行されてこそ輝きます。
むしろ、自動で実行されないテストはいつか必ず腐ります。開発者の手動実行に依存する運用はそのうち実行されなくなります。自動実行によって強制的にテストを動かすことで、プロダクトコードの不具合やあるいはテストコード自体の誤りに早期に気づくことが可能になります。

自動実行の実装方法自体に私はこだわりはありません。
GitHubを使っているならGitHub Actions、GitLabならGitLab CI、あるいはAWSのCodeBuildなども選択肢に上がるでしょう。

そして、自動実行の結果は必ず開発者に通知しましょう。これはテストが失敗したことを気づかずに(あるいは見て見ぬふりをして)開発を進めてしまうことを防ぐためです。GitHub Actionsであれば「CIが通ってないPRをマージさせない」という設定を課すことも可能だったはずです。(自分はあまりこの設定が好きではないですが、、、)

小さな成功体験を作る(ゴール)

「テストがあってよかった!」と思える成功体験を作りましょう。
大きなもの出なくても構いません。テストを正しく実装していれば、そのうちこの体験は実感できることでしょう。例えば、

  • 機能改修時にデグレに気づくことができた
  • マニュアルテストの対応コストが減った

などです。

成功体験をメンバーが実感することで、よりテスト実装へのモチベーションが高まります。

テストを書く習慣がつくまで見守る

最後のステップとして、テストを書く習慣がメンバーに浸透するまで見守りましょう。
これは言い方を変えると、テストを書くことをサボるメンバーを指摘するということでもあります。
忙しさを理由にしてテストが書かれない、ということはよく起きます。個人的にはテストを書かない方が最終的な開発リードタイムは延びると考えているので、忙しさはテストをサボる理由にはならないと思っているのですが、プロジェクトの状況によっては一分一秒すら惜しいこともあり得るとは思います。メンバーと相談して、のっぴきならない事情がある場合はテストを書かずに(あるいは落ちるテストを一時的にコメントアウトして)マージすることもよしとしています。ただし、サボったテストは後から必ず実装するように、チケットなどで管理しておく必要があります。

テストをサボることを許容してしまうと、その文化がじわじわと広がっていきまたテストのないプロジェクトに戻ってしまいます。こうならないように、テストを書く習慣が浸透するまでじっくり見守っていく必要があります。