【PHPテスト入門 第9回】カバレッジとCI:数字との付き合い方とGitHub Actions

PHP
B!

はじめに

今回から第3部、実践編に入ります。第2部までで、テストの書き方はひととおり身につきました。第3部のテーマは、書いたテストを、実務の中で活かし続けることです。

今回は2つのことを扱います。1つは、テストがコードのどこまでを確かめているかを測る「カバレッジ」。もう1つは、GitHubにコードをpushするたびにテストを自動で実行する「CI」です。

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

  • カバレッジを計測し、テストされていないコードを見つけられる
  • 「カバレッジが高い=よいテスト」ではない理由を、実例で説明できる
  • GitHub Actionsで、pushのたびにテストが自動実行されるようにできる

動作確認環境

ソフトウェア動作確認バージョン使う場面
PHP8.4.26
PHPUnit13.3.5第1部の php-test-cart
Laravel / Pest13.33.0 / 5.2.1第2部の shop
PCOV1.0.12カバレッジ計測
Xdebug3.5.3カバレッジ計測(PCOVの代わりに使う場合)

カバレッジの計測結果は、すべてこの環境で実際に動かして取得したものです。GitHub Actionsのワークフローは、ファイルの書式を検証したうえで掲載しています。GitHub上での実行画面は、みなさんの環境で実行したものをスクショで差し込む想定です。

カバレッジとは

カバレッジとは、テストを実行したときに、本番コードのどれだけの部分が実際に実行されたかを表す割合です。たとえば、100行のコードのうち、テストの実行中に通った行が80行なら、行カバレッジは80%です。

PHPUnitでは、次の3つの単位で割合を出してくれます。

単位意味
Lines(行)実行された行の割合
Methods(メソッド)すべての行が実行されたメソッドの割合
Classes(クラス)すべてのメソッドが実行されたクラスの割合

カバレッジの一番の使い道は、テストが一度も通っていないコードを見つけることです。0%の部分は、そこにバグがあってもテストでは絶対に気づけない、という意味だからです。

計測の準備:カバレッジドライバ

カバレッジを測るには、PHPに「どの行が実行されたか」を記録する拡張機能(カバレッジドライバ)が必要です。入れずに計測しようとすると、次の警告が出てテストが実行されません。

vendor/bin/phpunit --coverage-text
There was 1 PHPUnit test runner warning:

1) No code coverage driver available that supports line coverage

No tests executed!

ドライバには、PCOVとXdebugの2つがあります。

ドライバ特徴向いている人
PCOVカバレッジの計測専用。軽くて速いカバレッジを測りたいだけの人(おすすめ)
Xdebugステップ実行などのデバッグ機能も持つ多機能な拡張。計測はPCOVより遅めすでにデバッグ用にXdebugを入れている人

PCOVを入れる

Mac(Homebrew)では、PHPの拡張をインストールする pecl コマンドを使います。

pecl install pcov

WSLのUbuntu(第2回で追加したPPAのPHP)では、apt で入ります。

sudo apt install php8.4-pcov

有効になったかを確認しましょう。pcov と表示されれば準備完了です。

php -m | grep pcov
pcov

Xdebugを使う場合

Xdebugはカバレッジを測るときだけ、環境変数 XDEBUG_MODE=coverage を付けて実行します。

XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text

どちらのドライバを使っているかは、PHPUnitの実行結果の冒頭にある Runtime: の行でわかります。

Runtime:       PHP 8.4.26 with PCOV 1.0.12
Runtime:       PHP 8.4.26 with Xdebug 3.5.3

両方入っている場合は、カバレッジの計測に使いたくない方を無効にしておくと混乱しません。今回のコードでは、どちらのドライバで測っても同じ結果になりました。

カバレッジを測る

テキストで概要を見る

第1部の php-test-cart で計測してみます。--coverage-text を付けると、テストの実行結果の後にカバレッジの一覧が表示されます。

