nunulkのプログラミング徒然日記

毎日暇なのでプログラミングの話題について書きたいことがあれば書いていきます。

私がテストファーストでコードを書く理由

はじめに

この記事について

ちょっと前に X でテストファーストであることの意義みたいな話が流れていたのを見たのをきっかけに、テストファーストおよびテスト駆動開発について日頃思っていることをだらだら書いてまとめておこうと思います。

概要としては、テストファーストで書こうが書くまいがどちらでもよくて、要はスムーズに開発できる方法論を自分なりに確立できていればそれでいいんじゃないか、ということです。

前提として、私は「基本的にはテストファーストで書くが、いつもテストファーストで書くわけではない」派です。

テストファーストとは

プログラムを書くときに、テストコード(自動テスト)を先に書き、プロダクションコードを後に書くやり方のことです。反対に、プロダクションコードを先に書いて、テストコードを後に書くやり方はテストラストと呼ばれます。

テスト駆動開発との違いとしては、テストコードから先に書いてからプロダクションコードを実装する点は同じですが、テスト駆動開発では最初にテストを失敗させ、最低限の実装でテストをパスさせ、さらにテストを書いて失敗させ、…みたいなサイクルが決まっている点とそのサイクルにリファクタリングが組み込まれている点です。

テストファーストとテストラストの違いがもたらす結果

まず、テストファーストとテストラストのアウトプットのみに注目してみます。

そもそも自動テストがないプロジェクトもまだまだあるようですが(なかには自動テストを不要とみなしているマネジャーやエンジニアもいるようですし)、手動テストであっても納品前(リリース前)にテストがされていれば、品質を保つことは可能です。リリースしたコードセットが正しく動くことを確認できていれば、手動でも自動でもどちらでもいいわけです。その意味で、テストを先に書こうが後に書こうが、コード(動作)の正しさを確認できていれば、結果に変わりはないはずです。

実装コストとメンテナンスコストを考えると、結局のところ、どっちが自分に(自分たちに)とって素早く手間なく仕事を終わらせられるか、将来の仕事のコストを高めずに治安を維持できるか、というのが問題になります。

ハードウェアと比較した上でのソフトウェアの特徴として(ハードウェアはあまり詳しくありませんのでイメージで)、

  • すぐ変更できる
  • 構造が不安定である

という点があって、ソフト(柔軟)であることのメリットが、ソフトウェアが頻繁に変更される要因になっている一方で、そのメリットがもたらす負の結果として、壊れやすい・シンプルに保ちにくい、という問題を抱えることになります。

私自身のこれまでの経験では、プログラミングパラダイムによらず高品質で低コストなソフトウェアを作ることは可能であり、実装方法については実装者が実装工程を円滑に進めるための好みの問題であると思っていて、その意味でいうと、テストファーストとテストラストの違いがもたらす結果は、自分自身の経験からくる感想レベルの域を出ないんですが、以下の2点に集約されるかなと思います。

  1. テストファーストでは同じブランチ(プルリクエスト)に必ずテストが存在する(ただし、テストケースが十分かどうかは未定)
  2. テストラストではテストの書き漏れが発生する可能性や別のブランチ(プルリクエスト)に書かれる可能性がある

これはプロジェクトによりますが、テストがなくてもレビューを通すチームであれば、2 のパターンでも問題ないでしょう。私はプロダクションコードとテストコードは同一ブランチ(プルリクエスト)でレビューしたいので、セットになっていてほしいです。テストラストでもテストコードがセットになってなければレビューを通さない、という基準でやっていれば防げますし、逆に、テストファーストでやってたとしても私のように「いつもテストファーストで書くわけではない」場合は同様に漏れる可能性はあります。

なので、これはあくまでも頻度と可能性の問題になります。

実装過程におけるテストファーストの利点

次に、テストファーストが実装過程にもたらす利点を挙げます。

