技術資料

PHPUnitでPHPのテストを始める:正常系・例外・モックの基本

作成日:2026.09.14

PHP 8.3とPHPUnit 12.5を使い、フレームワークやデータベースに依存しないPHPの例で、テストの考え方からComposerによる導入、基本的なアサーション、データプロバイダー、例外、モックまでを解説し、PHPUnitの基本を整理します。

PHPでアプリケーションを作っていると、コードを変更するたびに手動で画面を操作したり、いくつかの入力を試したりすることがあります。

しかし、確認する項目が増えるほど、毎回同じ操作を繰り返すのは大変です。前回は確認した入力を、今回は確認し忘れることもあります。そこで、期待する結果をPHPのコードとして記録し、同じ確認を何度でも実行できるようにします。

今回はPHPUnitを使って、テストとは何を確認するものなのか、どのようにテストを書き始めるのかを、PHPだけのシンプルな例で整理します。特定のフレームワークやデータベースは使わず、正常系、入力エラー、例外、外部処理への依存を順番に確認してみます。

今回の前提

今回の動作確認環境は以下の通りです。

  • OS: Windows 11
  • PHP: 8.3.15
  • Composer: 2.5.4
  • PHPUnit: 12.5系
  • 実行方法: PHP CLI、PowerShell

PHPUnit 12はPHP 8.3が必要です。対象環境のPHPが古い場合は、PHPUnitのメジャーバージョンと対応するPHPのバージョンを確認してください。

PHPUnitは、テストを書くためのフレームワークと、テストを実行するコマンドラインツールを提供します。今回はプロジェクトごとにバージョンを管理しやすいよう、Composerの開発用依存関係へ追加します。

テストで確認するもの

テストでは、実装のすべての行を確認するのではなく、コードが満たすべき振る舞いを確認します。

例えば、入力された名前を扱うクラスに次のルールがあるとします。

  • 前後の空白は取り除く
  • 空文字列は受け付けない
  • 20文字を超える名前は受け付けない
  • 問題がなければ、整形した名前を保持する

この場合、テストで確認するのは「内部でどの関数を呼び出したか」ではなく、入力に対して期待した値になるか、期待した例外が発生するかです。

一つのテストでは、次の3つを決めます。

  1. どのような前提を用意するか
  2. 何を実行するか
  3. どの結果なら正しいと判断するか

この流れは、Arrange、Act、Assertと呼ばれることがあります。日本語では、準備、実行、検証と考えると分かりやすいと思います。

PHPUnitをインストールする

まず、PHPのプロジェクトを用意します。すでにComposerを使っているプロジェクトでは、プロジェクトのルートディレクトリで次のコマンドを実行します。

composer require --dev phpunit/phpunit:^12.5

--devは、開発時だけ必要な依存関係として追加する指定です。PHPUnitはアプリケーションの実行に必要なライブラリではないため、通常は開発用依存関係へ追加します。

インストールが完了したら、プロジェクト内のPHPUnitを実行してバージョンを確認します。

vendor/bin/phpunit --version

WindowsのPowerShellでも、Composerが作成したvendor/bin/phpunitを実行できます。プロジェクトによっては、Composer Scriptへテストコマンドを登録して、composer testのように実行する方法もあります。

テストのディレクトリを用意する

今回は、次のようなディレクトリ構成にします。

phpunit-example/
├── composer.json
├── phpunit.xml
├── src/
│   └── UserName.php
└── tests/
    └── UserNameTest.php

srcにはテスト対象のコード、testsにはテストコードを置きます。Composerのオートロードを使うため、composer.jsonへ名前空間とディレクトリの対応も定義します。

{
    "require": {
        "php": "^8.3"
    },
    "require-dev": {
        "phpunit/phpunit": "^12.5"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Tests\\": "tests/"
        }
    }
}

composer.jsonを変更した場合は、オートロード用のファイルを更新します。

composer dump-autoload

テストを自動検出するため、プロジェクトのルートにphpunit.xmlを作成します。

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php">
    <testsuites>
        <testsuite name="default">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

bootstrapでComposerのオートロードを読み込み、testsディレクトリをテストスイートとして登録しています。設定ファイルの形式は、利用するPHPUnitのバージョンに合わせて公式マニュアルを確認してください。

入力を検証するクラスを作る

まずは、名前を保持するUserNameクラスを作ります。外部サービスやデータベースへ接続しないため、テストの結果が環境に左右されにくい題材です。

<?php

declare(strict_types=1);

namespace App;

use InvalidArgumentException;

final class UserName
{
    private function __construct(
        private readonly string $value,
    ) {
    }