vendor/bin/phpunit --coverage-text
OK (25 tests, 38 assertions)


Code Coverage Report:
  2026-09-26 14:47:07

 Summary:
  Classes:   85.71% (6/7)
  Methods:   94.11% (16/17)
  Lines:     98.24% (56/57)

App\Cart\Cart
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 15/ 15)
App\Order\Order
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
App\Order\OrderRepository
  Methods: 100.00% ( 5/ 5)   Lines: 100.00% ( 26/ 26)
App\Order\OrderService
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% (  5/  5)
App\Sale\SaleDiscount
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% (  5/  5)
App\Stock\StockChecker
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% (  4/  4)

行カバレッジは98.24%、57行中56行が実行されています。一覧に並んでいるクラスは、どれも100%です。

では、残りの1行はどこでしょうか。Summaryをよく見ると、クラスは「7個中6個」です。ところが一覧には6クラスしか表示されていません。テキスト形式の一覧は、初期設定では一度も実行されなかったクラスを表示しないのです。

HTMLレポートで詳しく見る

どこが実行されていないかを探すには、HTML形式のレポートが便利です。

vendor/bin/phpunit --coverage-html coverage
OK (25 tests, 38 assertions)

Generating code coverage report in HTML format ... done [00:00.019]

coverage/ フォルダにレポートが作られます。coverage/index.html をブラウザで開くと、フォルダごと・ファイルごとのカバレッジが一覧で表示されます。

【スクショ:coverage/index.html をブラウザで開いた画面。Clock フォルダだけ緑になっていない状態】

一覧をたどると、Clock/SystemClock.php が0%だとわかります。ファイル名をクリックすると、ソースコードが行ごとに色分けされて表示されます。緑の行はテストで実行された行、赤の行は一度も実行されなかった行です。

【スクショ:SystemClock.php のレポート画面。now() の中の行が赤く表示されている状態】

SystemClock は、第4回で作った「本物の現在時刻を返す時計」です。テストではすべて FixedClock に差し替えたので、本物の時計は一度も動いていない、というわけです。

coverage/ フォルダは自動で作られるファイルなので、.gitignore に /coverage/ を追加して、Gitの管理から外しておきましょう。

Laravelでは –coverage

Laravelの shop プロジェクトでは、php artisan test に --coverage を付けるだけで、見やすい一覧が表示されます。

php artisan test --coverage
  Tests:    22 passed (46 assertions)
  Duration: 0.68s

  Cart/Cart ........................................................... 100.0%
  Http/Controllers/Controller ......................................... 100.0%
  Http/Controllers/OrderController .................................... 100.0%
  Mail/OrderConfirmed ..................................... 30..31, 32 / 57.1%
  Models/Order ........................................................ 100.0%
  Models/User ........................................................... 0.0%
  Providers/AppServiceProvider ........................................ 100.0%
  Services/InventoryClient ............................................ 100.0%
  ────────────────────────────────────────────────────────────────────────────
                                                                 Total: 86.8 %

こちらは0%のファイルも表示され、カバレッジが100%でないファイルには、実行されなかった行番号(30..31, 32)まで表示されます。

注目したいのは Mail/OrderConfirmed の57.1%です。30〜32行目は、メールの本文を組み立てる content() の中身です。第8回では Mail::fake() で「メールが送られたか」は確かめましたが、フェイクは本文を組み立てないので、この部分は一度も動いていなかったのです。つまり、本文のテンプレートにバグがあっても、今のテストでは気づけません。

カバレッジのおかげで見つかったこの穴を、テストで埋めておきましょう。tests/Feature/OrderConfirmedMailTest.php を作成します。

<?php

namespace Tests\Feature;

