【PHPテスト入門 第10回】TDDとレガシーコード:テストから書く、テストのないコードを守る

PHP
B!

はじめに

いよいよ最終回です。これまでは「コードを書いてから、テストを書く」順番で進めてきました。最終回では、テストとの付き合い方を広げる2つのテーマを扱います。

1つ目は、テストを先に書いてから、実装する「テスト駆動開発(TDD)」です。2つ目は、実務で必ず出会うテストが1本もない既存のコードに、安全にテストを導入していく方法です。

この回を読み終えると、次のことができるようになります。

  • レッド・グリーン・リファクタリングのサイクルで、小さな機能をテストから作れる
  • テストのないコードの「今の動き」を、特性テストで記録できる
  • テストを足場にして、既存のコードを安全に書き換えられる

動作確認環境は第1部と同じ、PHP 8.4.26、PHPUnit 13.3.5 です。第1部の php-test-cart プロジェクトを使います。記事中の実行結果は、すべてこの環境で実際に動かして取得したものです。

TDD(テスト駆動開発)とは

TDDは、実装より先にテストを書き、そのテストを通すために実装する開発の進め方です。次の3つの段階を、小さく何度も繰り返します。

段階やること大事なこと
レッドまだ実装していない機能のテストを書き、実行して失敗することを確かめる「失敗する」を必ず自分の目で見る。最初から通ってしまうテストは、何も確かめていないかもしれない
グリーンテストを通すための、最小限のコードを書くきれいさは後回し。まずは通すことだけを考える
リファクタリングテストが通った状態を保ったまま、コードを読みやすく整える整えるたびにテストを実行し、グリーンのままであることを確かめる

第2回で「テストが通らない状態をレッド、通る状態をグリーンと呼ぶ」と紹介しました。TDDは、この赤と緑を意図的に行き来する開発手法です。

テストを先に書くと、次のような良いことがあります。

  • 仕様が先にはっきりする:テストを書くには、「この入力なら、この結果」を具体的に決める必要がある
  • テストしやすい設計になる:使う側(テスト)から先に書くので、第4回で苦労した「テストしにくいコード」が生まれにくい
  • テストの書き忘れがない:実装が終わった時点で、テストもそろっている

TDDを実演する:送料の計算

カートに、送料を計算する機能を加えます。仕様は次のとおりです。

  • 商品の合計金額が3000円未満なら、送料は500円
  • 3000円以上なら、送料は無料

これを、TDDのサイクルで作っていきます。

サイクル1:レッド ── クラスがないので失敗する

まだ ShippingFee クラスは存在しません。それでも、先にテストを書きます。tests/Shipping/ShippingFeeTest.php を作成します。

<?php

declare(strict_types=1);

namespace Tests\Shipping;

use App\Shipping\ShippingFee;
use PHPUnit\Framework\TestCase;

final class ShippingFeeTest extends TestCase
{
    public function test_3000円未満なら送料は500円(): void
    {
        $fee = new ShippingFee();

        $this->assertSame(500, $fee->calculate(1000));
    }
}

テストを書く時点で、「クラス名は ShippingFee」「calculate() に合計金額を渡すと、送料が返る」という使い方が決まりました。実行すると、当然失敗します。

vendor/bin/phpunit tests/Shipping
E                                                                   1 / 1 (100%)

There was 1 error:

1) Tests\Shipping\ShippingFeeTest::test_3000円未満なら送料は500円
Error: Class "App\Shipping\ShippingFee" not found

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

これがレッドです。

サイクル1:グリーン ── 最小限の実装で通す

テストを通すための、最小限のコードを書きます。src/Shipping/ShippingFee.php を作成します。

<?php

declare(strict_types=1);

namespace App\Shipping;

final class ShippingFee
{
    public function calculate(int $subtotal): int
    {
        return 500;
    }
}

「常に500を返す」だけです。手抜きに見えるかもしれませんが、これで正しいのです。今あるテストは「1000円なら500円」しか求めていないので、それ以上のコードを書く理由がまだありません。

.                                                                   1 / 1 (100%)

OK (1 test, 1 assertion)

