公開日 2026-03-19

PHPUnitでDBテストを始める(PostgreSQL + Docker)

PostgreSQLを使ったPHPのDBテストを最小構成で作り、失敗から安定化までの流れを確認する。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 新規デモ環境を作成して起動する
  4. 3. テスト用DBと環境変数を確認する
  5. コードのポイント
  6. 4. PHPUnit設定とテスト対象コードを作成する
  7. コードのポイント
  8. コードのポイント
  9. 5. 失敗するDBテストを書く
  10. 6. SQLを直してテストを通す
  11. 7. 状態リセットを入れて安定させる
  12. コードのポイント
  13. コードのポイント
  14. 8. よくある詰まりと次の一歩

本記事は、WSL2 + Docker で PHP を触っていて、PHPUnit の最初の 1 本か PDO + PostgreSQL の最小 CRUD は通したことがあるものの、DB ありテストはまだこれから、という読者向けです。
この 1 本だけで、appapp_test を分けた最小 DB テストを作り、失敗 -> 修正 -> 安定化まで進めます。

補助導線として先に読んでおくと入りやすい記事:

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL 連携有効)

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
サービス名は appdb に固定しています。

1. ゴールと非対象

この記事で到達する状態:

  • appapp_test を分けた PostgreSQL 環境を Docker で起動できる
  • vendor/bin/phpunit で DB テストを失敗 -> 修正 -> 成功まで再現できる
  • テストを 2 回続けて実行しても同じ結果になる

この記事で扱わない内容:

  • Laravel などのフレームワーク専用テストヘルパー
  • マイグレーションツール導入
  • トランザクションを使った高速ロールバック型テスト
  • GitHub Actions での自動実行
  • ORM ベースの設計

2. 新規デモ環境を作成して起動する

まずは記事用の最小デモを作ります。

mkdir -p ~/projects/phpunit-postgresql-db-test-demo
cd ~/projects/phpunit-postgresql-db-test-demo
mkdir -p docker/php docker/db/init src tests
code .

compose.yml を作成します。

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    working_dir: /workspace
    volumes:
      - ./:/workspace
    env_file:
      - .env
    command: ["sleep", "infinity"]
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    ports:
      - "5432:5432"
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./docker/db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 20

volumes:
  db-data:

docker/php/Dockerfile を作成します。

FROM php:8.5-cli

RUN apt-get update \
    && apt-get install -y --no-install-recommends git unzip libpq-dev \
    && docker-php-ext-install pdo pdo_pgsql \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /workspace

php:8.5-cli が手元の環境で pull できない場合は、同じ手順のまま php:8.4-cli に読み替えて構いません。

.env.example を作成します。

POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=app

APP_DB_HOST=db
APP_DB_PORT=5432
APP_DB_NAME=app
APP_DB_USER=app
APP_DB_PASS=app

composer.json も先に作成します。

{
  "name": "example/phpunit-postgresql-db-test-demo",
  "type": "project",
  "require": {},
  "require-dev": {},
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  }
}

.env を作って起動します。

cp .env.example .env
docker compose up -d --build
docker compose exec app php -v
docker compose exec app composer --version
コンテナ起動後のPHPとComposerのバージョン確認

ここではまだ app_test を作っていません。
3章で初期化 SQL を追加したあと、DB ボリュームを作り直します。

詰まったとき:

docker compose logs app
docker compose logs db

3. テスト用DBと環境変数を確認する

ここで通常用の app と、テスト用の app_test を分けます。
docker-entrypoint-initdb.d は初回起動時だけ実行されるので、この章では SQL 追加後に down -v で作り直します。

docker/db/init/01-create-test-db.sql を作成します。

CREATE DATABASE app_test;

docker/db/init/02-schema.sql を作成します。 appapp_test に同じ tasks スキーマを作るファイルです。\connect で接続先を切り替えながら、1 ファイルで 2 DB にテーブルを適用します。