use App\Mail\OrderConfirmed;
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderConfirmedMailTest extends TestCase
{
    use RefreshDatabase;

    public function test_確認メールに件名と注文内容が含まれる(): void
    {
        $order = Order::factory()->create(['sku' => 'APPLE-001', 'quantity' => 2]);

        $mailable = new OrderConfirmed($order);

        $mailable->assertHasSubject('ご注文を受け付けました');
        $mailable->assertSeeInText('商品コード:APPLE-001');
        $mailable->assertSeeInText('数量:2');
    }
}

メールのクラスを直接作り、件名と本文を検証しています。もう一度計測すると、Mail/OrderConfirmed は100%になりました。

  Tests:    23 passed (49 assertions)

  Mail/OrderConfirmed ................................................. 100.0%
  ────────────────────────────────────────────────────────────────────────────
                                                                 Total: 92.5 %

これが、カバレッジの正しい使い方です。数字を上げるためではなく、テストの穴を見つけるために使います。

カバレッジの数字との付き合い方

第1回で「カバレッジ100%を目標にしない」とお話ししました。その理由を、実際に確かめてみましょう。

実験1:100%でも、バグを見逃す

第4回の SaleDiscount を思い出してください。セール終了の判定で > を >= と書き間違えるバグを、境界のテストで見つけました。

では、境界のテストがなく、「セール期間中」と「期間外」の2つのテストしかなかったらどうなるでしょうか。バグを入れた状態で、その2つのテストだけを実行し、カバレッジを測ってみます。

vendor/bin/phpunit --filter '/セール期間(中|外)/' --coverage-text
OK (2 tests, 2 assertions)

App\Sale\SaleDiscount
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% (  5/  5)

テストはすべて通り、SaleDiscount のカバレッジは100%です。それでも、「セール最終日の23:59:59だけ定価になる」バグは残っています。

カバレッジが測るのは「その行が実行されたか」だけです。どんな値で実行されたか、結果が正しいかまでは見ていません。境界のような大事な値を試しているかどうかは、カバレッジからはわからないのです。

実験2:中身のないアサーションでも、数字は上がる

次は、こんなテストを考えます。

public function test_合計金額を計算する(): void
{
    $cart = new Cart();
    $cart->add('りんご', 150, 3);

    $this->assertIsInt($cart->totalWithTax());
}

assertIsInt() は「結果が整数であること」しか確かめていません。このテスト1つだけで Cart のカバレッジを測ると、次のようになります。

OK (1 test, 1 assertion)

  Lines:     80.00% (12/15)

たった1つのテストで、80%の行を実行しています。ところが、第2回と同じ「数量を掛け忘れる」バグを入れても、このテストは通ってしまいます。

OK (1 test, 1 assertion)

結果が整数でありさえすれば通るので、金額が間違っていても気づけないのです。カバレッジの目標値だけを追いかけると、こうした「数字を上げるためだけのテスト」が増えがちです。

アサーションを1つも書かないテストは、PHPUnitが「リスキー(R)」として警告してくれます。しかし、assertIsInt() のような弱いアサーションは警告の対象になりません。テストの質は、最終的には人が見て判断するしかないのです。

0%の部分は、必ず理由を考える

一方で、0%や低い数字には必ず意味があります。見つけたら、次のどちらかを判断しましょう。

判断例
テストを追加するOrderConfirmed の本文。お客様に届く内容なので、テストで守る価値がある
テストしなくてよいと判断するSystemClock。new DateTimeImmutable() を呼ぶだけの1行で、ロジックがない

SystemClock のように、ほぼPHPの標準機能を呼ぶだけのコードをテストしても、得られるものはほとんどありません。第4回でロジックを SaleDiscount に集め、時刻の取得を小さなクラスに追い出したのは、まさに「テストしなくてもよい部分を最小にする」ための設計でもあったのです。

#[CoversClass] で、どのテストが何を確かめているかを明示する

第2回で、PHPUnitが自動生成する設定には requireCoverageMetadata="true" が含まれていて、テストがリスキーになったことを覚えているでしょうか。これは「どのクラスをテストしているかを、属性で明示しなさい」という設定でした。

