はじめに
こんにちは。
株式会社Kaienでエンジニアをしている窪田です。
チームで Laravel の自動テスト規約を整備するにあたり、Vladimir Khorikov 著「単体テストの考え方/使い方」を読みました。
本記事では、この書籍から学んだ考え方が規約の設計にどう影響したかを解説します。最後に実際の Feature テストを 1 つ紹介します。
「良いテスト」の 4 本の柱
書籍ではテストの品質を測る 4 つの属性が定義されています。
| 属性 | 説明 |
|---|---|
| 退行(リグレッション)からの保護 | バグを検知できるか |
| リファクタリング耐性 | 実装を変えたときに誤検知しないか |
| 迅速なフィードバック | テストの実行が速いか |
| 保守性 | テストを読みやすく維持しやすいか |
中でも書籍が特に強調するのが リファクタリング耐性 です。
実装の詳細(内部メソッド名・内部状態)を直接検証するテストは、コードをリファクタリングしただけで壊れます(いわゆる「偽陽性」)。これが積み重なるとテストへの信頼が失われ、「テストがあるのにリファクタリングできない」状況が生まれます。
→ 規約への反映: テスト名は「何のメソッドを呼んだか」ではなく「どんな振る舞いが期待されるか」を日本語の文章で書く。
テストの 3 種類と採用戦略
書籍ではテストの検証スタイルを 3 種類に分類しています。
1. 出力値ベーステスト(リファクタリング耐性が最高)
入力 → 呼び出し → 戻り値だけを検証。内部状態を触らないため、実装を変えてもテストが壊れにくい。
2. 状態ベーステスト
操作の後、オブジェクトや DB の状態が変化したことを検証する。
3. コミュニケーションベーステスト(モック多用)
「このメソッドが呼ばれたか」「何回呼ばれたか」をモックで検証する。実装の詳細に密結合するため、リファクタリング耐性が最も低い。
→ 規約への反映:
- Unit テスト → 純粋なビジネスロジックは「出力値ベース」で書く
- Feature テスト → HTTP レスポンスや DB 状態を「状態ベース」で検証する
- モック → 外部 API(管理外依存)だけを対象とし、自前 DB はモックしない
Feature vs Unit:なぜ Feature が多くなるのか
書籍(Vladimir Khorikov 氏)の理想はテストピラミッド、つまり「ドメインモデルを隔離して Unit テストを多くすべき」です。
しかし Laravel のようなフレームワーク統合の深いアプリでは、ビジネスロジックが Eloquent(DB アクセス)と密結合しがちです。この状況でモックだらけの Unit テストを量産すると、今度はリファクタリング耐性が損なわれます。書籍が最も重視するのがリファクタリング耐性である以上、「本物の DB を使う Feature テストを主軸にする」という現実的な妥協の方が書籍の精神に沿っていると判断しました。
判断に迷ったら:
- コントローラーを経由して検証したい → Feature
- 特定のメソッドや計算の戻り値だけを検証したい → Unit
規約の主なポイント
振る舞いベースの命名
書籍の「テストは実装の詳細ではなく観察可能な振る舞いを検証すべき」という原則から来ています。
// ✅ 振る舞いを日本語で表現
test('未来の日付を指定した予約は有効になる')
// ❌ 実装名を羅列した英語
test('test_validateDate_returns_true')
AAA パターン(Arrange / Act / Assert)の厳守
Act が複数行になっていると「何を実行しているのか」が不明瞭になります。Act は必ず 1 行 にします。
test('ログイン済みユーザーが記事を投稿できる', function () {
// Arrange
$user = User::factory()->create();
actingAs($user); // ← Arrange に含める
// Act(1行)
$response = post(route('posts.store'), ['title' => 'テスト', 'body' => '本文']);
// Assert
$response->assertRedirect();
});
自前 DB はモックしない
書籍では「管理下にある依存(自前 DB)はモックしない」と明確に述べています。自前 DB をモックすると実装の詳細に密結合し、リファクタリング耐性が著しく下がります。
// ❌ リポジトリをモックすると実装の詳細に密結合する
$repo = Mockery::mock(PostRepository::class);
$repo->shouldReceive('find')->andReturn($post);
// ✅ 本物のDBを使い、Factoryでデータを用意する
$post = Post::factory()->create();
実際の Feature テスト例
ログイン済みユーザーが記事を投稿すると、DB に正しいステータスで保存されることをテストしています。
<?php
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use function Pest\Laravel\{actingAs, post};
uses(DatabaseTransactions::class);
test('ログイン済みユーザーが記事を投稿するとDBに保存される', function () {
// Arrange
$user = User::factory()->create();
actingAs($user);
// Act
$response = post(route('posts.store'), [
'title' => 'テスト記事タイトル',
'body' => '本文です。',
'status' => '公開',
]);
// Assert
$response->assertRedirect();
$post = Post::where('user_id', $user->id)->first();
expect($post->status)->toBe('公開');
});
| ポイント | 理由 |
|---|---|
DatabaseTransactions::class |
テストごとにロールバック |
actingAs($user) を Arrange に置く |
Act を 1 行に保つため |
Post::where(...)->first() で DB を直接確認 |
DB に実際に書き込まれた値を検証する状態ベーステスト |
| テスト名が日本語の振る舞い記述 | 実装をリファクタリングしてもテスト名が陳腐化しない |
まとめ
「単体テストの考え方/使い方」から得た最も大きな気づきは、「リファクタリング耐性」を軸にテスト設計を考える ことでした。
- 内部実装ではなく 振る舞い を検証する
- 自前 DB はモックせず 本物を使う
- モックは 管理外の外部依存 だけに絞る
これらを規約として明文化することで、「テストがあるのにリファクタリングできない」という本末転倒な状態を防ぐことができると考えています。
最後に
Kaienでは様々なエンジニアが活躍しています。
ご興味のある方は、ぜひジョインしていただけると幸いです。
最後までお読みいただきありがとうございました。