これもあくまで私自身が感じている利点であり、テストファーストで書いたからといって必ず得られるものではないかもしれません。

  1. 事前に実装の全体像が見えないケースで、少しずつ明らかにしながら実装を進められる
  2. リファクタリング時にバグを混入させる可能性を下げられる
  3. テストしやすいモジュール構成にしやすい

以下にひとつずつ詳説します。

1. 事前に実装の全体像が見えないケースで、少しずつ明らかにしながら実装を進められる

たとえばウェブAPIの開発で、機能の概要だけ理解していて、詳細(どう実装するか)が決まってない場合、テストファーストで書くと、インタフェースに近い部分から段階的に実装することができます。

例として Laravel でなんらかの予約処理を実装すると仮定して、テストコードを書いてみます。

<?php

class ReservationControllerTest extends TestCase
{
    function test_create(): void
    {
        $user = User::factory()->create();

        $params = [
            // not yet
        ];

        $response = $this
            ->actingAs($user)
            ->postJson('/reservations', $params);

        $response->assertCreated();
    }
}

とりあえず、「/reservations に POST でなんらかのリクエスト(JSON 形式)を送ると 201 が返ってくる」というところだけ確定させられます。

あとは、このテストが失敗しないようにプロダクションコードを書いていき、適宜必要になったタイミングでデータベースのテストやレスポンスデータのテストを追加したり、リクエストパラメータに複数のパターンが想定されるならパターンを増やしていったりしていけばいいわけです。

2. リファクタリング時にバグを混入させる可能性を下げられる

テストが失敗すればリファクタリングに失敗したとみなせるので、意図せず不具合を起こした際に即時に気づけます。

テストラスト方式でも、プロダクションコード→テストコード→リファクタリング、のフローであればリファクタリング時に間違いを検出することはできますが、テストファーストの場合、プロダクションコードの完成前の段階で小刻みにリファクタリングできるのがメリットです。とくに 1 に挙げたようなどうやって実装するか見えてないケースでより威力を発揮します。手探りでプロダクションコードを実装していくので、最初から最適な設計になることはほとんどなく、プロダクションコード→リファクタリング→プロダクションコード→リファクタリング→…の繰り返しを何度もすることになるので、テストがあり、そのテストが落ちなければリファクタリングが上手くいっている、という安心感が得られます。

テスト駆動開発はテストに関する技法ではなく、詳細設計と実装を手助けする技法であり、リファクタリングする際にもっともその恩恵を感じられるはずです。

3. テストしやすいモジュール構成にしやすい

複雑な条件判定のような処理はユニットテストがあったほうがいいですが、ユニットテストを書くにあたっては、依存するデータが必要最低限であることが望ましいです。

自分自身もそうですが、これまで関わってきたチームではユニットテストカバレッジはそれほど高くなく、必要であれば書く、という方針のチームがほとんどでした。この方針は合理的であると思っていて、フィーチャーテストに対してテストが書かれていて(ウェブAPIであれば最低でも各エンドポイントに対して一つ)、かつその中で使われる各ユニットが最低でも1回は呼び出されていれば(できればステートメントカバレッジはほしいですが)、わざわざユニットテストを書かなくてもいいと考えます。

ただし、条件分岐が複雑だったり、金額計算のようなミスの許されない部分だったり、ユニットテストが必要だと判断すれば、もちろんユニットテストを書きます。

あるていどテストファーストに慣れてくると、たとえ実装の全体像が見えていない段階だとしても「あ、この仕様に対してはユニットテスト買いといたほうがいいな」という判断ができるようになります。

上に挙げた「なんらかの予約処理」を再び例に取ると(ここではある施設の利用予約とします)、ある条件で予約時に出すオファー金額に割引が適用されるとします。割引条件を判定するモジュールにはユニットテストを書いておきたい、と思ったとして、あらかじめその部分だけを取り出して先にテストを書いておきます。ここで「その部分」をあらかじめクラスあるいはメソッドとして定義する動機が発生するので、最初からテストしやすいモジュール構成になっていきます。テストラストの場合でも、同様の判断ができるとは思いますし、テストファーストでもできないこともあるでしょうし、あくまでも実装者の判断力次第にはなってしまいますが、複雑な部分を最初に検証する癖をつけることによって、判断力も自ずと上がっていくような気もします。