    public static function fromString(string $value): self
    {
        $normalized = trim($value);

        if ($normalized === '') {
            throw new InvalidArgumentException(
                '名前を入力してください。',
            );
        }

        if (mb_strlen($normalized) > 20) {
            throw new InvalidArgumentException(
                '名前は20文字以内で入力してください。',
            );
        }

        return new self($normalized);
    }

    public function value(): string
    {
        return $this->value;
    }
}

fromString()へ文字列を渡すと、まず前後の空白を取り除きます。空文字列や21文字以上の値の場合は、InvalidArgumentExceptionを投げます。

日本語を含む文字列の長さを扱うため、strlen()ではなくmb_strlen()を使っています。対象環境でmbstring拡張が有効になっていることも確認してください。

最初のテストを書く

次に、UserNameのテストを作ります。PHPUnitのテストケースは、通常PHPUnit\Framework\TestCaseを継承します。

<?php

declare(strict_types=1);

namespace Tests;

use App\UserName;
use PHPUnit\Framework\TestCase;

final class UserNameTest extends TestCase
{
    public function test_it_removes_surrounding_spaces(): void
    {
        $userName = UserName::fromString('  Tatsuya  ');

        $this->assertSame('Tatsuya', $userName->value());
    }
}

このテストでは、次のように処理しています。

  • 準備: 前後に空白がある文字列を用意する
  • 実行: UserName::fromString()を呼び出す
  • 検証: 保持された値がTatsuyaと一致することを確認する

assertSame()は、値と型が同じであることを確認するアサーションです。例えば、文字列の'1'と整数の1を区別したい場合に使います。

テストを実行します。

vendor/bin/phpunit tests/UserNameTest.php

テストが成功すると、テスト件数とアサーション数が表示されます。失敗した場合は、期待値と実際の値、失敗したファイルと行番号を確認します。

正常系と境界値を確認する

正常な入力だけでなく、ルールの境界にある値も確認します。20文字を許可するなら、20文字は成功し、21文字は失敗する必要があります。

同じ処理を入力だけ変えて何度も確認するときは、データプロバイダーを使えます。

<?php

declare(strict_types=1);

namespace Tests;

use App\UserName;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class UserNameValidInputTest extends TestCase
{
    #[DataProvider('validNames')]
    public function test_it_accepts_valid_names(
        string $input,
        string $expected,
    ): void {
        $userName = UserName::fromString($input);

        $this->assertSame($expected, $userName->value());
    }

    public static function validNames(): iterable
    {
        // 通常の名前は、そのまま同じ値として返る
        yield 'ordinary name' => ['Tatsuya', 'Tatsuya'];

        // 前後の空白を取り除いた値が返る
        yield 'name with surrounding spaces' => [
            '  Tatsuya  ', // 入力値
            'Tatsuya',     // 期待する結果
        ];

        // 上限の20文字は受け付けられ、値は変わらない
        yield '20 characters' => [
            str_repeat('あ', 20), // 入力値
            str_repeat('あ', 20), // 期待する結果
        ];
    }
}

データプロバイダーを使うと、テストの処理は一つのまま、入力と期待値の組み合わせを増やせます。テスト結果にはデータプロバイダーで付けた名前も表示されるため、どの入力で失敗したのかを確認しやすくなります。

ただし、入力を大量に追加すればよいわけではありません。仕様上意味のある通常値、空値、境界値などを選び、何を確認するデータなのか分かる名前を付けます。

例外が発生することを確認する

不正な入力を渡した場合は、戻り値ではなく例外を確認します。PHPUnitでは、対象の処理を実行する前にexpectException()を呼び出します。

<?php

declare(strict_types=1);

namespace Tests;

use App\UserName;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class UserNameInvalidInputTest extends TestCase
{
    #[DataProvider('invalidNames')]
    public function test_it_rejects_invalid_names(
        string $input,
        string $message,
    ): void {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage($message);

        UserName::fromString($input);
    }

    public static function invalidNames(): iterable
    {
        // 空白だけの入力は、空文字列として拒否される
        yield 'empty string' => [
            '   ',                         // 入力値
            '名前を入力してください。',   // 期待する例外メッセージ
        ];

        // 21文字の入力は、20文字の上限を超えるため拒否される
        yield 'more than 20 characters' => [
            str_repeat('あ', 21),         // 入力値
            '名前は20文字以内で入力してください。', // 期待する例外メッセージ
        ];
    }
}

expectException()を対象処理の後に書くと、例外が発生した後で期待を設定することになるため、正しく検証できません。例外を期待すること、対象処理を実行することの順番に注意します。

例外メッセージまで検証するかどうかは、アプリケーションの目的で決めます。利用者へ表示するメッセージや、エラー処理の分岐に使うメッセージなら確認する意味があります。一方で、内部実装の細かな文言まで固定すると、文言を変更しただけで多くのテストを修正することになります。