グリーンになりました。

「最小限の実装」にこだわるのは、テストに書かれていないコードを書かないためです。先回りして書いたコードは、テストで確かめられていないコードになってしまいます。仕様を足したいときは、まずテストを足します。

サイクル2:レッド ── 無料になるケースを足す

次の仕様「3000円以上なら無料」のテストを追加します。

public function test_3000円以上なら送料は無料(): void
{
    $fee = new ShippingFee();

    $this->assertSame(0, $fee->calculate(3000));
}
.F                                                                  2 / 2 (100%)

There was 1 failure:

1) Tests\Shipping\ShippingFeeTest::test_3000円以上なら送料は無料
Failed asserting that 500 is identical to 0.

FAILURES!
Tests: 2, Assertions: 2, Failures: 1.

「常に500円」の実装では、この仕様を満たせません。ここで初めて、条件分岐を書く理由が生まれました。

サイクル2:グリーン ── 条件分岐を加える

public function calculate(int $subtotal): int
{
    if ($subtotal >= 3000) {
        return 0;
    }

    return 500;
}
..                                                                  2 / 2 (100%)

OK (2 tests, 2 assertions)

サイクル3:境界のテストを足す

第3回と第4回で学んだとおり、バグは境界に潜みます。「2999円」と「3000円ちょうど」の両方を確かめたいので、データプロバイダでまとめます。

<?php

declare(strict_types=1);

namespace Tests\Shipping;

use App\Shipping\ShippingFee;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class ShippingFeeTest extends TestCase
{
    /**
     * @return array<string, array{int, int}>
     */
    public static function 送料のケース(): array
    {
        return [
            '3000円未満は500円' => [1000, 500],
            '境界の1円手前は500円' => [2999, 500],
            '境界ちょうどは無料' => [3000, 0],
            '3000円より上も無料' => [5000, 0],
        ];
    }

    #[DataProvider('送料のケース')]
    public function test_送料は合計3000円以上で無料になる(int $subtotal, int $expected): void
    {
        $fee = new ShippingFee();

        $this->assertSame($expected, $fee->calculate($subtotal));
    }
}
....                                                                4 / 4 (100%)

OK (4 tests, 4 assertions)

今回は、追加したテストが最初から通りました。TDDでは「レッドを見てから実装」が基本ですが、境界のテストのように、今の実装が正しいことを確かめるためのテストを足すこともあります。その場合は、テストが本当に機能しているかを確かめるため、一時的に実装を壊して失敗することを見ておくと安心です。

サイクル3:リファクタリング ── 数値に名前を付ける

テストがそろったので、実装を読みやすく整えます。3000 や 500 という数値が何を意味するのか、名前を付けておきましょう。

final class ShippingFee
{
    private const int FREE_SHIPPING_THRESHOLD = 3000;
    private const int STANDARD_FEE = 500;

    public function calculate(int $subtotal): int
    {
        return $subtotal >= self::FREE_SHIPPING_THRESHOLD ? 0 : self::STANDARD_FEE;
    }
}
....                                                                4 / 4 (100%)

OK (4 tests, 4 assertions)

書き換えた後もグリーンのままです。振る舞いを変えずに、コードだけを整えられました。

もし、このリファクタリング中にうっかり >= を > と書き間違えていたら、どうなっていたでしょうか。

..F.                                                                4 / 4 (100%)

There was 1 failure:

1) Tests\Shipping\ShippingFeeTest::test_送料は合計3000円以上で無料になる@境界ちょうどは無料 with data (3000, 0)
Failed asserting that 500 is identical to 0.

境界のテストが、すぐに間違いを教えてくれます。テストがあるから、安心してリファクタリングできる。第1回でお話しした効果を、TDDではサイクルのたびに体験できます。

【スクショ:レッド → グリーン → リファクタリングの各段階の実行結果を並べたもの】

TDDは、すべてのコードに必ず適用すべきものではありません。仕様がはっきりしている計算やロジックには特に向いていますが、画面のデザインのように「作りながら決める」ものには向きません。まずは、今回のような小さな計算から試してみてください。