仮に、以下のような条件のときに10%オフになるとします。現実にはこういうルールは存在しないかもしれませんが、あくまでも例ってことでご容赦を。複数のOR条件が入ってくるとテストを書く意義が一気に増すので無理矢理入れてみました。

  • 予約しようとしている施設が割引を許可している
  • 以下のいずれかの条件を満たす
    • 予約しようとしている施設を以前に利用したことがある
    • 会員ステータスがシルバー以上である(ブロンズ、シルバー、ゴールドの三種がある)
  • 利用予定日までの日数に関して、以下のいずれかの条件を満たす
    • 施設側が設定した日数があればそれに従う
    • 施設側が設定した日数がなければ3日前以内とする

条件判定の依存は「施設側の設定」「会員の利用履歴の有無」「会員ステータス」「予約日」の4つであることが判ります。

<?php

class DiscountOnReversationSpecificationTest
{
    /**
     * @param FacilitySetting $facilitySetting
     * @param bool $isUsed
     * ...
     * @param bool $expected
     * @dataProvider data_satisfied
     */
    function test_satisfied(FacilitySetting $facilitySetting, bool $isUsed, /*...*/, bool $expected): void
    {
        $specification = new DiscountOnReservationSpecification($facilitySetting, $isUsed, /*...*/);
        $actual = $specification->satisfied();
        $this->assertSame($expected, $actual);
    }

    public function data_satisfied(): array
    {
        return [
            '適用(施設側許可有り、施設利用済み、シルバー以上、3日前)' => [
                'facilitySetting' => new FacilitySetting(isDiscountAllowed: true, ...),
                'isUsed' => true,
                // 他のパラメータを列挙
                // ...
                // 最後に期待される返り値
                'expected' => true,
            ],
            // 他のパターンを列挙
        ];
    }
}

パラメータは今後変わるかもしれませんが、最初の時点で思いついた形でとりあえず(テストを)実装しておきます。最初に雑にでもいいのでテストを書いておくことで、少しずつ最適な形が見えてきます。しかも、テストが通れば壊れていないことが保証されるので、最適な形が見えてくるまでいくらでもリファクタリングできます(とはいえ、スケジュールの都合もあるでしょうから、実際にはいくらでもというわけにはいきませんが)。

おわりに

最初に書いたように、テストファーストであれテストラストであれ、品質の高いプログラムを早く完成させられればそれでいいわけですが、自分自身の経験から、テストファーストで書くと色々メリットがあると感じているので、まだ試してない、あるいは少し試したがやめてしまった、という方には、ぜひトライしてみてほしいという思いを込めて記事にまとめてみました。

とはいえ、推奨するというほどの熱量はなくて、どうやって内部品質を上げていけばいいのか悩んでいるくらいなら「ものは試し」で、やってみてから判断してもいいんじゃないかっていう思いです。あるていどできるようになってもフィットしないと思えば元に戻ってもいいですし、テストラストで最適解を探っていく、でもいいと思います。それぞれにフィットするやり方はあると思うので。

余談ですが、テスト駆動開発難しい、という意見をたまに見かけるんですが、どの辺が難しく感じるのかまでは書いてなかったりするので、もし言語化できてる方がいたら話を聞いてみたいと思っているのでご協力いただけないでしょうか。

いいよという方がいたら X でコンタクト取ってください、よろしくお願いします。

nunulk(decayed) (@nunulk) / X

最後に宣伝ですが、Techpit にこんな教材を出してます。テスト駆動開発に興味のある方はチラ見してみてください(Laravel 8 までしか対応してないですが、おおむね最新版でも使える内容になっているはずです)。

www.techpit.jp