CREATE TABLE IF NOT EXISTS tasks (
  id BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  is_done BOOLEAN NOT NULL DEFAULT FALSE
);

\connect app_test

CREATE TABLE IF NOT EXISTS tasks (
  id BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  is_done BOOLEAN NOT NULL DEFAULT FALSE
);

コードのポイント

\connect でDB接続を切り替えて 1 ファイルで 2 DB に適用する

);

\connect app_test

CREATE TABLE IF NOT EXISTS tasks (
  id BIGSERIAL PRIMARY KEY,

最初に app 側へ tasks テーブルを作り、そのあと \connect app_test でテスト用 DB に同じスキーマを作る形です。
ファイル名を 01-02- にしているのは、app_test の作成より先に schema 適用が走らないようにするためです。

初期化 SQL を追加したので、ボリュームを作り直します。

docker compose down -v
docker compose up -d --build

appapp_test の両方を確認します。

docker compose exec db psql -U app -lqt
docker compose exec db psql -U app -d app -c "\dt"
docker compose exec db psql -U app -d app_test -c "\dt"
app と app_test の DB 一覧と tasks テーブル確認

確認ポイント:

  • appapp_test が見える
  • appapp_test の両方に tasks テーブルがある
  • 通常実行時の接続先は .envAPP_DB_NAME=app

このあと PHPUnit 側で APP_DB_NAME だけを app_test へ切り替えます。

詰まったとき:

docker compose down -v
docker compose up -d --build

4. PHPUnit設定とテスト対象コードを作成する

ここからテストコードを置きます。
まずは 1 本目を通すことを優先するので、まだ共通基底クラスは作りません。

PHPUnit を導入します。

docker compose exec app composer require --dev phpunit/phpunit:^13
docker compose exec app vendor/bin/phpunit --version

ここでは現行 stable の phpunit/phpunit:^13 を使います。 Docker で再現する手順と実行コマンドをそろえたまま、DB テストの流れに集中できる構成です。

phpunit.xml を作成します。

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
  <testsuites>
    <testsuite name="default">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
  <php>
    <env name="APP_DB_NAME" value="app_test" force="true"/>
  </php>
</phpunit>

コードのポイント

force="true".env の値を上書きする

  <php>
    <env name="APP_DB_NAME" value="app_test" force="true"/>
  </php>

force="true" を付けているのは、app コンテナの .env ですでに APP_DB_NAME=app が入っているためです。 これが無いと、テスト実行時も app を向いたままになります。

src/TaskRepository.php を作成します。
ここでは、あえて失敗する SQL、つまり WHERE is_done = FALSE を入れていない状態にしておきます。

<?php
declare(strict_types=1);

namespace App;

use PDO;

final class TaskRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    /**
     * @return list<string>
     */
    public function findIncompleteTitles(): array
    {
        $stmt = $this->pdo->query('SELECT title FROM tasks ORDER BY id ASC');

        return array_map(
            static fn (array $row): string => (string) $row['title'],
            $stmt->fetchAll()
        );
    }
}

tests/TaskRepositoryTest.php を作成します。

<?php
declare(strict_types=1);

namespace Tests;

use App\TaskRepository;
use PDO;
use PHPUnit\Framework\TestCase;

final class TaskRepositoryTest extends TestCase
{
    private PDO $pdo;
    private TaskRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();

        $dsn = sprintf(
            'pgsql:host=%s;port=%s;dbname=%s',
            getenv('APP_DB_HOST') ?: 'db',
            getenv('APP_DB_PORT') ?: '5432',
            getenv('APP_DB_NAME') ?: 'app_test'
        );

        $this->pdo = new PDO(
            $dsn,
            getenv('APP_DB_USER') ?: 'app',
            getenv('APP_DB_PASS') ?: 'app',
            [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]
        );