テストのないコードに、テストを入れる

実務では、新しく書くコードより、すでにあるコードを直す機会のほうがずっと多いものです。そして、そのコードにテストがないことも珍しくありません。テストのない既存のコードは、よくレガシーコードと呼ばれます。

ここでは、次のような古い関数を題材にします。会員ランクに応じてポイントを計算する関数で、長年使われてきましたが、テストは1本もありません。legacy/point.php に置かれているとします。

<?php

// 昔からある会員ポイントの計算(テストなし)
function calc_point($price, $rank)
{
    $rate = 1;
    if ($rank == 'gold') {
        $rate = 3;
    } else if ($rank == 'silver') {
        $rate = 2;
    }

    $point = floor($price / 100) * $rate;

    // 12月はポイント2倍キャンペーン
    if (date('m') == '12') {
        $point = $point * 2;
    }

    return $point;
}

型の宣言がなく、現在の日付に依存し、if が入り組んでいます。書き直したくなりますが、いきなり書き直してはいけません。この関数は今、本番で動いています。書き直して動きが変わってしまえば、お客様のポイントが狂います。

安全に進めるための手順は、次の4ステップです。

  1. 今の動きを、そのままテストで記録する(特性テスト)
  2. テストしにくい部分に、最小限の「縫い目」を入れる
  3. テストを足場に、動きを変えずに書き直す(リファクタリング)
  4. 動きを変えたい部分は、別のステップとしてテストから変える

ステップ1:特性テストで「今の動き」を記録する

最初に書くのは、「正しい動き」のテストではありません。今、実際にどう動いているかを記録するテストです。これを特性テスト(キャラクタリゼーションテスト)と呼びます。

仕様書が残っていないことも多いので、まずは予想で期待値を書き、実行して実際の値を確かめます。わざと適当な期待値(0)を書いて、実行してみましょう。

<?php

declare(strict_types=1);

namespace Tests\Legacy;

use PHPUnit\Framework\TestCase;

require_once __DIR__ . '/../../legacy/point.php';

final class CalcPointTest extends TestCase
{
    public function test_ゴールド会員の1000円(): void
    {
        $this->assertSame(0, calc_point(1000, 'gold'));
    }
}
There was 1 failure:

1) Tests\Legacy\CalcPointTest::test_ゴールド会員の1000円
Failed asserting that 30.0 is identical to 0.

失敗メッセージが、実際の値を教えてくれました。1000円のゴールド会員は30ポイント。ここまでは予想どおりです。

しかし、よく見ると 30 ではなく 30.0 と表示されています。floor() は小数(float)を返すので、この関数は整数ではなく小数を返していたのです。コードを読んだだけでは見落としがちなことが、テストを実行して初めてわかりました。

こうして確かめた「今の動き」を、テストとして書き留めていきます。

public function test_ゴールド会員は100円ごとに3ポイント(): void
{
    $this->assertSame(30.0, calc_point(1000, 'gold'));
}

public function test_シルバー会員は100円ごとに2ポイント(): void
{
    $this->assertSame(20.0, calc_point(1000, 'silver'));
}

public function test_一般会員は100円ごとに1ポイント(): void
{
    $this->assertSame(10.0, calc_point(1000, 'normal'));
}

public function test_100円未満の端数は切り捨て(): void
{
    $this->assertSame(3.0, calc_point(199, 'gold'));
}

public function test_大文字のGOLDは一般会員として扱われる(): void
{
    // 現状の動きを記録したもの。仕様として正しいかは要確認
    $this->assertSame(10.0, calc_point(1000, 'GOLD'));
}

最後のテストに注目してください。ランクが大文字の 'GOLD' だと、ゴールド会員として扱われず、一般会員の1倍になります。これがバグなのか、意図した仕様なのかは、コードからはわかりません。

特性テストの段階では、バグらしき動きも、直さずにそのまま記録します。そして、コメントで「要確認」と残し、仕様を知っている人に確認します。テストの目的は、書き直しの最中に動きが変わらないことを守る足場を作ることだからです。

ステップ2:日付への依存に「縫い目」を入れる