use PHPUnit\Framework\Attributes\CoversClass;

#[CoversClass(Cart::class)]
final class CartTest extends TestCase
{
    // ...
}

#[CoversClass(Cart::class)] を付けると、「このテストクラスは Cart を確かめるためのもの」と宣言したことになります。すると、このテストクラスの実行中についでに通った別のクラスの行は、カバレッジに数えられなくなります。

たとえば、OrderServiceTest に #[CoversClass(OrderService::class)] を付けたとします。このテストの中では StockChecker の本物も動きますが、それは StockChecker をテストしたことにはなりません。「ついでに通っただけ」の行でカバレッジが水増しされるのを防げるので、数字がより正直になります。

チーム開発でカバレッジを本格的に活用するなら、第2回で外した requireCoverageMetadata と beStrictAboutCoverageMetadata を phpunit.xml に戻し、すべてのテストに #[CoversClass] を付ける運用も検討してみてください。

まとめ:カバレッジは「健康診断」

カバレッジは、テストの「健康診断」のようなものです。数値が低ければ、どこかに問題がある可能性が高いので、原因を調べます。しかし、数値が高いからといって健康だとは限りません。

  • 0%や低い部分は、テストの穴の候補。理由を確かめる
  • 数字を目標にしない。100%でも、境界値や結果の正しさは保証されない
  • 大事なロジックほど、カバレッジではなく「どんな値を試しているか」でテストの質を見る

Laravelの php artisan test --coverage --min=90 のように、カバレッジが指定した値を下回ったら失敗させる機能もあります。導入する場合は、高すぎる値ではなく「大きく下がったら気づける」程度の値にとどめるのがおすすめです。

GitHub Actions で、テストを自動実行する

CIとは

テストは、実行しなければ意味がありません。しかし、忙しいときほど「今回は小さな修正だから」とテストの実行を省きたくなります。チームで開発していれば、誰かが実行し忘れることもあるでしょう。

そこで、コードをGitHubにpushするたびに、テストを自動で実行する仕組みを用意します。これをCI(Continuous Integration、継続的インテグレーション)と呼びます。GitHubには、CIを動かすための「GitHub Actions」という機能が標準で備わっていて、公開リポジトリなら無料で使えます。

ワークフローファイルを作る

GitHub Actionsの設定は、リポジトリの .github/workflows/ フォルダにYAML形式のファイルとして置きます。第1部の php-test-cart に、.github/workflows/tests.yml を作成します。

name: Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  phpunit:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        php: ['8.4', '8.5']

    name: PHP ${{ matrix.php }}

    steps:
      - name: コードをチェックアウト
        uses: actions/checkout@v7

      - name: PHPをセットアップ
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          coverage: pcov

      - name: 依存パッケージをインストール
        run: composer install --no-interaction --no-progress --prefer-dist

      - name: テストを実行
        run: vendor/bin/phpunit --coverage-text

上から順に、何をしているかを見ていきます。

部分意味
on:いつ実行するか。main ブランチへのpushと、すべてのプルリクエストで実行する
runs-on: ubuntu-latestGitHubが用意するUbuntuの仮想マシンで実行する
strategy.matrix.phpPHP 8.4と8.5の2つのバージョンで、同じテストを並行して実行する
actions/checkoutリポジトリのコードを仮想マシンに取り込む
shivammathur/setup-php指定したバージョンのPHPと、カバレッジドライバ(PCOV)を用意する
composer installcomposer.lock のとおりに依存パッケージを入れる
vendor/bin/phpunitテストを実行する。1件でも失敗すれば、ワークフロー全体が失敗になる

matrix を使うと、複数のPHPバージョンでの動作をまとめて確かめられます。次のPHPのバージョンが出たときに、「PHPを上げたら動かなくなった」という事態を事前に防げます。

