【PHPテスト入門 第3回】読みやすいテストの書き方:AAA・アサーション・例外・データプロバイダ

PHP
B!

はじめに

今回のゴールは、「動くテスト」から一歩進んで「読みやすく、漏れの少ないテスト」を書けるようになることです。前回はPHPUnitを導入し、Cart クラスの合計金額を確かめるテストを3本動かしました。

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

  • AAAパターン(準備・実行・検証の3段階で書く型)で、何をテストしているかが一目でわかるテストを書ける
  • assertSame と assertEquals の違いを理解し、使い分けられる
  • 例外が正しく投げられることをテストできる
  • データプロバイダで、同じテストを複数の入力値でまとめて検証できる

今回は Cart クラスに「不正な値のチェック」と「消費税込みの合計金額」を追加しながら進めます。途中で、テストがあったからこそ見つかるバグにも出会います。

動作確認環境は前回と同じ、PHP 8.4.26、Composer 2.10.3、PHPUnit 13.3.5 です。記事中の実行結果は、すべてこの環境で実際に動かして取得したものです。

AAAパターンで「読めるテスト」にする

テストは、書く時間より読まれる時間のほうがずっと長いコードです。半年後の自分やチームメンバーが、テストを読んで「何を確かめているのか」をすぐ理解できる形にしておきましょう。そのための定番の型が AAAパターン です。

段階英語やること
準備Arrangeテストに必要なオブジェクトやデータを用意する
実行Actテストしたい処理を1回だけ呼び出す
検証Assert結果が期待どおりかを確かめる

前回のテストをAAAで書き直すと、次のようになります。

public function test_数量を指定すると単価×数量が合計金額になる(): void
{
    // Arrange(準備)
    $this->cart->add('りんご', 150, 3);
    $this->cart->add('みかん', 80, 2);

    // Act(実行)
    $total = $this->cart->total();

    // Assert(検証)
    $this->assertSame(610, $total);
}

ポイントは、3つの段階を空行で区切ることです。コメントは慣れるまでの補助です。慣れてきたら、空行で区切るだけでも十分に伝わります。

Actは1回だけが原則です。1つのテストで複数の処理を実行して検証すると、失敗したときにどこが原因かわかりにくくなります。確かめたいことが2つあるなら、テストを2つに分けましょう。

setUp() で共通の準備をまとめる

上のコードでは、前回テストごとに書いていた new Cart() がなくなり、代わりに $this->cart を使っています。どのテストでも最初に Cart を作るので、その準備を setUp() メソッドにまとめたからです。

final class CartTest extends TestCase
{
    private Cart $cart;

    protected function setUp(): void
    {
        $this->cart = new Cart();
    }

    // 以下、各テストメソッド
}

setUp() は、テストメソッドが1つ実行されるたびに、その直前で毎回呼ばれます。そのため、どのテストも新品のカートから始まります。あるテストで追加した商品が、別のテストに残ることはありません。

この「テスト同士が影響し合わない」性質を、テストの独立性と呼びます。独立していれば、テストをどの順番で実行しても、1つだけ実行しても、同じ結果になります。

準備の処理をなんでも setUp() に入れると、今度は個々のテストを読んだだけでは前提がわからなくなります。setUp() に入れるのは「すべてのテストで共通の準備」だけにして、テストごとに違うデータはArrangeに書きましょう。

後片付けをする tearDown()

setUp() と対になるのが tearDown() です。こちらは各テストメソッドの実行が終わった直後に毎回呼ばれ、テスト中に作った一時ファイルの削除や、DB接続を閉じるといった後片付けに使います。

protected function tearDown(): void
{
    // 例:テスト中に作った一時ファイルを削除する
}

今回の Cart はメモリ上だけで完結するので、tearDown() は必要ありません。第6回のDBを使うテストで実際に登場します。実行の順番は、次のとおりです。

setUp() → テストメソッド1 → tearDown()
setUp() → テストメソッド2 → tearDown()
setUp() → テストメソッド3 → tearDown()

よく使うアサーション