実は、ここまでのテストには問題があります。この記事を書いている9月には通りますが、12月に実行すると、すべて失敗します。関数の中で date('m') を呼んでいるからです。第4回で扱った「現在時刻に依存したコード」そのものです。

第4回では Clock インターフェースを作りましたが、レガシーコードでいきなり大きな設計変更をするのは危険です。ここでは、既存の呼び出し元に影響を与えない、最小限の変更にとどめます。

function calc_point($price, $rank, $month = null)
{
    // (途中は同じ)

    // 12月はポイント2倍キャンペーン
    if (($month ?? date('m')) == '12') {
        $point = $point * 2;
    }

    return $point;
}

変えたのは2か所だけです。引数に $month を追加し、初期値を null にしました。null のときだけ、これまでどおり date('m') を使います。

こうすると、既存の calc_point(1000, 'gold') という呼び出しは、まったく同じ動きのままです。一方、テストからは calc_point(1000, 'gold', '09') のように月を指定できるようになります。このように、コードの外から動きを差し替えられるようにする切れ目を、縫い目(シーム)と呼びます。

テストに月を渡すように変え、12月のテストも追加します。

public function test_ゴールド会員は100円ごとに3ポイント(): void
{
    $this->assertSame(30.0, calc_point(1000, 'gold', '09'));
}

// (ほかのテストも同様に '09' を渡す)

public function test_12月はポイントが2倍(): void
{
    $this->assertSame(60.0, calc_point(1000, 'gold', '12'));
}
......                                                              6 / 6 (100%)

OK (6 tests, 6 assertions)

これで、いつ実行しても同じ結果になる特性テストがそろいました。

ステップ3:テストを足場に、書き直す

いよいよ関数を読みやすく書き直します。ただし、ここでは動きを一切変えないことが条件です。

function calc_point($price, $rank, $month = null)
{
    $rate = match ($rank) {
        'gold' => 3,
        'silver' => 2,
        default => 1,
    };

    $point = floor($price / 100) * $rate;

    // 12月はポイント2倍キャンペーン
    $isCampaign = ($month ?? date('m')) == '12';

    return $isCampaign ? $point * 2 : $point;
}

入り組んだ if を、PHP 8.0の match 式に置き換えました。

......                                                              6 / 6 (100%)

OK (6 tests, 6 assertions)

特性テストはすべて通ったままです。動きは変わっていないと、自信を持って言えます。

もし書き直しの途中で、「floor() はなくても同じだろう」と消してしまっていたら、どうなったでしょうか。

4) Tests\Legacy\CalcPointTest::test_100円未満の端数は切り捨て
Failed asserting that 5.97 is identical to 3.0.

199円で5.97ポイントという、おかしな値になってしまいました。特性テストが、書き直しによる動きの変化をすぐに教えてくれます。

ステップ4:動きを変えるのは、別のステップで

特性テストでわかった「小数を返す」という動きは、ポイントとしては不自然です。整数を返すように直したいところです。

ここで大事なのは、リファクタリング(動きを変えない書き直し)と、動きの修正を、同じタイミングでやらないことです。混ぜてしまうと、テストが失敗したときに、どちらが原因かわからなくなるからです。

動きを変えるときは、TDDと同じようにテストから先に変えます。期待値を小数から整数に書き換えます。

public function test_ゴールド会員は100円ごとに3ポイント(): void
{
    $this->assertSame(30, calc_point(1000, 'gold', '09'));
}

// (ほかのテストも同様に、30.0 → 30 のように整数へ)
FFFFFF                                                              6 / 6 (100%)

There were 6 failures:

1) Tests\Legacy\CalcPointTest::test_ゴールド会員は100円ごとに3ポイント
Failed asserting that 30.0 is identical to 30.

レッドになりました。それから、実装を1行だけ直します。

$point = (int) floor($price / 100) * $rate;
......                                                              6 / 6 (100%)

OK (6 tests, 6 assertions)

グリーンに戻りました。

