プラグイン(拡張アプリ)のテストの書き方


テスト基盤 ablogcms/testing-framework を使って、単体テスト・統合テストを書く具体的な手順です。導入がまだの場合は先に プラグイン(拡張アプリ)のテスト を読んでください。

テストの種類と使い分け

テストは大きく 2 種類です。速くて壊れにくい単体テストを優先し、DB や本体の状態がどうしても必要なときだけ統合テストに上げるのが基本です。

種類

基底クラス

DB

速さ

主な対象

単体テスト

TestCase

使わない

速い

純粋なロジック(Services / Validator / 校正オプション / Hook の計算部分)

統合テスト

DatabaseTestCase

使う(自動ロールバック)

やや遅い

DB への保存・取得、ServiceProvider の登録、複数レコードにまたがる振る舞い

同じ確信が得られるなら、依存が少なく速いテストを選びます。まず「単体で書けないか」を考え、書けないときだけ統合テストにするのがコツです。

何をテストするか

a-blog cms のハンドラ(ACMS_GET_* / ACMS_POST_*)は CSRF・セッション・グローバル変数に依存するため、そのままでは直接テストしにくい構造です。ロジックを Services / Validator に切り出し、そこをテストするのが基本方針です。ハンドラ本体は薄く保ち、切り出したクラス経由で間接的にカバーします。

対象

テストする

使う基底クラス

Services/ の純粋なロジック

✅ 単体

TestCase

Validator の入出力

✅ 単体

TestCase

Corrector(校正オプション)の入出力

✅ 単体

TestCase

Hook の各メソッドのロジック

✅ 単体

TestCase

DB を伴う Services / データ取得

✅ 統合

DatabaseTestCase

ServiceProvider の登録

✅ 統合

DatabaseTestCase

GET/ POST/ ハンドラ本体

ロジックを Services に切り出して間接的に