        $this->repository = new TaskRepository($this->pdo);
    }

    public function testFindIncompleteTitlesReturnsOnlyTodoTasks(): void
    {
        $this->pdo->exec(
            <<<'SQL'
            INSERT INTO tasks (title, is_done)
            VALUES
              ('買い物', FALSE),
              ('請求書を送る', TRUE),
              ('ブログを書く', FALSE)
            SQL
        );

        $titles = $this->repository->findIncompleteTitles();

        $this->assertSame(['買い物', 'ブログを書く'], $titles);
    }
}

コードのポイント

APP_DB_NAME を環境変数から取って接続先を切り替える

        $dsn = sprintf(
            'pgsql:host=%s;port=%s;dbname=%s',
            getenv('APP_DB_HOST') ?: 'db',
            getenv('APP_DB_PORT') ?: '5432',
            getenv('APP_DB_NAME') ?: 'app_test'
        );

phpunit.xmlforce="true" と組み合わせると、この getenv('APP_DB_NAME') がテスト実行時だけ app_test を返す。フォールバックの ?: 'app_test' も書いておくと、環境変数が未設定でも意図した DB を向ける。

assertSame で順序まで含めて検証する

        $titles = $this->repository->findIncompleteTitles();

        $this->assertSame(['買い物', 'ブログを書く'], $titles);

assertSame は型と順序が一致しないと失敗する。assertEquals と違い、['ブログを書く', '買い物'] のような順序違いも検出できる。ORDER BY id ASC の動作まで 1 アサーションで確認している。

5. 失敗するDBテストを書く

いまの TaskRepositoryis_done を見ていないので、完了済みタスクまで返します。
まずはその失敗を確認します。

docker compose exec app vendor/bin/phpunit tests/TaskRepositoryTest.php
初回実行時の PHPUnit 13 失敗出力

失敗の見え方:

  • 期待値は 買い物, ブログを書く の 2 件
  • 実際値には 請求書を送る も入っている
  • 原因は WHERE is_done = FALSE が無いこと

relation "tasks" does not exist が出た場合は 3 章へ戻って、app_test 側のテーブル作成を確認してください。

6. SQLを直してテストを通す

src/TaskRepository.php を修正版へ更新します。

<?php
declare(strict_types=1);

namespace App;

use PDO;

final class TaskRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    /**
     * @return list<string>
     */
    public function findIncompleteTitles(): array
    {
        $stmt = $this->pdo->query(
            'SELECT title FROM tasks WHERE is_done = FALSE ORDER BY id ASC'
        );

        return array_map(
            static fn (array $row): string => (string) $row['title'],
            $stmt->fetchAll()
        );
    }
}

1 回前の失敗実行で app_test にデータが残っているので、手動で 0 件に戻してから再実行します。

docker compose exec db psql -U app -d app_test -c "TRUNCATE TABLE tasks RESTART IDENTITY;"
docker compose exec app vendor/bin/phpunit tests/TaskRepositoryTest.php
手動 TRUNCATE 後の PHPUnit 13 成功出力

ここで 1 本目は通ります。
ただし、このままだとテストを増やすたびに手動 TRUNCATE が必要です。次の章でここを自動化します。

7. 状態リセットを入れて安定させる

まずは、リセットを入れないまま 2 本目のテストを足してみます。
tests/TaskRepositoryTest.php を次のように更新してください。

<?php
declare(strict_types=1);

namespace Tests;

use App\TaskRepository;
use PDO;
use PHPUnit\Framework\TestCase;

final class TaskRepositoryTest extends TestCase
{
    private PDO $pdo;
    private TaskRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();

        $dsn = sprintf(
            'pgsql:host=%s;port=%s;dbname=%s',
            getenv('APP_DB_HOST') ?: 'db',
            getenv('APP_DB_PORT') ?: '5432',
            getenv('APP_DB_NAME') ?: 'app_test'
        );

        $this->pdo = new PDO(
            $dsn,
            getenv('APP_DB_USER') ?: 'app',
            getenv('APP_DB_PASS') ?: 'app',
            [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]
        );

