はじめに
この記事では:
- Docker + PHP 8.4 + Laravel 12 の環境構築
- PHPUnitでTDD(テスト駆動開発)の基本サイクルを回す
- Todo APIをテストファーストで実装する
「テストを書いてから実装する」というTDDの基本サイクル(Red → Green → Refactor)を、実際に手を動かしながら体験する。
TDDとは
- Red:失敗するテストを書く
- Green:テストが通る最小限の実装をする
- Refactor:テストが通ったままコードを整理する
このサイクルを機能ごとに繰り返す。先にテストを書くことで「何を実装すべきか」が明確になり、実装後の手動確認の手間も減る。
1. Laravelプロジェクトを先に作る
composer create-project は空でないディレクトリには実行できない。php84/Dockerfile や docker-compose.yml を先に置くと「空じゃない」と判定されて失敗するため、先にLaravelプロジェクトを作ってから、Docker関連のファイルを追加する順番で進める。
ホストにComposerが無くても、使い捨てコンテナで作成できる。
mkdir todo-app-tdd
cd todo-app-tdd
docker run --rm -it \
-v "$(pwd):/app" \
-w /app \
composer:2 create-project laravel/laravel . "12.*"
-
-v "$(pwd):/app": カレントディレクトリ(まだ空)をコンテナにマウント -
laravel/laravel .:.(カレントディレクトリ)に直接インストールする
これでプロジェクト直下に app/ routes/ などLaravel一式が生成される。
2. Docker環境の構築
ここからDocker関連のファイルを追加していく。
Dockerfile
PHP 8.4 + Apache で構成する。
FROM php:8.4-apache
ENV APACHE_DOCUMENT_ROOT /var/www/html/todo-app/public
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf
RUN sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
RUN apt-get update \
&& apt-get -y install vim wget unzip lsb-release libicu-dev libfreetype6-dev libjpeg62-turbo-dev libzip-dev libsqlite3-dev \
&& docker-php-ext-install pdo_mysql pdo_sqlite mysqli intl gd zip
RUN a2enmod rewrite
# Composerをインストール
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
composer.phar を手動DLする代わりに、公式Composerイメージから直接バイナリをコピーする方式にした。旧来の php -r "readfile(...)" より安全で速い。
pdo_sqlite はTDDでテストを高速化するために使う(後述の「3. テスト環境の設定」)。ビルドには libsqlite3-dev が必要で、これが無いと pkg-config 関連のエラーでビルドが失敗するため最初から入れている。
docker-compose.yml
services:
mysql:
image: 'mysql:8.0'
environment:
- MYSQL_ROOT_PASSWORD=my-secret-pw
- MYSQL_DATABASE=todo_app
ports:
- "${DOCKER_HOST_MYSQL_PORT:-3306}:3306"
volumes:
- mysql_data:/var/lib/mysql
web:
tty: true
build: './php84'
ports:
- "${DOCKER_HOST_WEB_PORT:-8000}:80"
volumes:
- ".:/var/www/html/todo-app/"
depends_on:
- mysql
adminer:
image: adminer:latest
depends_on:
- mysql
ports:
- "${DOCKER_HOST_ADMINER_PORT:-8081}:8080"
volumes:
mysql_data:
-
version: '3'の記述は最近のDocker Composeでは不要になったので削除した -
MYSQL_DATABASEを環境変数で指定して初回起動時に自動作成 - ボリュームでMySQLのデータを永続化
- ポートは
${...:-デフォルト値}の書き方にして.envがなくても起動できるようにした -
volumesはプロジェクト直下(.)をそのままマウントする。Laravelプロジェクトもこの直下に作る
.env(Docker用)を作る
${DOCKER_HOST_MYSQL_PORT:-3306} のようにデフォルト値を指定していても、Docker Composeのバージョンや実行環境によっては変数が読めずエラーになることがある。docker-compose.yml と同じ階層に .env を作って明示しておくと確実。
DOCKER_HOST_MYSQL_PORT=3306
DOCKER_HOST_WEB_PORT=8000
DOCKER_HOST_ADMINER_PORT=8081
Laravelの .env(アプリ側の環境変数)とは別ファイル。同じ階層に2つの .env が並ぶ形になるが、片方はDocker Compose用、もう片方はLaravelアプリ用で役割が異なる。
ディレクトリ構成
1. で作ったLaravel一式(app/ routes/ など)はそのままに、php84/ と docker-compose.yml を追加する。
todo-app-tdd/
├── app/ ← Laravel(1.で作成済み)
├── routes/ ← Laravel(1.で作成済み)
├── ...(Laravelの他のファイル)
├── docker-compose.yml ← 追加
└── php84/ ← 追加
└── Dockerfile
mkdir -p php84
# Dockerfileを php84/Dockerfile として配置
起動
コンテナを起動する。
docker compose up -d --build
.envのDB接続情報を編集する
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=todo_app
DB_USERNAME=root
DB_PASSWORD=my-secret-pw
DB_HOST=mysql はdocker-composeのサービス名。コンテナ間はサービス名で名前解決できる。
マイグレーションを実行する
Laravel 12はデフォルトでセッションの保存先が database ドライバになっており、sessions テーブルが必要。マイグレーションを流さないままアクセスすると SQLSTATE[42S02]: Base table or view not found エラーになる。
docker compose exec web bash
cd /var/www/html/todo-app
php artisan migrate
sessions cache jobs などLaravelが標準で使うテーブルが作られる。
ブラウザで確認:
http://localhost:8000
Laravelのウェルカムページが表示されればOK。
tempnam(): file created in the system's temporary directory という警告が出る場合は storage と bootstrap/cache の権限不足が原因のことが多い。コンテナに入って権限を直す。
docker compose exec web bash
cd /var/www/html/todo-app
chmod -R 775 storage bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache
3. テスト環境の設定
TDDでは高速にテストを回したいので、テスト時はSQLiteのインメモリDBを使う。本番はMySQLのままでいい。
phpunit.xml を編集:
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
pdo_sqlite 拡張は「2. Docker環境の構築」のDockerfileに最初から含めている(libsqlite3-dev と pdo_sqlite を追加済み)。まだ入れていない場合は apt-get install に libsqlite3-dev を、docker-php-ext-install に pdo_sqlite を追加して再ビルドする。libsqlite3-dev が無いと pdo_sqlite のビルドが pkg-config のエラーで失敗する。
4. APIルーティングを有効化する
Laravel 11以降、新規プロジェクトには routes/api.php が最初から存在せず、APIルーティング自体が無効化されている(Webアプリ用のスケルトンがデフォルトのため)。このまま routes/api.php にルートを書いても読み込まれず、常に404になる。
install:api コマンドで有効化する。
docker compose exec web bash
cd /var/www/html/todo-app
php artisan install:api
これで routes/api.php が生成され、bootstrap/app.php にAPIルーティングの読み込み設定が自動で追記される。あわせて Laravel Sanctum(API認証用パッケージ)もインストールされるが、今回は使わないのでそのままで問題ない。
このコマンドを実行し忘れると、後述の routes/api.php にルートを書いてもすべて404になる。テストが Route [api/todos] not defined. ではなく 404 で失敗する場合はこれが原因の可能性が高い。
5. TDDサイクル①:Todo一覧取得
Red:失敗するテストを書く
Laravelの初期状態には tests/Feature/ExampleTest.php というサンプルテストが入っている。今回は使わないので先に削除しておく。
docker compose exec web bash
cd /var/www/html/todo-app
rm tests/Feature/ExampleTest.php
php artisan make:test TodoApiTest
docker compose exec web bash でコンテナに入った直後のカレントディレクトリはコンテナのデフォルト作業ディレクトリになる場合がある。php artisan コマンドはLaravelプロジェクト直下(/var/www/html/todo-app)で実行する必要があるので、以降のコマンドもこのディレクトリで実行する前提で読み進めてほしい。
tests/Feature/TodoApiTest.php:
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TodoApiTest extends TestCase
{
use RefreshDatabase;
public function test_todo一覧を取得できる(): void
{
$response = $this->getJson('/api/todos');
$response->assertStatus(200)
->assertJson([]);
}
}
RefreshDatabase トレイトでテストごとにDBをリセットする。
php artisan test
FAILED Tests\Feature\TodoApiTest > todo一覧を取得できる
ルート・モデル・コントローラーが何も無いのでFAILになる。これがRed。具体的なエラー内容(404・405・500など)は環境やLaravelのバージョンによって多少変わるが、要は「まだ実装していないので失敗する」状態であればRedとして問題ない。
Green:テストが通る最小限の実装
Todo モデルとマイグレーションを用意する(php artisan make:model Todo -m で生成したものをベースに編集する)。
database/migrations/xxxx_create_todos_table.php:
public function up(): void
{
Schema::create('todos', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->boolean('done')->default(false);
$table->timestamps();
});
}
app/Models/Todo.php に fillable を追加する(Todo::create() で一括代入するために必要):
class Todo extends Model
{
protected $fillable = ['title', 'done'];
}
これを忘れると、後で Todo::create(...) を呼んだ時に Add [title] to fillable property to allow mass assignment on [App\Models\Todo]. というエラーになる。Laravelはデフォルトで「一括代入(Mass Assignment)」を禁止しており、fillable に明示したカラムだけ create() や update() で書き込めるようになる。
コントローラーを作る:
php artisan make:controller Api/TodoController
app/Http/Controllers/Api/TodoController.php:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Todo;
class TodoController extends Controller
{
public function index()
{
return Todo::all();
}
}
ルートを登録する。routes/api.php:
<?php
use App\Http\Controllers\Api\TodoController;
use Illuminate\Support\Facades\Route;
Route::get('/todos', [TodoController::class, 'index']);
php artisan test
PASS Tests\Feature\TodoApiTest
✓ todo一覧を取得できる
Greenになった。
6. TDDサイクル②:Todo作成
Red
public function test_todoを作成できる(): void
{
$response = $this->postJson('/api/todos', [
'title' => '買い物',
]);
$response->assertStatus(201)
->assertJson([
'title' => '買い物',
'done' => false,
]);
$this->assertDatabaseHas('todos', [
'title' => '買い物',
]);
}
assertDatabaseHas でDBに実際に保存されたかも検証する。
php artisan test
FAILED Tests\Feature\TodoApiTest > todoを作成できる
POST /api/todos のルートがまだ無いのでFAILになる。これもRed。
Green
fillable は前段階(サイクル①)で既に設定済みなので、ここではコントローラーに store を追加するだけでよい。
コントローラーに store を追加:
use Illuminate\Http\Request; // ← ファイル先頭に追加
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
]);
$todo = Todo::create([
'title' => $validated['title'],
'done' => false,
]);
return response()->json($todo, 201);
}
Todo::create($validated) のように title だけ渡すと、レスポンスのJSONに done キーが含まれないことがある。マイグレーションの default(false) はDBレベルのデフォルト値であり、create() で明示的に渡していない属性は、モデルインスタンスに自動反映されるとは限らない(DBから再取得すれば正しい値が入るが、create() の戻り値はメモリ上に構築されたものをそのまま返す)。テストで done の値も検証したい場合は、明示的に 'done' => false を渡しておくと確実。
use Illuminate\Http\Request; を忘れると App\Http\Controllers\Api\Request のように現在の名前空間内でクラスを探してしまい Class "App\Http\Controllers\Api\Request" does not exist というエラーになる。コントローラーは App\Http\Controllers\Api 名前空間にあるため、Illuminate\Http\Request は必ずuse文でインポートする必要がある。
ルートを追加:
Route::post('/todos', [TodoController::class, 'store']);
php artisan test
PASS Tests\Feature\TodoApiTest
✓ todo一覧を取得できる
✓ todoを作成できる
7. TDDサイクル③:バリデーションエラー
TDDは正常系だけでなく異常系も先にテストしておくと堅牢になる。
Red
public function test_タイトルなしでは作成できない(): void
{
$response = $this->postJson('/api/todos', [
'title' => '',
]);
$response->assertStatus(422);
}
php artisan test
PASS Tests\Feature\TodoApiTest
✓ タイトルなしでは作成できない
実はこれ、すでにGreenになっている。store メソッドで 'title' => 'required' のバリデーションを先に入れていたから。TDDの理想形は「このテストのためにこのコードを書く」だが、実務では実装が先に条件を満たしていることもある。その場合はテストが「仕様を保証するドキュメント」として機能する。
8. TDDサイクル④:完了状態の更新
Red
public function test_todoを完了にできる(): void
{
$todo = \App\Models\Todo::create(['title' => '掃除', 'done' => false]);
$response = $this->putJson("/api/todos/{$todo->id}/done");
$response->assertStatus(200)
->assertJson(['done' => true]);
$this->assertDatabaseHas('todos', [
'id' => $todo->id,
'done' => true,
]);
}
public function test_存在しないtodoの完了はエラー(): void
{
$response = $this->putJson('/api/todos/999/done');
$response->assertStatus(404);
}
php artisan test
FAILED Tests\Feature\TodoApiTest > todoを完了にできる
Green
public function markDone(Todo $todo)
{
$todo->update(['done' => true]);
return response()->json($todo);
}
Laravelのルートモデルバインディングを使うと $id からモデルを探す処理を書かずに済む。存在しない場合は自動的に404を返す。
Route::put('/todos/{todo}/done', [TodoController::class, 'markDone']);
php artisan test
PASS Tests\Feature\TodoApiTest
✓ todo一覧を取得できる
✓ todoを作成できる
✓ タイトルなしでは作成できない
✓ todoを完了にできる
✓ 存在しないtodoの完了はエラー
Tests: 5 passed
全部Greenになった。
9. 完成したコード
routes/api.php
<?php
use App\Http\Controllers\Api\TodoController;
use Illuminate\Support\Facades\Route;
Route::get('/todos', [TodoController::class, 'index']);
Route::post('/todos', [TodoController::class, 'store']);
Route::put('/todos/{todo}/done', [TodoController::class, 'markDone']);
app/Http/Controllers/Api/TodoController.php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Todo;
use Illuminate\Http\Request;
class TodoController extends Controller
{
public function index()
{
return Todo::all();
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
]);
$todo = Todo::create([
'title' => $validated['title'],
'done' => false,
]);
return response()->json($todo, 201);
}
public function markDone(Todo $todo)
{
$todo->update(['done' => true]);
return response()->json($todo);
}
}
app/Models/Todo.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Todo extends Model
{
protected $fillable = ['title', 'done'];
}
動作確認(実際のcurl)
テストが通ったらMySQL側でも動作確認する。マイグレーションを流す:
php artisan migrate
curl -X POST http://localhost:8000/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "買い物"}'
{"title":"買い物","done":false,"updated_at":"...","created_at":"...","id":1}
curl http://localhost:8000/api/todos
[{"id":1,"title":"買い物","done":false,...}]
curl -X PUT http://localhost:8000/api/todos/1/done
{"id":1,"title":"買い物","done":true,...}
まとめ
| サイクル | テスト内容 | 実装内容 |
|---|---|---|
| ① | 一覧取得 |
index() + ルート |
| ② | 作成 |
store() + Model の fillable
|
| ③ | バリデーション | (①②の実装で既にカバー) |
| ④ | 完了更新 |
markDone() + ルートモデルバインディング |
TDDの基本サイクルは「Red(失敗)→ Green(成功)→ Refactor(整理)」。今回はRefactorのステップを明示的に踏まなかったが、実装が育つにつれてコントローラーが肥大化したらService層に切り出す、といったリファクタリングをテストに守られながら進められるのがTDDの強み。
テストファーストで書くことで「何を実装すべきか」が先に明確になり、実装後の手動確認(curlでの動作確認)は最終チェックだけで済んだ。


