本記事は、WSL2 + Docker で PHP を触っていて、PHPUnit の最初の 1 本か PDO + PostgreSQL の最小 CRUD は通したことがあるものの、DB ありテストはまだこれから、という読者向けです。
この 1 本だけで、app と app_test を分けた最小 DB テストを作り、失敗 -> 修正 -> 安定化まで進めます。
補助導線として先に読んでおくと入りやすい記事:
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop(WSL 連携有効)
以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
サービス名は app と db に固定しています。
1. ゴールと非対象
この記事で到達する状態:
appとapp_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
ここではまだ 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 を作成します。
app と app_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
app と app_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が見えるappとapp_testの両方にtasksテーブルがある- 通常実行時の接続先は
.envのAPP_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.xml の force="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テストを書く
いまの TaskRepository は is_done を見ていないので、完了済みタスクまで返します。
まずはその失敗を確認します。
docker compose exec app vendor/bin/phpunit tests/TaskRepositoryTest.php
失敗の見え方:
- 期待値は
買い物,ブログを書くの 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
ここで 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
原因は、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
どちらも成功すれば、テスト間の状態リセットまで入りました。
8. よくある詰まりと次の一歩
よくある詰まりは次の 4 つにまとまります。
| 症状 | まず確認すること | よくある原因 | 対処 |
|---|---|---|---|
could not find driver | `docker compose exec app php -m | grep -E ’^(pdo | pdo_pgsql)$‘` | pdo_pgsql 未導入 / イメージ未再ビルド |
app_test に切り替わらない | phpunit.xml の <env ... force="true" /> | .env の APP_DB_NAME=app が優先されている | force="true" を付ける |
relation "tasks" does not exist | docker compose exec db psql -U app -d app_test -c "\dt" | 初期化 SQL 未反映 / down -v していない | docker compose down -v 後に再起動 |
| テスト実行ごとに件数が増える | tests/DatabaseTestCase.php の setUp() | 手動 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 テストへ進めます。