戻り値の型を変えるのは、呼び出し元にも影響しうる変更です。たとえば、どこかで calc_point(...) === 30.0 のように厳密に比較していれば、その箇所が動かなくなります。実際の現場では、関数名でプロジェクト全体を検索して呼び出し元を確認してから変えましょう。

'GOLD' の扱いも、仕様を確認して「大文字でもゴールド会員として扱う」と決まったなら、同じ手順で直せます。まずテストの期待値を30に変えてレッドにし、次に実装を直してグリーンにする、という流れです。

レガシーコードに向き合うときの心得

  • いきなり書き直さない。まず特性テストで「今の動き」を記録する
  • 特性テストでは、バグらしき動きも直さずに記録し、「要確認」と残す
  • テストしにくい部分には、呼び出し元に影響しない最小限の縫い目を入れる
  • リファクタリングと動きの修正は、別々のステップで行う
  • 一度に全部やろうとしない。触る部分から少しずつテストを増やしていく

最後の点は特に大切です。レガシーコードのすべてにテストを書こうとすると、途方もない作業になります。「今回直す関数の周りだけ」でも十分です。触るたびに少しずつテストが増えていけば、コードは確実に安全になっていきます。

今回のまとめ

  • TDDは「レッド → グリーン → リファクタリング」を小さく繰り返す開発手法。テストを先に書くことで、仕様がはっきりし、テストしやすい設計になる
  • グリーンにするときは最小限の実装にとどめ、仕様を足したいときはテストから足す
  • レガシーコードは、いきなり書き直さない。特性テストで今の動きを記録してから触る
  • テストしにくい部分には、呼び出し元に影響しない最小限の縫い目を入れる
  • リファクタリングと動きの修正は、別のステップで行う

今回のテストを追加した php-test-cart 全体を実行すると、35件のテストがすべて通ります。

...................................                               35 / 35 (100%)

OK (35 tests, 48 assertions)

連載を振り返って

全10回、お疲れさまでした。最後に、連載で身につけたことを振り返ります。

回テーマ身につけたこと
第1回なぜテストを書くのか手動確認の限界、テストの種類とピラミッド
第2回環境構築と最初のテストPHPUnitの導入、テストの実行と失敗の読み方
第3回読みやすいテストAAA、assertSame、例外のテスト、データプロバイダ
第4回テストしやすいコード依存性注入、時刻の固定、境界のテスト
第5回テストダブルスタブとモックの使い分け、モックの使いすぎに注意
第6回DBを使うテストインメモリDB、トランザクションによる後片付け
第7回Laravelのテスト基盤HTTPテスト、RefreshDatabase、Factory
第8回フェイクとPestHttp::fake()、Mail::fake()、Pestでの書き方
第9回カバレッジとCIカバレッジとの付き合い方、GitHub Actions
第10回TDDとレガシーコードテストから書く開発、テストのないコードの守り方

連載を通して、何度も繰り返し出てきた考え方があります。

  • テストは、安心してコードを変えるためにある。書き直しても、振る舞いが変わっていないことを確かめられる
  • バグは境界に潜む。「ちょうど」と「1つ手前」を必ず試す
  • 外部への依存は、外から渡す。時刻もAPIもDBも、差し替えられるようにすればテストできる
  • テストの質は、数字ではなく中身で見る。カバレッジ100%でも、何を確かめているかが大事

これらは、PHPUnitでもPestでも、素のPHPでもLaravelでも変わりません。ツールが変わっても通用する、テストの基本です。

次の一歩

この連載で学んだことを、ぜひ手元のプロジェクトで試してみてください。いきなり完璧を目指す必要はありません。

  • 次にバグを直すとき、直す前にそのバグを再現するテストを1本書く。レッドを確かめてから直せば、同じバグは二度と戻ってこない
  • 次に機能を足すとき、計算やロジックの部分だけでもTDDで書いてみる
  • CIがまだなら、第9回のワークフローを1つ置いてみる

1本のテストから始めて、少しずつ増やしていけば十分です。テストがあるコードは、触るたびに少しずつ安全になっていきます。

最後まで読んでいただき、ありがとうございました。

参考資料

B!
← 一覧へ戻る