背景
無事レガシーSierから転職し、現在は自社プロダクト開発を行っております。
そんな現在の現場では、LaravelでTDD開発を行っており、(恥ずかしながらTDDという単語の意味すら知らなかった。)テストコードなどもちろん書いたことなかったため、Laravelのテストコードあれこれを残そうと思いました。
備忘録ですので、悪しからず。
大前提、今参画してる案件での構成に則っています。
1. テストクラスの土台
use Tests\TestCase; // Laravelの機能を使う(DB、HTTP、認証など)
use PHPUnit\Framework\TestCase; // 純粋なPHPUnit(Laravel非依存、爆速)
Tests\TestCaseを継承:Laravelアプリがブートされる。DBアクセスやHTTPリクエストのシミュレートができる代わりに遅い。
PHPUnit\Framework\TestCaseを直接継承:Laravel無しで動くロジック(今回でいうと純粋な計算ロジックなど)を検証するとき用。爆速
DBを触るテストにはトレイトを付ける必要あり!!
use Illuminate\Foundation\Testing\DatabaseTransactions; // 各テストをトランザクションで囲み、終わったらロールバック
use Illuminate\Foundation\Testing\RefreshDatabase; // マイグレーションを毎回re-runする(重いが確実)
現在の職場はDatabaseTransactionsを使う方針。
既存のDBスキーマを使い回して差分だけロールバックするので速い。
2. テストメソッドの命名
職場ではPHPUnit属性+日本語メソッド名というスタイルを採用。
use PHPUnit\Framework\Attributes\Test;
#[Test]
public function saveで本文が更新される(): void
{
// ...
}
#[Test]属性を付ければメソッド名は自由(test_接頭辞が不要になる)
日本語名にすることで「何を検証しているテストか」がテスト結果一覧にそのまま出る(実行結果でも✓ saveで本文が更新されると表示される)
確かにわかりやすい。
昔ながらの書き方はpublic function test_保存すると本文が更新される()(test_接頭辞で自動認識)。
命名のコツ:「〇〇すると/〇〇の場合、△△になる」という条件→結果の形にすると、テスト一覧がそのまま仕様書になります。
ここら辺が便利。
以前は全部スプシで管理してたからなぁ。。。
3. PHPUnit標準のアサーション(どのテストでも使う)
| メソッド | 用途 | 今回の例 |
|---|---|---|
assertSame($expected, $actual) |
厳密一致(===) |
$this->assertSame('ほげ', $updated->hoge) |
assertEquals() |
ゆるい一致(型変換あり) | あまり推奨しない。曖昧になりがち |
assertTrue() / assertFalse()
|
真偽 | $this->assertTrue($validator->fails()) |
assertNull() / assertNotNull()
|
null判定 | $this->assertNull($result) |
assertCount($n, $collection) |
件数 | $this->assertCount(2, $histories) |
assertInstanceOf(Class::class, $obj) |
型 | Interfaceの実装確認に使った |
$this->expectException(Class::class) |
例外発生の検証 | service->save('unknown_key', ...) |
assertSameとassertEqualsは使い分けが大事。
assertEquals(1, '1')は通ってしまう(型が違っても値が近ければOK)。バグを見逃したくないなら基本assertSameにする!!!!
4. Laravel特有のテストヘルパー
HTTPリクエストのシミュレート
$response = $this->get(route('hoge.hoge-sub.hoge-sub-sub.index'));
$response = $this->post('/hoge/hoge-sub/templates/', ['body' => '...']);
$response->assertStatus(200);
$response->assertRedirect(); // 何らかのリダイレクト
$response->assertRedirect('/hoge/login'); // 特定先へのリダイレクト
$response->assertSee('期日前リマインド'); // レスポンス本文にテキストが含まれる
$response->assertJson(['success' => true]);
認証系
$this->actingAs($user, 'hoge'); // ログイン済み状態を作る(guard名を第2引数で指定)
$this->assertGuest('hoge'); // 未ログインであることを確認(今回のControllerTestで使用)
DB検証系(今回は使わなかったが頻出)
$this->assertDatabaseHas('hoge', ['key' => 'pre_reminder', 'body' => '新ほげ']);
$this->assertDatabaseMissing('sms_templates', ['key' => 'unknown']);
$this->assertDatabaseCount('sms_template_histories', 2);
これらは今回assertSame($updated->body, ...)のようにモデルを取り直して比較したが、assertDatabaseHasを使えば「DBへ実際に書き込まれているか」をもっと直接的に書ける。
バリデーションルール単体のテスト(FormRequestをHTTP経由せずに検証)
use Illuminate\Support\Facades\Validator;
$rules = (new UpdateHogeRequest())->rules();
$validator = Validator::make(['body' => ''], $rules);
$this->assertTrue($validator->fails());
FormRequestは「HTTPを通さないと動かない」と思われがちだが、rules()は普通のpublicメソッドなので直接呼べます。これで軽量に検証できます。
Mockery(モック)
use Mockery;
$repository = Mockery::mock(SmsTemplateRepositoryInterface::class);
$repository->shouldReceive('findByKey')->with('shared_contact')->andReturn($sharedContact);
$repository->shouldNotReceive('save'); // 呼ばれないことの検証もできる
// tearDown()で必ず Mockery::close(); を呼ぶ(呼ばないと次のテストに影響することがある)
shouldReceive('メソッド名')->with(引数)->once()->andReturn(戻り値) が基本形。
「呼ばれてはいけない」ことを保証したいときはshouldNotReceive()が便利(今回の「許可されないkeyではsave()が呼ばれない」テストで使用)。
5. Playwright(E2E)側のセオリー
test.describe('グループ名', () => {
test.beforeEach(async ({ page }) => { /* 共通の準備(ログインなど) */ });
test('振る舞いの説明', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'ほげ' })).toBeVisible();
});
});
セレクタはgetByRole優先(page.locator('.card-title')のようなCSSクラス指定より壊れにくい)。理由:クラス名はスタイル都合で変わるが、見出し(role="heading")やボタン(role="button")はユーザー視点で変わりにくい
.filter({ has: ... })で「特定の見出しを含むカード」のように絞り込める
失敗時は自動でスクリーンショット・動画・トレースがtest-results/に残る。原因調査はまずそこを見る。
総括
テストコードを書いたことなかったので初めてみた時は???でした。
今回まとめてみて何となく理解はできた気がする。
DIなども、テストでとても便利な機能だったので、また今度アウトプットしていきたいな。