composer install は composer.lock を元にパッケージを入れます。composer.lock は必ずGitにコミットしておきましょう。逆に、vendor/ はコミットしないのが基本です(第2回で .gitignore に追加しました)。

pushして確かめる

ファイルをコミットしてGitHubにpushすると、リポジトリの「Actions」タブでワークフローが動き始めます。

git add .github/workflows/tests.yml
git commit -m "GitHub Actionsでテストを自動実行する"
git push

【スクショ:GitHubの Actions タブで、PHP 8.4 と PHP 8.5 の2つのジョブが緑のチェックになった画面】

【スクショ:ジョブの詳細画面で「テストを実行」ステップを開き、PHPUnit の結果とカバレッジが表示されている画面】

テストが失敗すると、ジョブに赤い×印が付き、GitHubから通知が届きます。プルリクエストの画面にも結果が表示されるので、レビューする人も「テストが通っているか」をひと目で確認できます。

【スクショ:プルリクエストの画面で、チェックが失敗して赤い×が表示されている状態】

ブランチ保護で「テストが通らないとマージできない」ようにする

さらに一歩進めて、テストが通らないプルリクエストはマージできないように設定できます。GitHubのリポジトリの「Settings」→「Rules」(または「Branches」)で、main ブランチに対して「マージ前に必須のステータスチェックを通すこと」を有効にし、今回作ったジョブを指定します。

これで、「テストが落ちたまま本番のブランチに取り込まれる」ことが、仕組みとして起きなくなります。

GitHubの設定画面の名前や場所は、変更されることがあります。見つからない場合は、GitHubの公式ドキュメントで「ブランチ保護」や「ルールセット」を検索してください。

Laravel版のワークフロー

第2部の shop プロジェクトでは、Laravelアプリの初期設定が必要な分、ステップが少し増えます。

name: Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  tests:
    runs-on: ubuntu-latest

    steps:
      - name: コードをチェックアウト
        uses: actions/checkout@v7

      - name: PHPをセットアップ
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: pcov

      - name: 依存パッケージをインストール
        run: composer install --no-interaction --no-progress --prefer-dist

      - name: .env を用意する
        run: |
          cp .env.example .env
          php artisan key:generate

      - name: テストを実行
        run: php artisan test --coverage

.env はパスワードなどを含むためGitにコミットしないのが基本です。そこで、CIの中でサンプルの .env.example をコピーし、アプリケーションキーを生成しています。

DBについては、特別な準備はいりません。第7回で見たとおり、phpunit.xml の設定で、テストではSQLiteのインメモリDBが使われるからです。本番と同じMySQLでテストしたい場合は、GitHub Actionsの「サービスコンテナ」という機能で、ワークフローの中にMySQLを立ち上げることもできます。

まとめ

  • カバレッジは、テストで実行された本番コードの割合。測るにはPCOVかXdebugが必要
  • --coverage-html のレポートで、一度も実行されていない行を見つけられる。Laravelなら php artisan test --coverage
  • 0%や低い部分は、テストを足すか、テスト不要と判断するかを必ず決める
  • 100%でもバグは残る。カバレッジは「どんな値で試したか」「結果を正しく確かめたか」までは見ない
  • #[CoversClass] で、どのテストが何を確かめるかを明示すると、数字が正直になる
  • GitHub Actionsで、pushやプルリクエストのたびにテストを自動実行し、ブランチ保護でテストが通らない変更の取り込みを防ぐ

次回予告

次回はいよいよ最終回、第10回「TDDとレガシーコード」です。これまでは「コードを書いてからテストを書く」順番で進めてきました。最終回では、テストを先に書いてから実装する「テスト駆動開発(TDD)」を、小さな機能を1つ作りながら実演します。後半では、テストが1本もない既存のコードに、安全にテストを導入していく手順を扱います。

参考資料

B!
← 一覧へ戻る