外部処理への依存をテストする

ここまでのUserNameは、入力を受け取って値を返すだけでした。実際のアプリケーションでは、メール送信、データベース保存、外部API呼び出しなど、別のオブジェクトへ処理を依頼することがあります。

例えば、メールを送信する処理を次のインターフェースへ分離します。

<?php

declare(strict_types=1);

namespace App;

interface Mailer
{
    public function send(string $to, string $body): void;
}

メール送信を依頼するクラスは、具体的なSMTPクライアントを直接作らず、Mailerを受け取るようにします。

<?php

declare(strict_types=1);

namespace App;

final class WelcomeMessageSender
{
    public function __construct(
        private readonly Mailer $mailer,
    ) {
    }

    public function send(string $email): void
    {
        $this->mailer->send(
            $email,
            'ご登録ありがとうございます。',
        );
    }
}

このクラスをテストするとき、実際のメールサービスへ接続する必要はありません。PHPUnitのモックを使い、Mailer::send()が期待した引数で1回呼び出されたことを確認します。

<?php

declare(strict_types=1);

namespace Tests;

use App\Mailer;
use App\WelcomeMessageSender;
use PHPUnit\Framework\TestCase;

final class WelcomeMessageSenderTest extends TestCase
{
    public function test_it_sends_a_welcome_message(): void
    {
        $mailer = $this->createMock(Mailer::class);
        $mailer
            ->expects($this->once())
            ->method('send')
            ->with(
                'tatsuya@example.com',
                'ご登録ありがとうございます。',
            );

        $sender = new WelcomeMessageSender($mailer);

        $sender->send('tatsuya@example.com');
    }
}

このテストでメールが実際に送信されるわけではありません。WelcomeMessageSenderが、Mailerへ正しい依頼をしたことを確認しています。

テストダブルには、戻り値を用意するスタブや、呼び出し方を検証するモックなどがあります。外部サービスを使わずに特定の分岐を確認したい場合には便利ですが、実装の内部呼び出しを細かく固定しすぎると、実装を変更しにくくなることがあります。

テストを書きやすいコードにする

テストが難しいときは、テストコードの書き方だけでなく、テスト対象の構造も確認します。

例えば、次のように一つのメソッドの中で現在時刻、乱数、外部通信、データベース保存をすべて行うと、テストの準備が複雑になります。

public function register(): void
{
    $now = new \DateTimeImmutable();
    $token = bin2hex(random_bytes(16));
    $response = file_get_contents('https://example.com/api');

    // 取得結果を加工してデータベースへ保存する
}

このような処理では、毎回同じ時刻や乱数になるとは限らず、ネットワークの状態にも結果が左右されます。現在時刻を受け取る、乱数を生成する処理を別のオブジェクトへ分ける、HTTPクライアントを注入するなど、役割を分離すると確認しやすくなります。

ただし、テストのためだけに細かいクラスを大量に作ればよいわけではありません。まずは一つのクラスが複数の責務を持っていないか、外部処理を直接呼び出していないかを確認し、必要な範囲だけ分離します。

テスト結果を確認する

プロジェクト内のすべてのテストを実行する場合は、引数を付けずにPHPUnitを起動します。

vendor/bin/phpunit

特定のテストだけを実行したい場合は、ファイルやテスト名を指定します。

vendor/bin/phpunit tests/UserNameTest.php
vendor/bin/phpunit --filter test_it_rejects_invalid_names

テストが失敗したときは、まず次の順番で確認します。

  1. どのテストが失敗したか
  2. 期待値と実際の値が何か
  3. テストの入力値が意図したものか
  4. 実装の不具合か、テストの期待値の誤りか
  5. PHPのバージョンや拡張など、環境による差がないか

テストが成功しても、すべての問題がなくなったとは限りません。テストに含めていない分岐や、実際のメールサービス、データベース、ブラウザ操作などは別に確認する必要があります。

テストの量とカバレッジだけを目的にしない

テストを増やすと、確認できる範囲は広がります。一方で、似たようなテストを増やしすぎると、実装変更にともなう修正量も増えます。

まずは次のような処理からテストを追加すると、変更時の影響を確認しやすくなります。

  • 入力値の正規化や変換
  • 条件分岐が多い処理
  • 不正入力や権限エラーを扱う処理
  • 金額や在庫など、間違えると影響が大きい処理
  • 外部サービスへ依存し、失敗時の扱いが重要な処理

コードカバレッジは、テストが実行した行や分岐の割合を確認する指標です。カバレッジが高くても、期待値が適切でなければ、間違った結果を検出できません。カバレッジは未実行の箇所を見つける補助として使い、テストの内容そのものも確認します。