PHPUnitにはたくさんのアサーションがありますが、最初は次の表のものを押さえておけば十分です。

アサーション確かめること使用例
assertSame($a, $b)型も値も完全に一致するassertSame(610, $cart->total())
assertEquals($a, $b)値が等しい(型の違いは許容)オブジェクトの中身の比較など
assertTrue($x) / assertFalse($x)true / false であるassertTrue($cart->isEmpty())
assertNull($x)null である見つからないときに null を返す処理
assertCount($n, $arr)配列の要素数が $n 個検索結果の件数
assertContains($v, $arr)配列に $v が含まれる一覧に特定の値があるか
assertStringContainsString($s, $str)文字列に $s が含まれるメッセージの一部を確認

今回の Cart には、カートが空かどうかを返す isEmpty() を追加しました。空のカートのテストは次のようになります。

public function test_空のカートの合計金額は0円(): void
{
    $this->assertTrue($this->cart->isEmpty());
    $this->assertSame(0, $this->cart->total());
}

assertSame と assertEquals の違い

初心者が特につまずきやすいのがこの2つです。違いを実際に確かめてみましょう。ここでは検証用に tests/CompareTest.php という別ファイルを作って実行しています(確認が終わったら削除してかまいません)。

public function test_assertEqualsは型を無視する(): void
{
    $this->assertEquals(150, '150');
}

public function test_assertSameは型も比べる(): void
{
    $this->assertSame(150, '150');
}
.F                                                                  2 / 2 (100%)

There was 1 failure:

1) CompareTest::test_assertSameは型も比べる
Failed asserting that '150' is identical to 150.

assertEquals は、数値の 150 と文字列の '150' を「等しい」と判定して通してしまいます。assertSame は型の違いを見逃さず、失敗させます。

たとえば、DBから取り出した金額が文字列のまま返ってくるバグがあったとします。assertEquals ではテストが通ってしまい、バグに気づけません。迷ったら assertSame を使うのがおすすめです。

assertEquals の出番は、2つのオブジェクトの「中身が同じか」を比べたいときです。assertSame でオブジェクトを比べると、「同じインスタンスか」を判定するため、中身が同じ別のオブジェクトでは失敗します。

例外をテストする

今の Cart は、単価にマイナスの値を渡しても、数量に0を渡しても、そのまま受け付けてしまいます。不正な値が渡されたら InvalidArgumentException を投げるように、add() を修正しましょう。

public function add(string $name, int $unitPrice, int $quantity = 1): void
{
    if ($unitPrice < 0) {
        throw new InvalidArgumentException("単価は0以上を指定してください(指定値: {$unitPrice})");
    }

    if ($quantity < 1) {
        throw new InvalidArgumentException("数量は1以上を指定してください(指定値: {$quantity})");
    }

    // 以下、前回と同じ
}

ファイル冒頭の namespace の下に use InvalidArgumentException; を追加するのを忘れないでください。

例外のテストの書き方

「例外が投げられること」を確かめるには、expectException() を使います。

public function test_単価がマイナスなら例外を投げる(): void
{
    $this->expectException(InvalidArgumentException::class);
    $this->expectExceptionMessage('単価は0以上を指定してください');

    $this->cart->add('りんご', -1);
}

public function test_数量が0なら例外を投げる(): void
{
    $this->expectException(InvalidArgumentException::class);
    $this->expectExceptionMessage('数量は1以上を指定してください(指定値: 0)');

    $this->cart->add('りんご', 150, 0);
}

注意したいのは、expectException() は例外が起きる処理より前に書くという点です。例外が投げられた時点でテストメソッドの実行は止まるので、後に書いた行は実行されません。「予告してから実行する」と覚えておきましょう。

順番を逆にすると、どうなるか試してみましょう(検証用の別ファイル OrderTest.php で実行しています)。

public function test_expectExceptionを後に書いた場合(): void
{
    $this->cart->add('りんご', 150, 0);
    $this->expectException(InvalidArgumentException::class);
}
E                                                                   1 / 1 (100%)

There was 1 error:

1) OrderTest::test_expectExceptionを後に書いた場合
InvalidArgumentException: 数量は1以上を指定してください(指定値: 0)

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

期待どおりの例外が投げられているのに、結果は E(エラー)です。例外が起きた時点ではまだ「予告」されていないので、PHPUnitは想定外の例外が発生したと判断します。例外のテストが通らないときは、まずこの順番を確認しましょう。

expectExceptionMessage() は、メッセージに指定した文字列が含まれているかを確認します。完全一致ではないので、1つ目のテストのように、メッセージの前半だけを指定することもできます。

例外の型だけでなくメッセージまで確かめておくと、「単価のチェックで止まるはずが、数量のチェックで例外になっていた」といった取り違えにも気づけます。同じ InvalidArgumentException を複数の場所で投げている場合は、特に有効です。

例外が投げられなかったらどうなるか

試しに、例外が起きない正しい値を渡してみます。

public function test_例外が投げられないと失敗する(): void
{
    $this->expectException(InvalidArgumentException::class);

    $this->cart->add('りんご', 150, 1);
}
1) CompareTest::test_例外が投げられないと失敗する
Failed asserting that exception of type "InvalidArgumentException" is thrown.

「InvalidArgumentException が投げられることを期待したが、投げられなかった」という意味です。うっかりチェック処理を消してしまっても、このテストが失敗して知らせてくれます。

【スクショ:例外のテスト2本が追加され、すべて OK になった実行結果】

データプロバイダで、たくさんのケースをまとめて検証する

次は、消費税込みの合計金額を返す totalWithTax() を追加します。仕様は「税率10%、1円未満は切り捨て」とします。

まずは素直に実装してみます。

public function totalWithTax(): int
{
    return (int) round($this->total() * 1.1);
}

同じテストを入力だけ変えて何本も書く?

税込計算では、端数が出ない金額、端数が0.5の金額、端数が0.9の金額など、確かめたいケースがいくつもあります。これを1本ずつテストメソッドにすると、ほとんど同じコードが並んでしまいます。

そこで使うのが データプロバイダ です。テストメソッドに引数を持たせ、入力値と期待値の組み合わせを別のメソッドから渡します。

use PHPUnit\Framework\Attributes\DataProvider;

// ...

/**
 * @return array<string, array{int, int}>
 */
public static function 税込金額のケース(): array
{
    return [
        '0円は0円' => [0, 0],
        '端数が出ない' => [100, 110],
        '端数0.5は切り捨て' => [105, 115],
        '端数0.9も切り捨て' => [999, 1098],
    ];
}

#[DataProvider('税込金額のケース')]
public function test_税込金額は10パーセントを加えて1円未満を切り捨てる(int $subtotal, int $expected): void
{
    $this->cart->add('商品', $subtotal);

    $this->assertSame($expected, $this->cart->totalWithTax());
}

書き方のポイントは次の3つです。

  1. データプロバイダは public static なメソッドにし、配列の配列を返す
  2. 内側の配列の値が、順番どおりテストメソッドの引数に渡される([105, 115] なら $subtotal = 105、$expected = 115)
  3. テストメソッドに #[DataProvider('メソッド名')] 属性を付ける

外側の配列のキー('端数0.5は切り捨て' など)は、データセットの名前になります。失敗したときにこの名前が表示されるので、何のケースなのかがわかる名前を付けておきましょう。

PHPUnit 10より前は、@dataProvider というアノテーション(ドキュメントコメント)で指定していました。ネット上の古い記事ではこちらの書き方をよく見かけますが、PHPUnit 12以降はアノテーションに対応していません。必ず属性(#[...])で書いてください。

実行すると、バグが見つかる

......FF                                                            8 / 8 (100%)

There were 2 failures:

1) Tests\Cart\CartTest::test_税込金額は10パーセントを加えて1円未満を切り捨てる@端数0.5は切り捨て with data (105, 115)
Failed asserting that 116 is identical to 115.

2) Tests\Cart\CartTest::test_税込金額は10パーセントを加えて1円未満を切り捨てる@端数0.9も切り捨て with data (999, 1098)
Failed asserting that 1099 is identical to 1098.