単体テスト(TestCase

DB に触れない純粋なロジックは Acms\TestingFramework\TestCase を継承します。速く、外部依存が無いので、まずここに寄せられないかを考えます。

<?php

declare(strict_types=1);

namespace Acms\Plugins\Foo\Tests\Unit\Services;

use Acms\Plugins\Foo\Services\Foo\Helper;
use Acms\TestingFramework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;

final class HelperTest extends TestCase
{
    #[Test]
    #[TestDox('先頭にアットマークが無ければ付与する')]
    public function 先頭にアットマークを付与する(): void
    {
        // Arrange
        $helper = new Helper();

        // Act
        $result = $helper->formatHandle('appleple');

        // Assert
        $this->assertSame('@appleple', $result);
    }
}

テストメソッドは PHPUnit の Attribute(#[Test] / #[TestDox])で書きます。test プレフィックスは不要です。#[TestDox] にはテストレポートに出したい説明を日本語で書けます。中身は Arrange(準備)→ Act(実行)→ Assert(検証)の AAA パターンで整えると読みやすくなります。

複数の入力をまとめて検証する(データプロバイダ)

同じロジックを入力違いで何度も確かめたいときは、PHPUnit の データプロバイダ を使うとテストが 1 つで済みます。

use PHPUnit\Framework\Attributes\DataProvider;

#[Test]
#[DataProvider('provideHandles')]
#[TestDox('$input を正規化すると $expected になる')]
public function ハンドルを正規化する(string $input, string $expected): void
{
    $this->assertSame($expected, (new Helper())->formatHandle($input));
}

public static function provideHandles(): array
{
    return [
        'アットマーク無し' => ['appleple', '@appleple'],
        'アットマーク付き' => ['@appleple', '@appleple'],
        '前後の空白'       => ['  appleple ', '@appleple'],
    ];
}

モックとテストダブル

外部サービスや重い依存に触れるコードは、その依存を テストダブル(モック)に差し替えると単体テストにできます。前提は「依存をコンストラクタ引数などで外から渡せる形にしておく」ことです。

テストダブル(モック)は PHPUnit の createMock メソッドを利用して作成できます。

#[Test]
public function 通知の送信をクライアントに委譲する(): void
{
    // 送信クライアントをモックに差し替える
    $client = $this->createMock(HttpClient::class);
    $client->expects($this->once())
        ->method('post')
        ->with('https://example.com/hook', ['text' => 'hello']);

    (new Notifier($client))->notify('hello');
}
  • グローバル関数や Facade(Auth:: など)に直接触れるコードはモックしにくいです。無理にモックするより、その呼び出しを薄いラッパークラス(seam)に隔離し、ラッパーを注入する形にリファクタします。隔離した本体ロジックは単体テストで固定でき、ラッパー自体は統合テストで薄く担保します。

  • モックが多すぎる・作りにくいと感じたら、たいてい設計を見直す合図です(後述の「テストを書きやすくするコツ」)。

テスト用データベースを用意する

統合テストは実際の DB に接続します。テスト専用のデータベースを 1 つ用意してください(本番とは必ず分けます)。各テストはトランザクション内で実行され、終了時に自動でロールバックされるので、テストがデータを残すことはありませんが、事故防止のため別 DB にします。単体テストしか書かないなら、この準備は不要です。

用意するものは 3 つです。

  • MySQL(互換)サーバ — 同梱イメージ appleple/acms を docker compose で使う場合は db サービスがこれにあたります。

  • テスト用データベースが 1 つ存在すること — docker compose なら db サービスの MYSQL_DATABASE、手動なら CREATE DATABASE で作ります。vendor/bin/acms-create-databaseその中にテーブルを作るだけで、データベース自体は作りません。

  • 接続情報を渡すことphpunit.xml ではなく、本体の .env.testingACMS_ROOT 直下)か環境変数で渡します。

.env.testingACMS_ROOT(本体側)に置くファイルで、テスト用の接続先をまとめます。

# <ACMS_ROOT>/.env.testing
ACMS_DB_HOST=db
ACMS_DB_PORT=3306
ACMS_DB_NAME=db_acms_test
ACMS_DB_USER=root
ACMS_DB_PASS=root

同じ ACMS_DB_* は、CI や docker compose の environment に環境変数として書いても構いません。未指定時の既定は 127.0.0.1 / 3306 / db_acms_test / root / root です。テーブルは初回に一度だけ作成します。

vendor/bin/acms-create-database

環境構築の全体像(Docker の起動から)は プラグイン(拡張アプリ)のテスト を参照してください。

統合テスト(DatabaseTestCase

DB を伴う処理は Acms\TestingFramework\DatabaseTestCase を継承します。各テストはトランザクション内で実行され、終了時に自動でロールバックされます。実 DB に接続しても、テストが実際のデータを汚すことはありません。テストごとに DB がまっさらな状態から始まるので、テスト同士が互いに影響しません。

自動ロールバックが効かない操作に注意。 ALTER TABLE / DROP TABLE などの DDL は多くの DB で暗黙にコミットされ、それまでのロールバックを打ち消します。全文検索インデックス・空間インデックスを伴う処理もトランザクション外で確定します。これらを含む処理は統合テストで直接扱わず、ロジックを純粋関数に切り出して単体でテストするか、実機での確認に回してください。

テストデータは setUpDatabase() の中で用意します。

<?php

declare(strict_types=1);

namespace Acms\Plugins\Foo\Tests\Integration;

use Acms\Plugins\Foo\Services\Foo\UserMapper;
use Acms\TestingFramework\DatabaseTestCase;
use Acms\TestingFramework\Seeder\UserSeeder;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;

final class UserMapperTest extends DatabaseTestCase
{
    private int $blogId;

    protected function setUpDatabase(): void
    {
        $this->blogId = $this->createTestBlog(['blog_name' => 'テストブログ']);
    }

    #[Test]
    #[TestDox('メールアドレスから有効なユーザーを特定できる')]
    public function メールで有効ユーザーを特定する(): void
    {
        $userId = UserSeeder::seed($this->blogId, ['user_mail' => 'alice@example.com']);

        $result = (new UserMapper($this->blogId))->resolve('alice@example.com');

        $this->assertSame($userId, $result['userId']);
    }
}

DatabaseTestCase のヘルパー

テストデータの用意・確認によく使うメソッドが揃っています。

// エンティティ作成(必要な値だけ渡す)
$blogId     = $this->createTestBlog(['blog_name' => 'テストブログ']);
$categoryId = $this->createTestCategory($blogId, ['category_name' => 'ニュース']);
$userId     = $this->createTestUser(['user_mail' => 'test@example.com']);
$entryId    = $this->createTestEntry($blogId, $categoryId, ['entry_title' => '記事']);

// 任意テーブルへの汎用操作
$id    = $this->insertTestData('table', ['col' => 'value']);
$row   = $this->fetchTestData('table', ['id' => $id]);
$count = $this->countTestData('table', ['status' => 'open']);
$this->deleteTestData('table', ['id' => $id]);

処理後の DB 状態を検証したいときは、fetchTestData() / countTestData() で実際に保存された行を取り出して assert します(更新系のテストは「戻り値」だけでなく「DB がどう変わったか」まで確認すると副作用の見落としを防げます)。

テストデータの作成(Seeder)

Acms\TestingFramework\Seeder\* は「必要な値だけ渡し、残りは自動で埋める」ためのデータ生成クラスです。直接 INSERT するより簡潔で、テストの意図(何が重要な値か)が読み取りやすくなります。

use Acms\TestingFramework\Seeder\BlogSeeder;
use Acms\TestingFramework\Seeder\CategorySeeder;
use Acms\TestingFramework\Seeder\UserSeeder;

$blogId = BlogSeeder::seed(['blog_name' => 'テストブログ']);
$userId = UserSeeder::seed($blogId, ['user_mail' => 'alice@example.com', 'user_auth' => 'administrator']);
CategorySeeder::seed($blogId, [], 5); // 5 件まとめて作成
  • Blog / Category / User / Entry のほか、Field / Tag / Module / Media / Comment / Relationship / Usergroup / Role など主要エンティティ向けの Seeder が用意されています。

  • 渡したキーは、Seeder が既定値を持つカラムを上書きします。既定で埋めないカラムも、キーとして渡せば保存されます。

  • 存在しないカラム名を渡すとエラーになります(書き間違いにその場で気づけます)。

  • ブログ ID など引数で渡す値は、同名のキーを渡しても引数側が優先されます。

プラグインの有効化を前提にしたテスト(PluginActivator

プラグインの有効/無効に依存するロジックを検証したいときに使います。プラグイン名を渡すと、app テーブルに保存される ServiceProvider のクラス名(Acms\Plugins\{名前}\ServiceProvider)へ内部で補完します。

use Acms\TestingFramework\Helpers\PluginActivator;

PluginActivator::activate('Foo', $blogId);
$this->assertTrue(PluginActivator::isActive('Foo', $blogId));

ServiceProvider が規約と異なる場所にある場合は、FQCN を直接渡すこともできます。

統合テストで「対象プラグインが有効なときだけ動く挙動」や、別のプラグインに依存する挙動を検証したいときは、activate() で必要なプラグインを先に有効化してからテストします。有効化漏れは、テストが本番と違う前提で通ってしまう(偽陽性)原因になります。

テストの実行

本体を含む環境(Docker コンテナ)の中で実行します。

vendor/bin/phpunit                       # 全部
vendor/bin/phpunit --testsuite=Unit      # 単体だけ
vendor/bin/phpunit --testsuite=Integration  # 統合だけ
vendor/bin/phpunit --filter 先頭にアットマークを付与する  # メソッド名で絞り込み

統合テストを回す前には、一度だけ vendor/bin/acms-create-database でテスト用 DB のテーブルを作成しておきます。

失敗したテストを調べる

落ちたテストの原因を追うときは、次のフラグが役立ちます。

vendor/bin/phpunit --testdox              # 何を検証しているかを文章で一覧(#[TestDox] が出る)
vendor/bin/phpunit --filter メソッド名     # 1 メソッドだけ実行して素早く再現する
vendor/bin/phpunit --display-deprecations # 非推奨の警告も表示する
vendor/bin/phpunit --stop-on-failure      # 最初の失敗で止める

スタックトレースは、自分のコードに一番近い行から読むと原因にたどり着きやすいです。

テストを書きやすくするコツ

  • ハンドラから純粋関数・Services へ切り出す。 テストしにくいと感じたら、それはテストの問題ではなく設計の合図です。ロジックを引数と戻り値で完結するクラスに移すと、単体テストで完全に固定できます。

  • 単体で書けないか先に検討する。 DB が本当に要るときだけ統合テストにします。単体テストは速く、失敗時の原因切り分けも簡単です。

  • 1 テスト 1 検証。 1 つのテストメソッドで確かめることを 1 つに絞ると、落ちたときに何が壊れたか一目でわかります。

  • 実装ではなく振る舞いをテストする。 「内部でどのメソッドを呼んだか」ではなく「入力に対して何を返すか・何が変わるか」を検証します。実装を変えても振る舞いが同じなら、テストは緑のままであるべきです。

  • モックが多すぎるのは設計の合図。 1 つのテストで大量のモックが必要なクラスは、責務や依存を持ちすぎています。分割・注入を見直すとモックもテストも減ります。

  • テストを先に書く(TDD)。 失敗するテスト → 通す最小実装 → リファクタの順で回すと、テストしやすい設計に自然と寄っていきます。

関連ドキュメント