        $this->repository = new TaskRepository($this->pdo);
    }

    public function testFindIncompleteTitlesReturnsOnlyTodoTasks(): void
    {
        $this->pdo->exec(
            <<<'SQL'
            INSERT INTO tasks (title, is_done)
            VALUES
              ('買い物', FALSE),
              ('請求書を送る', TRUE),
              ('ブログを書く', FALSE)
            SQL
        );

        $titles = $this->repository->findIncompleteTitles();

        $this->assertSame(['買い物', 'ブログを書く'], $titles);
    }

    public function testFindIncompleteTitlesStillReturnsTwoItems(): void
    {
        $this->pdo->exec(
            <<<'SQL'
            INSERT INTO tasks (title, is_done)
            VALUES
              ('買い物', FALSE),
              ('請求書を送る', TRUE),
              ('ブログを書く', FALSE)
            SQL
        );

        $titles = $this->repository->findIncompleteTitles();

        $this->assertCount(2, $titles);
    }
}

一度 app_test を空にしてから実行すると、2 本目で落ちます。

docker compose exec db psql -U app -d app_test -c "TRUNCATE TABLE tasks RESTART IDENTITY;"
docker compose exec app vendor/bin/phpunit tests/TaskRepositoryTest.php
状態リーク時の PHPUnit 13 失敗出力

原因は、1 本目の 3 件に 2 本目の 3 件がそのまま積み上がることです。
未完了タスクは 2 件 + 2 件 で 4 件になるため、assertCount(2, ...) で落ちます。
ここでリセット処理をテスト側へ寄せます。

tests/DatabaseTestCase.php を作成します。 PDO 接続の確立とテーブルのリセットを setUp() にまとめ、サブクラスがデータ定義と検証だけに集中できるようにします。

<?php
declare(strict_types=1);

namespace Tests;

use PDO;
use PHPUnit\Framework\TestCase;

abstract class DatabaseTestCase extends TestCase
{
    protected PDO $pdo;

    protected function setUp(): void
    {
        parent::setUp();

        $dsn = sprintf(
            'pgsql:host=%s;port=%s;dbname=%s',
            getenv('APP_DB_HOST') ?: 'db',
            getenv('APP_DB_PORT') ?: '5432',
            getenv('APP_DB_NAME') ?: 'app_test'
        );

        $this->pdo = new PDO(
            $dsn,
            getenv('APP_DB_USER') ?: 'app',
            getenv('APP_DB_PASS') ?: 'app',
            [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]
        );

        $this->pdo->exec('TRUNCATE TABLE tasks RESTART IDENTITY');
    }

    /**
     * @param list<array{title: string, is_done: bool}> $tasks
     */
    protected function seedTasks(array $tasks): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO tasks (title, is_done) VALUES (:title, :is_done)'
        );

        foreach ($tasks as $task) {
            $stmt->bindValue(':title', $task['title'], PDO::PARAM_STR);
            $stmt->bindValue(':is_done', $task['is_done'], PDO::PARAM_BOOL);
            $stmt->execute();
        }
    }
}

コードのポイント

setUp() 末尾の TRUNCATE でテストごとにテーブルを初期化する

        $this->pdo->exec('TRUNCATE TABLE tasks RESTART IDENTITY');
    }

    protected function seedTasks(array $tasks): void

setUp() の末尾で毎回テーブルを初期化することで、前のテストが残したデータが次のテストに影響しない。RESTART IDENTITY まで付けると ID も振り直されるので、特定の ID に依存するアサーションも安定する。

seedTasks() で型を明示しながらデータを投入する

        foreach ($tasks as $task) {
            $stmt->bindValue(':title', $task['title'], PDO::PARAM_STR);
            $stmt->bindValue(':is_done', $task['is_done'], PDO::PARAM_BOOL);
            $stmt->execute();
        }

PDO::PARAM_BOOL を明示することで boolean が文字列化されず DB に正しく渡る。サブクラスは $this->seedTasks([...]) を呼ぶだけで型変換まで済んだ投入ができる。