FAILURES!
Tests: 8, Assertions: 11, Failures: 2.

【スクショ:データプロバイダの2ケースが赤く失敗している実行結果】

テストメソッドは1つなのに、データセットの数だけテストが実行されています。失敗した2件は @端数0.5は切り捨て のように、データセットの名前付きで表示されます。

原因は round() です。round() は四捨五入なので、105円の税込額115.5円が116円になってしまいました。仕様は「切り捨て」なので、実装が間違っています。

「0円」や「端数なし」のケースだけを試していたら、このバグは見逃していたはずです。境界になる値(端数がちょうど0.5、1円に近い0.9など)をデータセットに入れておくことが、データプロバイダを活かすコツです。

整数だけで計算して直す

切り捨てにするなら floor() を使う手もあります。ただ、金額の計算に小数(浮動小数点数)を使うのはおすすめできません。コンピュータは 0.1 のような小数を正確に表せず、ごくわずかな誤差が出るからです。

php -r 'var_dump(0.1 + 0.2 === 0.3); printf("%.17f\n", 0.1 + 0.2);'
bool(false)
0.30000000000000004

0.1 + 0.2 が 0.3 にならず、0.30000000000000004 になっています。この誤差が切り捨ての境目で表に出ると、1円ずれる原因になります。そこで、金額は最初から最後まで整数だけで計算する形に直します。

private const int TAX_RATE_PERCENT = 10;

public function totalWithTax(): int
{
    return intdiv($this->total() * (100 + self::TAX_RATE_PERCENT), 100);
}

intdiv() は整数同士で割り算を行い、小数点以下を切り捨てた整数を返す関数です。105円なら 105 × 110 ÷ 100 = 115.5 の小数点以下が切り捨てられ、115円になります。

........                                                            8 / 8 (100%)

OK (8 tests, 11 assertions)

すべてのテストが通りました。実装を書き換えても、テストを流すだけで「仕様どおりに動いている」ことを確認できます。これがテストを書いておく最大のメリットです。

#[TestDox] でテスト結果を仕様書にする

前回、--testdox で実行すると「空のカートの合計金額は 0円」のように、数字の前に半角スペースが入ることを紹介しました。表示を思いどおりにしたいときは、#[TestDox] 属性で説明文を直接指定します。

use PHPUnit\Framework\Attributes\TestDox;

#[TestDox('空のカートの合計金額は0円')]
public function test_空のカートの合計金額は0円(): void

データプロバイダと組み合わせると、$引数名 の部分にデータセットの値が埋め込まれます。

#[DataProvider('税込金額のケース')]
#[TestDox('税抜$subtotal円の税込金額は$expected円')]
public function test_税込金額は10パーセントを加えて1円未満を切り捨てる(int $subtotal, int $expected): void
vendor/bin/phpunit --testdox
Cart (Tests\Cart\Cart)
 ✔ 空のカートの合計金額は0円
 ✔ 数量を指定すると単価×数量が合計金額になる
 ✔ 単価がマイナスなら例外を投げる
 ✔ 数量が0なら例外を投げる
 ✔ 税抜0円の税込金額は0円
 ✔ 税抜100円の税込金額は110円
 ✔ 税抜105円の税込金額は115円
 ✔ 税抜999円の税込金額は1098円

OK (8 tests, 11 assertions)

【スクショ:–testdox の実行結果。緑のチェックが8行並んだ状態】

Cart クラスの仕様が、そのまま読めるチェックリストになりました。コードを読まない人(企画担当者など)に「どこまで確認済みか」を説明するときにも使えます。

テスト名の付け方

テスト名は「何をしたら、どうなるか」を1文で書くのが基本です。

良くない例良い例
test_addtest_数量が0なら例外を投げる
test_total2test_数量を指定すると単価×数量が合計金額になる
test_taxtest_税込金額は10パーセントを加えて1円未満を切り捨てる