LaravelやCIのテストへ進む場合

今回の例は、フレームワークに依存しないPHPのテストです。Laravelでは、HTTPリクエストやデータベースを含めて確認するFeature Testなど、別の種類のテストも使います。

LaravelのFeature Testで開発用DBへ接続しないための設定や安全ガードについては、以前のLaravelのFeature Testで開発用DBを初期化してしまった記事で扱いました。PHPUnitの基本を確認した後に読むと、テストコードだけでなく、テスト環境の安全性も考える必要があることが分かります。

また、GitHub Actionsへテストを組み込むと、PushやPull Requestをきっかけに同じ検証を実行できます。導入方法はGitHub ActionsでPHP・LaravelのCIを始める記事で説明しています。

まとめ

PHPUnitを使うと、入力に対する期待結果や、例外が発生する条件を、繰り返し実行できるテストとして記録できます。

まずは外部サービスやデータベースに依存しない小さなクラスから始め、準備、実行、検証の流れでテストを書きます。正常系だけでなく、境界値、不正入力、例外も確認すると、変更による問題を見つけやすくなります。

外部処理をテストするときは、依存するオブジェクトを分離し、必要に応じてテストダブルを使います。ただし、内部実装を細かく固定しすぎると、テストが変更の妨げになることもあります。

テストの数やカバレッジを増やすことだけを目的にせず、重要な仕様や失敗すると影響の大きい処理から、実行可能な確認を追加していくのがよいかと思います。

参考資料

この記事を書いた人

※上が私です。

奈良市を拠点に、27年以上の経験を持つフリーランスWebエンジニア、阿部辰也です。

これまで、ECサイトのバックエンド開発や業務効率化システム、公共施設の予約システムなど、多彩なプロジェクトを手がけ、企業様や制作会社様のパートナーとして信頼を築いてまいりました。

【制作会社・企業様向けサポート】
  • 専任エンジニアのいない企業様に対するシステム面の不安を解消
  • 柔軟な契約形態や短納期での対応により、急なニーズにも迅速にサポート
  • システムの企画段階から運用まで、ワンストップでのサービスを提供

Webシステムの開発やサイト改善でお困りの際は、どうぞお気軽にご相談ください。小さな疑問から大規模プロジェクトまで、最適なご提案を心を込めてさせていただきます。

ぜひ、プロフィールやWeb制作会社様向け業務案内、一般企業様向け業務案内もご覧くださいね。

Laravelのコードレビューで遅いSQLの候補を洗い出す

2026.10.02

Laravelのコードレビューで、ループ内のDBアクセスやEloquentのN+1、大量取得、検索条件とインデックスなど、性能問題につながりやすい箇所を候補として洗い出します。Laravelで発行SQLと実行時間を確認し、MySQLのEXPLAINや実データに近い環境での計測へつなげる方法を紹介します。コードレビューだけではSQLの実行時間を断定できない点も説明します。

Laravel MySQL PHP

Windows 11でDocker Composeを使ってLaravel環境を作る:Container・Volume・Networkの基本

2026.10.01

Windows 11のDocker Desktop上に、DockerfileとDocker Composeを使ってLaravel・PHP・MySQL・Mailpitの開発環境を構築します。ImageとContainerの関係、Bind MountとNamed Volumeによるデータの保存、ポート公開とContainer間通信の違いを実例で確認。LaravelからMySQLへ接続するときに、localhostではなくService名を使う理由も説明します。

Docker Laravel PHP

PHPでYAML設定を検証する:symfony/yamlとopis/json-schemaの使い方

2026.09.30

symfony/yamlでYAMLを読み込み、opis/json-schemaで必須項目や型、許可する値を検証する方法を紹介します。YAMLのマップをstdClassとして扱う方法や、空のobjectとarrayの違い、信頼できないYAMLを扱う際の注意点も解説します。

PHP YAML

PHPの正規表現で日本語・改行を扱う:u・m・s修飾子の違い

2026.09.29

PHPの正規表現で日本語や複数行テキストを扱う際の、u・m・s修飾子の違いを解説します。Unicode文字クラスと全角数字、CRLFの行末処理、\A・\zの使い方、Unicode正規化との違いを、PHP 8.3.15・PCRE2 10.42で確認したコード例とともに紹介します。

PHP 正規表現

阿部辰也へのお仕事の依頼・お問い合わせ

軽いご相談もお気軽にどうぞ!

個人情報の取り扱いについて *必須 プライバシーポリシーをご確認いただき、同意いただける場合は「同意する」にチェックをしてください。

keyboard_double_arrow_up
TOP