tests/TaskRepositoryTest.php は最終版へ置き換えます。

<?php
declare(strict_types=1);

namespace Tests;

use App\TaskRepository;

final class TaskRepositoryTest extends DatabaseTestCase
{
    private TaskRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();

        $this->seedTasks([
            ['title' => '買い物', 'is_done' => false],
            ['title' => '請求書を送る', 'is_done' => true],
            ['title' => 'ブログを書く', 'is_done' => false],
        ]);

        $this->repository = new TaskRepository($this->pdo);
    }

    public function testFindIncompleteTitlesReturnsOnlyTodoTasks(): void
    {
        $titles = $this->repository->findIncompleteTitles();

        $this->assertSame(['買い物', 'ブログを書く'], $titles);
    }

    public function testFindIncompleteTitlesStillReturnsTwoItems(): void
    {
        $titles = $this->repository->findIncompleteTitles();

        $this->assertCount(2, $titles);
    }
}

コードのポイント

extends DatabaseTestCase で接続とリセットの記述を省く

final class TaskRepositoryTest extends DatabaseTestCase
{
    private TaskRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();

        $this->seedTasks([
            ['title' => '買い物', 'is_done' => false],
            ['title' => '請求書を送る', 'is_done' => true],
            ['title' => 'ブログを書く', 'is_done' => false],
        ]);

        $this->repository = new TaskRepository($this->pdo);
    }

PDO 接続と TRUNCATE は parent::setUp() が担うので、このクラスは seedTasks でデータを定義するだけに集中できる。テストメソッド内からも投入コードが消え、実行と検証だけの 2〜3 行になっている。

これで、毎回 setUp() の先頭で tasks テーブルを空に戻せます。
PDO::PARAM_BOOL を明示しているのは、boolean 値を文字列まかせにせず、意図した型で渡すためです。

同じ suite を 2 回続けて実行してみます。

docker compose exec app vendor/bin/phpunit tests/TaskRepositoryTest.php
docker compose exec app vendor/bin/phpunit tests/TaskRepositoryTest.php
状態リセット後に 2 回連続成功した PHPUnit 13 出力

どちらも成功すれば、テスト間の状態リセットまで入りました。

8. よくある詰まりと次の一歩

よくある詰まりは次の 4 つにまとまります。

症状まず確認することよくある原因対処
could not find driver`docker compose exec app php -m | grep -E ’^(pdopdo_pgsql)$‘`pdo_pgsql 未導入 / イメージ未再ビルド
app_test に切り替わらないphpunit.xml<env ... force="true" />.envAPP_DB_NAME=app が優先されているforce="true" を付ける
relation "tasks" does not existdocker compose exec db psql -U app -d app_test -c "\dt"初期化 SQL 未反映 / down -v していないdocker compose down -v 後に再起動
テスト実行ごとに件数が増えるtests/DatabaseTestCase.phpsetUp()手動 TRUNCATE のまま / リセット共通化漏れsetUp()TRUNCATE TABLE tasks RESTART IDENTITY

切り分けに使う最小コマンド:

docker compose exec app php -m | grep -E '^(pdo|pdo_pgsql)$'
docker compose exec db psql -U app -lqt
docker compose exec db psql -U app -d app_test -c "\dt"
docker compose down -v

ここまでで、テスト用 DB を分けた最小 DB テストを通し、失敗原因の読み方と状態リセットまで一通り確認できました。
次は、トランザクションや migration ベースの DB テストへ進めます。

シリーズ 4/4

このシリーズ

PHPのテストと品質改善

  1. 1. PHPUnit入門(最初のテスト1本)
  2. 2. PHPStan入門(最初のエラー1件を直す)
  3. 3. GitHub ActionsでPHPUnit / PHP CS Fixer / PHPStanを回す最小CI
  4. 4. PHPUnitでDBテストを始める(PostgreSQL + Docker) 現在の記事