test_add のような名前だと、失敗したときに「add の何が壊れたのか」がわかりません。長くなっても、条件と結果が読み取れる名前にしましょう。

今回の完成コード

src/Cart/Cart.php

<?php

declare(strict_types=1);

namespace App\Cart;

use InvalidArgumentException;

final class Cart
{
    private const int TAX_RATE_PERCENT = 10;

    /** @var list<array{name: string, unitPrice: int, quantity: int}> */
    private array $items = [];

    public function add(string $name, int $unitPrice, int $quantity = 1): void
    {
        if ($unitPrice < 0) {
            throw new InvalidArgumentException("単価は0以上を指定してください(指定値: {$unitPrice})");
        }

        if ($quantity < 1) {
            throw new InvalidArgumentException("数量は1以上を指定してください(指定値: {$quantity})");
        }

        $this->items[] = [
            'name' => $name,
            'unitPrice' => $unitPrice,
            'quantity' => $quantity,
        ];
    }

    public function isEmpty(): bool
    {
        return $this->items === [];
    }

    public function total(): int
    {
        $total = 0;

        foreach ($this->items as $item) {
            $total += $item['unitPrice'] * $item['quantity'];
        }

        return $total;
    }

    public function totalWithTax(): int
    {
        return intdiv($this->total() * (100 + self::TAX_RATE_PERCENT), 100);
    }
}

tests/Cart/CartTest.php

<?php

declare(strict_types=1);

namespace Tests\Cart;

use App\Cart\Cart;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;

final class CartTest extends TestCase
{
    private Cart $cart;

    protected function setUp(): void
    {
        $this->cart = new Cart();
    }

    #[TestDox('空のカートの合計金額は0円')]
    public function test_空のカートの合計金額は0円(): void
    {
        $this->assertTrue($this->cart->isEmpty());
        $this->assertSame(0, $this->cart->total());
    }

    public function test_数量を指定すると単価×数量が合計金額になる(): void
    {
        // Arrange(準備)
        $this->cart->add('りんご', 150, 3);
        $this->cart->add('みかん', 80, 2);

        // Act(実行)
        $total = $this->cart->total();

        // Assert(検証)
        $this->assertSame(610, $total);
    }

    public function test_単価がマイナスなら例外を投げる(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('単価は0以上を指定してください');

        $this->cart->add('りんご', -1);
    }

    #[TestDox('数量が0なら例外を投げる')]
    public function test_数量が0なら例外を投げる(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('数量は1以上を指定してください(指定値: 0)');

        $this->cart->add('りんご', 150, 0);
    }

    /**
     * @return array<string, array{int, int}>
     */
    public static function 税込金額のケース(): array
    {
        return [
            '0円は0円' => [0, 0],
            '端数が出ない' => [100, 110],
            '端数0.5は切り捨て' => [105, 115],
            '端数0.9も切り捨て' => [999, 1098],
        ];
    }

    #[DataProvider('税込金額のケース')]
    #[TestDox('税抜$subtotal円の税込金額は$expected円')]
    public function test_税込金額は10パーセントを加えて1円未満を切り捨てる(int $subtotal, int $expected): void
    {
        $this->cart->add('商品', $subtotal);

        $this->assertSame($expected, $this->cart->totalWithTax());
    }
}

まとめ

  • テストはAAA(準備・実行・検証)の3段階で書き、空行で区切る
  • すべてのテストで共通の準備は setUp() にまとめる。setUp() はテストごとに毎回呼ばれる
  • 値の比較は、型まで確かめる assertSame を基本にする
  • 例外は expectException() で「予告してから実行する」
  • 入力だけが違うテストはデータプロバイダでまとめ、境界になる値を必ず入れる

次回予告

第4回は「テストしやすいコードの書き方」です。今の Cart は税率10%が固定で、日付や外部サービスにも依存していません。実際のアプリでは「セール期間中だけ割引する」「在庫をAPIに問い合わせる」といった処理が入り込み、途端にテストが書きにくくなります。依存性注入(DI)を使って、そうしたコードをテストしやすい形に変えていきます。

参考資料

B!
← 一覧へ戻る