Upgrade to Pro — share decks privately, control downloads, hide ads and more …

AI 時代だからこそ抑えたい「価値のある」PHP ユニットテストを書く技術 #phpconfu...

Avatar for shogogg shogogg
November 08, 2025

AI 時代だからこそ抑えたい「価値のある」PHP ユニットテストを書く技術 #phpconfuk / phpcon-fukuoka-2025

2025年11月8日に開催されたPHPカンファレンス福岡2025の登壇資料です。

Avatar for shogogg

shogogg

November 08, 2025
Tweet

More Decks by shogogg

Other Decks in Programming

Transcript

  1. Shogo Kawase / @shogogg Nov. 8 2025 PHP Conference Fukuoka

    2025 AI 時代だからこそ抑えたい 「価値のある」PHP ユニットテストを書く技術
  2. 自己紹介 河瀨 翔吾 / Shogo Kawase 株式会社 PR TIMES ソフトウェアエンジニア(2024.12〜)

    I LOVE... 妻 / 型安全 / アジャイル / ももいろクローバーZ F1 / マリオカート shogogg shogogg
  3. AIエージェントの普及による変化 アウトプットが爆増 • AIエージェントが自律的に作業することでアウトプット≒プルリクが爆増中 • プルリクの増加に伴い、レビューの負担も増加 テストコードの作成コストは激減 • 熟練の職人が手作りする時代は終わり、大量生産の時代へ •

    複雑なモジュールのテストも、テスト設計の段階からAIの支援が受けられる 自動テストの重要性は増加 • 大量の変更を安全にリリースするため、自動化の重要性は増している • AI による誤った変更を検知するための「ガードレール」としての価値
  4. AI時代におけるユニットテストの価値 高い生産性と安全性を両立させる「安全装置」 • AI による圧倒的な量のアウトプットをすべて人力でテストするのは無理 • 自動テストで品質を担保することで、高速かつ安全なリリースが可能に AI の暴走を抑える「ガードレール」 •

    AI は放っておくと暴走しておかしなことをすることもまだ少なくない • AI が既存の仕様を破壊していないか、自動で検知する仕組みが必要 人間と AI の協働を助ける「動く仕様書」 • ユニットテストの「実行可能なドキュメント」としての価値は依然高いまま • 読みづらいテストコードは誤ったコンテキストを伝え、人間や AI がバグを生む原因に
  5. • テストはいつ・どこで実行されるかわからない 例)ローカル / CI / LLM Agent on Web

    / 隔離されたコンテナ内など • 特定の環境になるべく依存しない「どこでも動くテスト」の必要性が従来よりもさらに 高まっている 移植性 — いつ・どこで・誰が実行しても同じ結果になる  
  6. 可読性 — 人間と AI の両方にとって読みやすい • テストコードは「動く仕様書」 • 読みにくいテストコードはレビューやリファクタリングのコストを増やすだけでなく AI

    が間違ったコードを生成する原因 にもなる • 読みやすいテストコードは AI や人間のコンテキスト消費を抑え、高い生産性を実現 し それが経済的なインパクト に繋がる
  7. おことわり • ここから紹介するアンチパターンはすべて私が通ってきた道 であり先人・先達の知恵 から学び、今では避けているパターンです 😖 • 個人的な主義・主張が反映されている場合があります 😋 •

    あらゆるアンチパターンを網羅したものではありません 󰢁 • アンチパターンの「名前」はおおむね私の創作なので現場で急に使うことはオススメ しません 😇
  8. #[Test] public function isEvolvableShouldReturnTrueWhenThePokemonCanEvolve(): void { $pokemon = new Pokemon(

    name: 'ピカチュウ', evolveTo: ['ライチュウ'], // ... ); self::assertTrue($pokemon->isEvolvable()); } 1. 英語至上主義
  9. 1. 英語至上主義 • 変数やクラス名同様、テストケースを英語で書きたがる ※ BDD スタイルのフレームワークの場合にテストメソッドが it だったりする等、英語で書きたくなる気持ちはわかる 症状

    • 非英語話者が英文を考える労力 • 英単語や英文法の間違いなど、本質的でないレビューのやり取りが増える • 複雑なドメイン用語を英語にすると長くなる問題 • シンプルに読みづらい 問題点
  10. #[Test] public function isEvolvableShouldReturnTrueWhenThePokemonCanEvolve(): void { $pokemon = new Pokemon(

    name: 'ピカチュウ', evolveTo: ['ライチュウ'], // ... ); self::assertTrue($pokemon->isEvolvable()); } Before
  11. #[Test] public function isEvolvable_そのポケモンが進化できる場合は_true_を返す(): void { $pokemon = new Pokemon(

    name: 'ピカチュウ', evolveTo: ['ライチュウ'], // ... ); self::assertTrue($pokemon->isEvolvable()); } After
  12. // テストクラス内で共通のプロパティ private $pokemon = [ new Pokemon(name: 'フシギダネ', types:

    ['くさ', 'どく']), // ... ]; // ... #[Test] public function hasSameType_指定されたポケモンと同じタイプを持つ場合は_true_を返す(): void { self::assertTrue($this->pokemons[0]->hasSameType($this->pokemons[21])); } 2. 勘違いDRY原則
  13. DAMP原則? • Descriptive And Meaningful Phrases • 説明的で意味のある 記述 •

    コードの重複を受け入れ、一目見ただけで意味の分かる記述を重視 • 「動く仕様書」 であるテストコードではDRY原則よりもDAMP原則を守った方がよい
  14. // テストクラス内で共通のプロパティ private $pokemon = [ new Pokemon(name: 'フシギダネ', types:

    ['くさ', 'どく']), // ... ]; // ... #[Test] public function hasSameType_指定されたポケモンと同じタイプを持つ場合は_true_を返す(): void { self::assertTrue($this->pokemons[0]->hasSameType($this->pokemons[21])); } Before
  15. #[Test] public function hasSameType_指定されたポケモンと同じタイプを持つ場合は_true_を返す(): void { $bulbasaur = new Pokemon(

    name: 'フシギダネ', types: ['くさ', 'どく'], // ... ); $gengar = new Pokemon( name: 'ゲンガー', types: ['ゴースト', 'どく'], // ... ); self::assertTrue($bulbasaur->hasSameType($gengar)); } After
  16. #[Test] public function hasSameType_指定されたポケモンと同じタイプを持つ場合は_true_を返す(): void { $a = TestPokemonFactory::make( types:

    ['くさ', 'どく'], ); $b = TestPokemonFactory::make( types: ['ゴースト', 'どく'], ); self::assertTrue($a->hasSameType($b)); } テスト用ファクトリクラスの例
  17. public function provideTestCases(): iterable { yield '同じタイプを持つポケモンの場合は true を返す' =>

    [ 'pokemon1' => new Pokemon(/* ... */), 'pokemon2' => new Pokemon(/* ... */), 'expected' => true, ]; yield '同じタイプを持たないポケモンの場合は false を返す' => [ // ... ]; } #[Test] #[DataProvider('provideTestCases')] public function hasSameType_引数を判定して結果を返す(Pokemon $a, Pokemon $b, bool $expected): void { self::assertSame($expected, $a->hasSameType($b)); } 3. テストケースプロバイダー
  18. public function provideTestCases(): iterable { yield '同じタイプを持つポケモンの場合は true を返す' =>

    [ 'pokemon1' => new Pokemon(/* ... */), 'pokemon2' => new Pokemon(/* ... */), 'expected' => true, ]; yield '同じタイプを持たないポケモンの場合は false を返す' => [ // ... ]; } #[Test] #[DataProvider('provideTestCases')] public function hasSameType_引数を判定して結果を返す(Pokemon $a, Pokemon $b, bool $expected): void { self::assertSame($expected, $a->hasSameType($b)); } Before
  19. #[Test] public function hasSameType_同じタイプを持つポケモンの場合は_true_を返す(): void { $a = new Pokemon(name:

    'フシギダネ', types: ['くさ', 'どく']); $b = new Pokemon(name: 'ゲンガー', types: ['ゴースト', 'どく']); self::assertTrue($a->hasSameType($b)); } #[Test] public function hasSameType_同じタイプを持たないポケモンの場合は_false_を返す(): void { $a = new Pokemon(name: 'フシギダネ', types: ['くさ', 'どく']); $b = new Pokemon(name: 'プリン', types: ['ノーマル', 'フェアリー']); self::assertFalse($a->hasSameType($b)); } After
  20. #[Test] public function doAction_正常系(): void { $action = new ReplacePokemonAction();

    $actual = $action->doAction(new Pokemon(name: 'ゼニガメ', status: Pokemon::ACTIVE)); self::assertTrue($actual); } #[Test] public function doAction_異常系(): void { $action = new ReplacePokemonAction(); $actual = $action->doAction(new Pokemon(name: 'カイリュー', status: Pokemon::FAINTING)); self::assertFalse($actual); } 4. 言葉足らずのテスト
  21. #[Test] public function doAction_正常系(): void { $action = new ReplacePokemonAction();

    $actual = $action->doAction(new Pokemon(name: 'ゼニガメ', status: Pokemon::ACTIVE)); self::assertTrue($actual); } #[Test] public function doAction_異常系(): void { $action = new ReplacePokemonAction(); $actual = $action->doAction(new Pokemon(name: 'カイリュー', status: Pokemon::FAINTING)); self::assertFalse($actual); } Before
  22. #[Test] public function doAction_指定されたポケモンが元気な場合は成功し_true_を返す(): void { $action = new ReplacePokemonAction();

    $actual = $action->doAction(new Pokemon(name: 'ゼニガメ', status: Pokemon::ACTIVE)); self::assertTrue($actual); } #[Test] public function doAction_指定されたポケモンが「ひんし」の場合は失敗し_false_を返す(): void { $action = new ReplacePokemonAction(); $actual = $action->doAction(new Pokemon(name: 'カイリュー', status: Pokemon::FAINTING)); self::assertFalse($actual); } After
  23. #[Test] public function doAction_バリデーションエラーがない場合は処理に成功する(): void { $request = new ReplacePokemonActionRequest(

    // ... ); $actual = $this->action->doAction($request); self::assertSame(Response::HTTP_OK, $actual->getStatusCode()); $json = json_decode((string)$actual->getContent(), true); self::assertArrayHasKey('message', $json); self::assertSame('ポケモンの交代に成功しました。', $json['message']); self::assertArrayHasKey('from', $json); self::assertSame('フシギバナ', $json['from']); self::assertArrayHasKey('to', $json); self::assertSame('カメックス', $json['to']); } 5. 欲張りなテスト
  24. 5. 欲張りなテスト • 1つのテストケースで複数のふるまい・事後条件をテストしてしまう 症状 • 複数のふるまいをテストするため、結果的にテストケース名がふわっとしてしま い「言葉足らず」 に •

    複数のアサーションの先頭や途中で検査に失敗するとそこでテストの実行が中断 されてしまうため、後続の検査が行われず、問題発生時の調査が困難に 問題点
  25. #[Test] public function doAction_バリデーションエラーがない場合は処理に成功する(): void { $request = new ReplacePokemonActionRequest(

    // ... ); $actual = $this->action->doAction($request); self::assertSame(Response::HTTP_OK, $actual->getStatusCode()); $json = json_decode((string)$actual->getContent(), true); self::assertArrayHasKey('message', $json); self::assertSame('ポケモンの交代に成功しました。', $json['message']); self::assertArrayHasKey('from', $json); self::assertSame('フシギバナ', $json['from']); self::assertArrayHasKey('to', $json); self::assertSame('カメックス', $json['to']); } Before
  26. #[Test] public function doAction_成功時はステータスコードが_200_OK_である(): void { $request = new ReplacePokemonActionRequest(/*

    ... */); $actual = $this->action->doAction($request); self::assertSame(Response::HTTP_OK, $actual->getStatusCode()); } #[Test] public function doAction_成功時は本文に交代前のポケモン名が含まれる(): void { $request = new ReplacePokemonActionRequest(/* ... */); $actual = $this->action->doAction($request); $json = json_decode((string)$actual->getContent(), true); self::assertArrayHasKey('from', $json); self::assertSame('フシギバナ', $json['from']); } After
  27. #[Test] public function fetchMyTeam_APIが_200_OK_を返す場合は手元のポケモン一覧を返す(): void { $httpClientMock = Mockery::mock(HttpClient::class); $httpClientMock

    ->expects('get') ->with('/api/v2/my-team') ->andReturn(new Response(200, [], json_encode([ ['name' => 'プリン', 'level' => 88, /* ... */], // ... ]))); $subject = new PokemonService($httpClientMock); $expected = [ new Pokemon(name: 'プリン', level: 88, /* ... */), // ... ]; $actual = $subject->fetchMyTeam(); self::assertEquals($expected, $actual); } 6. スパゲッティ・テスト
  28. • テストコードを以下の3つのセクションに分けるプラクティス ◦ Arrange / Given - 事前条件の準備 ◦ Act

    / When - テスト対象の実行 ◦ Assert / Then - 事後条件の検査 • それぞれの役割ごとにコードのブロックを分割することで構造化し、テストコードの 可読性向上を実現 • 2つのパターンは名前や由来が異なるだけなので、どちらでもお好きな方、しっくり 来る方を採用するとよい AAAパターン・Given/When/Then パターン
  29. #[Test] public function fetchMyTeam_APIが_200_OK_を返す場合は手元のポケモン一覧を返す(): void { $httpClientMock = Mockery::mock(HttpClient::class); $httpClientMock

    ->expects('get') ->with('/api/v2/my-team') ->andReturn(new Response(200, [], json_encode([ ['name' => 'プリン', 'level' => 88, /* ... */], // ... ]))); $subject = new PokemonService($httpClientMock); $expected = [ new Pokemon(name: 'プリン', level: 88, /* ... */), // ... ]; $actual = $subject->fetchMyTeam(); self::assertEquals($expected, $actual); } Before
  30. #[Test] public function fetchMyTeam_APIが_200_OK_を返す場合は手元のポケモン一覧を返す(): void { // Arrange $httpClientMock =

    Mockery::mock(HttpClient::class); $httpClientMock ->expects('get') ->with('/api/v2/my-team') ->andReturn(new Response(200, [], json_encode([/* ... */]))); $subject = new PokemonService($httpClientMock); // Act $actual = $subject->fetchMyTeam(); // Assert $expected = [/* ... */]; self::assertEquals($expected, $actual); } After
  31. 7. モック地獄 #[Test] public function update_更新されたインスタンスを返す(): void { // Arrange

    $repository = Mockery::mock(PokemonRepositoryInterface::class); $repository->allows('store')->... $fooService = Mockery::mock(FooServiceInterface::class); $fooService->allows('performAction')->... $barService = Mockery::mock(BarServiceInterface::class); $barService->allows('executeTask')->... $bazService = Mockery::mock(BazServiceInterface::class); $bazService->allows('runProcess')->... // ... $useCase = new UpdatePokemonUseCase($repository, $fooService, $barService, $bazService, /* ... */); // Act // ... }
  32. 7. モック地獄 • 「単体テスト」の言葉に囚われすぎてしまった結果、あらゆる依存をモック化してしま う 症状 • テストコードがテスト対象の内部実装と密結合してしまい、リファクタリング耐性の 低い、保守性の低いテスト に

    • テストコードがモックの設定で溢れかえり、可読性が著しく低下 • モックの設定ミスによって「本物」と異なる振る舞いをさせてしまうことでテストの 信頼性が低下する場合も 問題点
  33. 8. モック恐怖症 • モック地獄を経て、モックによる弊害を怖れるあまりにモックの使用を過度に避けてし まい、データベースなどに依存したテストばかりを書いてしまう 症状 • データベースやファイルシステム、外部 API に依存してしまうことで速度が低下

    • 外部依存の障害等によってテストが不安定化(Flaky 化) • テストが失敗した時に、コードと外部依存のどっちに問題があるのかわからないため 失敗時の原因切り分けが困難に • テスト環境のセットアップが複雑化し、手軽に実行できなくなってしまう 問題点
  34. まとめ • AI エージェントが普及した現代において自動化されたテストの価値は向上 • テストを書く上でも AI は強い味方だが、丸投げできる状況ではない • AI

    を活用し安全かつ高い生産性を実現する上で、ユニットテストにおいては 高速性・信頼性・移植性・可読性 が4つが特に重要 • 先人の知恵を活用 することで、テストコードの価値を高めることができる • テストを書こう