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

テストコード書いてみませんか?

onopon
December 22, 2024

 テストコード書いてみませんか?

https://github.com/onopon/tc_practice
https://fortee.jp/phpcon-2024/proposal/f1473725-d50a-4fae-8045-d13ec9e49b05

新入社員がテストコードを書けない場合、どのように教えますか?
日頃書く人は感覚的に書けますが、書いたことのない人はコツを掴むまで時間がかかってしまうものです。
皆様も経験があるのではないでしょうか?

本セッションでは、そんなテストコードのチュートリアルをpublicリポジトリとして公開・そのリポジトリの利用方法やテストコードを書く上でのテクニックをお話します。
Docker環境さえ整っていれば誰でもLaravelフレームワーク内でPHPUnitのテストを書くための問題集にチャレンジできる内容となっております。
全ての操作用のコマンドやREADMEを用意しているので、Dockerの知識がなくてもテストの実行やテスト用DBの中身を確認できます。

これを機にテストコードを書けるようになりましょう!

対象者:
・テストコードを知りたい方
・育成コストに悩んでいる方

onopon

December 22, 2024
Tweet

More Decks by onopon

Other Decks in Programming

Transcript

  1. 自己紹介 株式会社ウエディングパーク ValueDevelopment 本部 おのぽん(@onopon_engineer) ・2022年6月にウエディングパークに入社 ・前職では   - 大規模ソーシャルゲームのサーバエンジニア   -

    人工知能コミュニケーションロボットのサーバ全般   - Androidアプリ開発  を行い、業務でのPHP開発は未経験 ・エンジニア以外の時は大体卓球をしています🏓 ・パラ卓球選手のコーチもしていました! ・個人としても港区代表になったり  年代別で東京代表になったり頑張っています🔥 3
  2. 15 初心者はなぜテストコードでつまずくのか • 実コードと考え方が異なるため ◦ 実コードでは動くようにプログラムを書く ◦ テストコードは実コードが期待した通り動くことを確認する • 書き方や考え方を教えてくれる人が周りにいない

    • 個人開発であれば、テストコードの導入が検討されない • 導入しようにもテストコードを導入しづらいコードを書いている可能性がある ◦ 密結合のコードのテストコードは書きづらい ◦ テストコードを書き慣れたエンジニアは疎結合でコードを書くことが多い
  3. 18 tc_practiceの概要 • Laravel + PHPUnitの問題集 全12問(2024/12月現在) • 初心者でも取り組みやすい内容設計 •

    シンプルかつ実践的なデモページ(ユーザログインページ・マイページ) • コンテナ環境により、誰でも同じ開発環境を構築可能
  4. 20 tc_practice マイページ http://localhost:8000 • 上部はユーザデータの表示 • 下部はapiを叩くことにより取得できる 今日の天気 •

    Logoutボタンを押下することでユーザ セッションが削除され、 http://localhost:8000 へのアクセスは 全て /user/login へリダイレクトされ る
  5. 22 tc_practice |-- QUESTIONS.md |-- README.md |-- app |-- database

    |-- initial_run.sh |-- mysql |-- phpunit |-- phpunit.xml |-- public |-- resources |-- routes |-- storage `-- tests tc_practiceのディレクトリ構成 —— clone直後の1度のみ実行するスクリプト —— dbコンテナ経由で実行するためのスクリプト —— phpunitコンテナ経由で実行するためのスクリプト
  6. 23 tc_practice で 学習環境を整える手順 • Docker をインストールする ※ Apple シリコンの場合は、Dockerの「Use

    Rosetta for 〜〜〜」をONにしてください • tc_practice をcloneする • tc_practice 内にある initial_run.sh を実行する $ sh ./initial_run.sh ◦ どんなスクリプトが回っているかはAppendixにも記載 以上。
  7. 24 docker-compose.ymlで立ち上がるコンテナとその役割 appコンテナ localhost:8000 へのアクセスを可能に するためのコンテナ dbコンテナ 存在するデータベース tc_practice tc_practice_test

    phpunitコンテナ phpunitの実行環境 dbコンテナのmysql接続方法 $ ./mysql docker compose exec db bash -c "mysql -htc-practice-db -utc_user -ptc_user -D tc_practice" PHPUnitの実行コマンド $ ./phpunit docker-compose run --rm phpunit vendor/bin/phpunit $@
  8. 27 問題に取り組む流れ • 全体実行する場合 $ ./phpunit • 単体実行する場合 $ ./phpunit

    test/Unit/Library/UserUtilTest.php QUESTIONS.md の問題を読む 該当ファイルを開く ロジックを埋める テストを実行する
  9. 28 問題に取り組む流れ • 全体実行する場合 $ ./phpunit • 単体実行する場合 $ ./phpunit

    test/Unit/Library/UserUtilTest.php QUESTIONS.md の問題を読む 該当ファイルを開く ロジックを埋める テストを実行する 実作業にそっくりなシチュエーション
  10. 29 何もしないでmasterブランチのテストを実行すると テストはスキップされたり失敗する . : Success。全て「.」になることを目指す F: Failure。テストが通らなかった(= 落ちた)ことを示す E:

    Error。テスト実行時に何かしらのエラーが生じたことを示す S: Skip。テストが実行されなかったことを示す R: Risky。テストを実行したが、評価する式(=アサーション)が存在しないことを示す。
  11. 34 class PasswordUtil { /** * 生のpasswordとHash化されてるpasswordを比較し、合っているかどうかを確認します。 * * @param

    password: string * @param passwordHash: string * @return bool */ public function isCorrect($password, $passwordHash) { return password_verify($password, $passwordHash); } } こんな実コードがあるとする(tc_practiceより抜粋) app/Libraries/PasswordUtil.php
  12. 35 テストコードはこうなる class PasswordUtilTest extends TestCase { public function setUp()

    { parent::setUp(); } public function tearDown() { parent::tearDown(); } public function test_isCorrectTrue() { $pu = new PasswordUtil(); $plain = 'password'; $hash = $pu->toHash($plain); $this->assertTrue($pu->isCorrect($plain, $hash)); } public function test_isCorrectFalse() { $pu = new PasswordUtil(); $hash = $pu->toHash("password"); $this->assertFalse($pu->isCorrect("wrong password", $hash)); } } tests/Unit/Libraries/PasswordUtilTest.php
  13. 36 テストコードはこうなる class PasswordUtilTest extends TestCase { public function setUp()

    { parent::setUp(); } public function tearDown() { parent::tearDown(); } public function test_isCorrectTrue() { $pu = new PasswordUtil(); $plain = 'password'; $hash = $pu->toHash($plain); $this->assertTrue($pu->isCorrect($plain, $hash)); } public function test_isCorrectFalse() { $pu = new PasswordUtil(); $hash = $pu->toHash("password"); $this->assertFalse($pu->isCorrect("wrong password", $hash)); } } tests/Unit/Libraries/PasswordUtilTest.php • テストコードはtestsディレクトリに存在 • xxxTest.php というファイル名 • LaravelではUnit/Featureディレクトリがあ り、それぞれ下記の役割を持つ ◦ Unit: LibraryやModelのような単体の確認 ◦ Feature: Controllerなど全体の動作確認 • ファイルパスは実ファイルと同じにする ◦ app/Libraries/PasswordUtil.php が存在
  14. 37 テストコードはこうなる class PasswordUtilTest extends TestCase { public function setUp()

    { parent::setUp(); } public function tearDown() { parent::tearDown(); } public function test_isCorrectTrue() { $pu = new PasswordUtil(); $plain = 'password'; $hash = $pu->toHash($plain); $this->assertTrue($pu->isCorrect($plain, $hash)); } public function test_isCorrectFalse() { $pu = new PasswordUtil(); $hash = $pu->toHash("password"); $this->assertFalse($pu->isCorrect("wrong password", $hash)); } } tests/Unit/Libraries/PasswordUtilTest.php • 「test」というプレフィックスをつけることでテストコード と認識される • @test というアノテーションをつけても認識される /** * @test */ public function isCorrectTrue()
  15. 38 テストコードはこうなる class PasswordUtilTest extends TestCase { public function setUp()

    { parent::setUp(); } public function tearDown() { parent::tearDown(); } public function test_isCorrectTrue() { $pu = new PasswordUtil(); $plain = 'password'; $hash = $pu->toHash($plain); $this->assertTrue($pu->isCorrect($plain, $hash)); } public function test_isCorrectFalse() { $pu = new PasswordUtil(); $hash = $pu->toHash("password"); $this->assertFalse($pu->isCorrect("wrong password", $hash)); } } tests/Unit/Libraries/PasswordUtilTest.php   テストケースは1メソッドにつき分岐の数だけ作るべし isCorrectメソッドの場合、 • パスワードが合ってるケース • パスワードが間違っているケース の2パターン なぜか? • いざテストがこけた時に、原因を特定しやすくなるため • 特定の条件に絞りながらテストを実行しながらの開発を行えるよう になるため ex) PasswordUtilTestのisCorrectTrueのケースのみ確認したい時 ./phpunit tests/Unit/Libraries/PasswordUtilTest.php --filter=isCorrectTrue
  16. 39 テストコードはこうなる class PasswordUtilTest extends TestCase { public function setUp()

    { parent::setUp(); } public function tearDown() { parent::tearDown(); } public function test_isCorrectTrue() { $pu = new PasswordUtil(); $plain = 'password'; $hash = $pu->toHash($plain); $this->assertTrue($pu->isCorrect($plain, $hash)); } public function test_isCorrectFalse() { $pu = new PasswordUtil(); $hash = $pu->toHash("password"); $this->assertFalse($pu->isCorrect("wrong password", $hash)); } } tests/Unit/Libraries/PasswordUtilTest.php   テストケースの名前はパッと見でわかれば何でも良い テストケース名に決まりはない ケースが分かれていて、伝わるものであればOK tc_practiceでは、下記の命名がされている test_テスト名条件 PHPでは日本語をメソッド名に使うこともできるので、条件を日本 語で書くことも。 ex) test_isCorrect_正しく照合できる
  17. 40 テストコードはこうなる class PasswordUtilTest extends TestCase { public function setUp()

    { parent::setUp(); } public function tearDown() { parent::tearDown(); } public function test_isCorrectTrue() { $pu = new PasswordUtil(); $plain = 'password'; $hash = $pu->toHash($plain); $this->assertTrue($pu->isCorrect($plain, $hash)); } public function test_isCorrectFalse() { $pu = new PasswordUtil(); $hash = $pu->toHash("password"); $this->assertFalse($pu->isCorrect("wrong password", $hash)); } } tests/Unit/Libraries/PasswordUtilTest.php setUpやtearDownによりテストケースの事前・事後処理を行える • setUp:事前処理(変数のinitializeやデータの準備など) • tearDown:事後処理(ゴミデータの削除など)
  18. 41 テストコードはこうなる class PasswordUtilTest extends TestCase { public function setUp()

    { parent::setUp(); } public function tearDown() { parent::tearDown(); } public function test_isCorrectTrue() { $pu = new PasswordUtil(); $plain = 'password'; $hash = $pu->toHash($plain); $this->assertTrue($pu->isCorrect($plain, $hash)); } public function test_isCorrectFalse() { $pu = new PasswordUtil(); $hash = $pu->toHash("password"); $this->assertFalse($pu->isCorrect("wrong password", $hash)); } } tests/Unit/Libraries/PasswordUtilTest.php PasswordUtilTest 内のメソッドの実行順は、 setUp() test_isCorrectTrue() tearDown() setUp() test_isCorrectFalse() tearDown() となる
  19. 42 テストコードはこうなる class PasswordUtilTest extends TestCase { public function setUp()

    { parent::setUp(); } public function tearDown() { parent::tearDown(); } public function test_isCorrectTrue() { $pu = new PasswordUtil(); $plain = 'password'; $hash = $pu->toHash($plain); $this->assertTrue($pu->isCorrect($plain, $hash)); } public function test_isCorrectFalse() { $pu = new PasswordUtil(); $hash = $pu->toHash("password"); $this->assertFalse($pu->isCorrect("wrong password", $hash)); } } tests/Unit/Libraries/PasswordUtilTest.php テストに必要な変数を用意する ※ PasswordUtilはplain文字列をハッシュ化するtoHashメソッ ドが用意されているが、スライドでは割愛
  20. 43 テストコードはこうなる class PasswordUtilTest extends TestCase { public function setUp()

    { parent::setUp(); } public function tearDown() { parent::tearDown(); } public function test_isCorrectTrue() { $pu = new PasswordUtil(); $plain = 'password'; $hash = $pu->toHash($plain); $this->assertTrue($pu->isCorrect($plain, $hash)); } public function test_isCorrectFalse() { $pu = new PasswordUtil(); $hash = $pu->toHash("password"); $this->assertFalse($pu->isCorrect("wrong password", $hash)); } } tests/Unit/Libraries/PasswordUtilTest.php アサーションメソッドを使用し、テストしたいメソッ ドの実行結果が想定通りとなっているかを確認 今回利用しているassertTrueメソッドは、 引数の中身がtrueであればテスト成功となる 今回はアサーションメソッドは1つであるが 1テストケースに複数のアサーションメソッドを書く ことが多い
  21. 44 テストコードはこうなる class PasswordUtilTest extends TestCase { public function setUp()

    { parent::setUp(); } public function tearDown() { parent::tearDown(); } public function test_isCorrectTrue() { $pu = new PasswordUtil(); $plain = 'password'; $hash = $pu->toHash($plain); $this->assertTrue($pu->isCorrect($plain, $hash)); } public function test_isCorrectFalse() { $pu = new PasswordUtil(); $hash = $pu->toHash("password"); $this->assertFalse($pu->isCorrect("wrong password", $hash)); } } tests/Unit/Libraries/PasswordUtilTest.php test_isCorrectFalseでは、 わざとisCorrectメソッドに誤ったパスワードとハッシュ文字を入れ falseが返ってくることを期待したケースを表現 ◯もしisCorrectがtrueを返すような変更を加えてしまったら システム的には違うパスワードを入力してもログインできる状況と なってしまう。 しかし、その場合はこのテストケースが落ちるため、誤った変更であ ることに気づける
  22. 46 テストコード内でアサーションはロジックや分岐は避ける public class Target { public function isEven($num) {

    return $num % 2 === 0; } } public function test_isEven() { $target = new Target(); $num = rand(1, 10); if ($num % 2 === 0) { $this->assertTrue($target->isEven($num)); } else { $this->assertFalse($target->isEven($num)); } }
  23. 47 テストコード内でアサーションはロジックや分岐は避ける public class Target { public function isEven($num) {

    return $num % 2 === 0; } } public function test_isEven() { $target = new Target(); $num = rand(1, 10); if ($num % 2 === 0) { $this->assertTrue($target->isEven($num)); } else { $this->assertFalse($target->isEven($num)); } } 呼ばれるアサーションを分岐してしまうと、一貫したテスト結果が得られない。 (assertTrueを通ったりassertFalseを通ったり) テスト自体が複雑になるとメンテナンスが難しくなったり、可読性が低くなる。
  24. 考え得るテストケース 愚直にテストケースを考えると、 各conditionのtrue/falseの数だけパターンを出さな いといけない。 2 × 2 × 2 ×

    2 = 16パターン 必要となる。 public function exec() { if ($condition && $cond2 || $cond3 ) { if ($cond4 ) { } } } 49 ロジックはシンプルにしよう 条件やネストが多いほどテストケースも複雑に。 $condition $cond2 $cond3 $cond4 1 true true true true 2 true true true false 3 true true false true 4 true true false false … … … … …
  25. 50 ロジックはシンプルにしよう 複数条件はメソッド化することでテストケースが少なくなる public function isSatisfied() { return $condition &&

    $cond2 || $cond3 ; } public function exec() { if (!$this->isSatisfied() ) { return; } if ($cond4 ) { } } public function exec() { if ($condition && $cond2 || $cond3 ) { if ($cond4 ) { } } }
  26. 51 ロジックはシンプルにしよう 複数条件はメソッド化することでテストケースが少なくなる public function isSatisfied() { return $condition &&

    $cond2 || $cond3 ; } public function exec() { if (!$this->isSatisfied() ) { return; } if ($cond4 ) { } } public function exec() { if ($condition && $cond2 || $cond3 ) { if ($cond4 ) { } } } 複雑な条件分岐をメソッド化
  27. 52 ロジックはシンプルにしよう 複数条件はメソッド化することでテストケースが少なくなる public function isSatisfied() { return $condition &&

    $cond2 || $cond3 ; } public function exec() { if (!$this->isSatisfied() ) { return; } if ($cond4 ) { } } public function exec() { if ($condition && $cond2 || $cond3 ) { if ($cond4 ) { } } } isSatisfied()の条件を満たさない場合は、アーリーリターン。 ネストもシンプルに。
  28. 53 ロジックはシンプルにしよう 複数条件はメソッド化することでテストケースが少なくなる public function isSatisfied() { return $condition &&

    $cond2 || $cond3 ; } public function exec() { if (!$this->isSatisfied() ) { return; } if ($cond4 ) { } } 考え得るテストケース isSatisfied 2 × 2 × 2 = 8パターン exec isSatisfied がfalseのパターン isSatisfied がtrueであり、$cond4 がtrue/false の計3パターン となる。
  29. 54 必ず通るテストを書く 確率で落ちたり、時間帯、日付などによりこけるテストは書かない。 // 2025年になったらどうするの? $this->assertEquals($date->getThisYear(), 2024); // intが返ってくることだけを期待したり $this->assertIsInt($date->getThisYear());

    // Carbonを使って時間を操作してみる Carbon::setTestNow(Carbon::createFromDate(2024, 12, 22)); $this->assertEquals($date->getThisYear(), 2024); // 2回に1回こけない? $number = rand(1, 2); $this->assertEquals(1, $number); // intが返ってくることだけを期待してみたり $number = rand(1, 2); $this->assertIsInt($number); // 数字が3未満であることを期待する $this->assertTrue($number < 3);
  30. 60 まとめ • テストコードの学習用リポジトリ「tc_practice」を作成・公開 ◦ Laravel/PHPUnit 環境におけるテストコードの問題集 • テストコードの基礎的な書き方やテクニックを紹介 •

    Appendixの内容 ◦ initial_run.sh により行われること ◦ よく使われるアサーション一覧 ◦ voidメソッドのテストケースの作成方法 ◦ Exceptionが発生するかを確認するテストケースの作成方法 ◦ 外部接続を行うメソッドの場合(mockの活用方法)
  31. 63 initial_run.sh により行われること • .env ファイルの作成 • Dockerの準備・立ち上げ ◦ app

    / db / phpunit コンテナが立ち上がる • composer install • .envファイルのアプリケーションキー作成 • マイグレーション実行 • ローカル環境にseederを実行 • testユーザの作成
  32. 64 よく使われるアサーションメソッド① メソッド名 用途 利用例 assertEquals 実測値と予期する値が一致することを期待する(==) ✅ $this->assertEquals('1', 1);

    ❌ $this->assertEquals('1', 2); (左:予期する値、 右:実測値) assertSame 実測値と予期する値が型も含めて一致することを期待する(===) ✅ $this->assertSame('1', '1'); ❌ $this->assertSame('1', 1);(左:予期する 値、 右:実測値) assertEmpty 配列の中身が空であることを期待する ✅ $this->assertEmpty([]); ❌ $this->assertEmpty(['hoge']); assertNotEmpty 配列の中身が空でないことを期待する ✅ $this->assertNotEmpty(['hoge']); ❌ $this->assertNotEmpty([]); assertIsInt 実測値がintであることを期待する ✅ $this->assertIsInt(100); ❌ $this->assertIsInt('100');
  33. 65 よく使われるアサーションメソッド② メソッド名 用途 利用例 assertTrue 実測値がtrueであることを期待する ✅ $this->assertTrue(true); ✅

    $this->assertTrue(1); ❌ $this->assertTrue(false); ❌ $this->assertTrue(0); assertFalse 実測値がfalseであることを期待する ✅ $this->assertFalse(false); ✅ $this->assertFalse(0); ❌ $this->assertFalse(true); ❌ $this->assertFalse(1); assertCount 配列のカウントがNであることを期待する ✅ $this->assertCount(3, [0, 1, 2]); ❌ $this->assertCount(3, [0, 1]); assertStatus Controllerのアサーション。 アクセス結果のステータスコードを確認する $this->get("/")->assertStatus(200); assertSee Controllerのアサーション。 レスポンス内に特定の文字列が存在することを確認する $this->get("/")->assertSee("DisplayText") ; assertRedirect Controllerのアサーション。 アクセス後、どこにリダイレクトされるかを確認する $this->get("/") ->assertStatus(302) ->assertRedirect("/user/login");
  34. 67 voidメソッドのテストケースの作成方法 class Target { /** * 期限切れのデータを一括削除する * @return

    void */ public function deleteAllIfExpired() { self::where('expired_at', '<', Carbon::now()) ->update(['is_deleted' => true]); } } 考えうるデータのパターンは、下記4つ。 ①期限切れかつまだ削除されていない ②かつすでに削除されている ③まだ期限内かつ削除されていない ④かつすでに削除されている このうち、updateが走らなければいけないデータは① である。
  35. 68 voidメソッドのテストケースの作成方法 class TargetTest { public function test_deleteAllIfExpired_execDelete() { Carbon::setTestNow(Carbon::create(2024,

    12, 22, 0, 0, 0)); $target = Target::factory()->create([ 'expired_at' => '2024-12-21 23:59:59', 'is_deleted' => false, ]); (new Target())->deleteAllIfExpired(); $result = Target::find($taget->id); $this->assertTrue($result->is_deleted); } }
  36. 69 voidメソッドのテストケースの作成方法 class TargetTest { public function test_deleteAllIfExpired_execDelete() { Carbon::setTestNow(Carbon::create(2024,

    12, 22, 0, 0, 0)); $target = Target::factory()->create([ 'expired_at' => '2024-12-21 23:59:59', 'is_deleted' => false, ]); (new Target())->deleteAllIfExpired(); $result = Target::find($taget->id); $this->assertTrue($result->is_deleted); } } 前処理として、Carbonを利用し、今日の日付を 2024/12/22 00:00:00固定。
  37. 70 voidメソッドのテストケースの作成方法 class TargetTest { public function test_deleteAllIfExpired_execDelete() { Carbon::setTestNow(Carbon::create(2024,

    12, 22, 0, 0, 0)); $target = Target::factory()->create([ 'expired_at' => '2024-12-21 23:59:59', 'is_deleted' => false, ]); (new Target())->deleteAllIfExpired(); $result = Target::find($taget->id); $this->assertTrue($result->is_deleted); } } また、Factoryによりdelete処理が実行されるレコードを作成。 Factoryとはダミーデータを作るためのライブラリである。 https://readouble.com/laravel/10.x/ja/eloquent-factories.html 今日の日付(2024/12/22 00:00:00)よりも以前の日付がexpired_atに 設定されていればよいが、 閾値のチェックを行うため今日より1秒前の日付(2024/12/21 23:59:59)とする。
  38. class TargetTest { public function test_deleteAllIfExpired_execDelete() { Carbon::setTestNow(Carbon::create(2024, 12, 22,

    0, 0, 0)); $target = Target::factory()->create([ 'expired_at' => '2024-12-21 23:59:59', 'is_deleted' => false, ]); (new Target())->deleteAllIfExpired(); $result = Target::find($taget->id); $this->assertTrue($result->is_deleted); } } 71 voidメソッドのテストケースの作成方法 前データを用意し終えてから、テスト対象のメソッドを実行。
  39. class TargetTest { public function test_deleteAllIfExpired_execDelete() { Carbon::setTestNow(Carbon::create(2024, 12, 22,

    0, 0, 0)); $target = Target::factory()->create([ 'expired_at' => '2024-12-21 23:59:59', 'is_deleted' => false, ]); (new Target())->deleteAllIfExpired(); $result = Target::find($taget->id); $this->assertTrue($result->is_deleted); } } 72 voidメソッドのテストケースの作成方法 実行後、対象のレコードを取得。 その後、is_deletedがtrueとなっていることを期待する アサーション(assertTrue)を呼び出す。
  40. 73 Exceptionが発生するかを確認するテストケースの作成方法 class User extends Authenticatable { /** * ユーザを登録します。

    * * @param loginId: string * @param name: string * @param password: string * @param birthday: string(Y-m-d) * @return user **/ public static function register($loginId, $name, $password, $birthday) { $pu = new PasswordUtil(); return self::create([ 'login_id' => $loginId, 'name' => $name, 'password' => $pu->toHash($password), 'birthday' => $birthday ]); } } app/Models/User.php
  41. 74 Exceptionが発生するかを確認するテストケースの作成方法 class User extends Authenticatable { /** * ユーザを登録します。

    * * @param loginId: string * @param name: string * @param password: string * @param birthday: string(Y-m-d) * @return user **/ public static function register($loginId, $name, $password, $birthday) { $pu = new PasswordUtil(); return self::create([ 'login_id' => $loginId, 'name' => $name, 'password' => $pu->toHash($password), 'birthday' => $birthday ]); } } app/Models/User.php 与えた情報を元にユーザ登録をするためのクラスメソッド。 テーブルの中で、login_idにユニーク制約が 設定されているため、同じlogin_idの登録はできない仕様。
  42. 75 Exceptionが発生するかを確認するテストケースの作成方法 class User extends Authenticatable { /** * ユーザを登録します。

    * * @param loginId: string * @param name: string * @param password: string * @param birthday: string(Y-m-d) * @return user **/ public static function register($loginId, $name, $password, $birthday) { $pu = new PasswordUtil(); return self::create([ 'login_id' => $loginId, 'name' => $name, 'password' => $pu->toHash($password), 'birthday' => $birthday ]); } } app/Models/User.php 与えた情報を元にユーザ登録をするためのクラスメソッド。 テーブルの中で、login_idにユニーク制約が 設定されているため、同じlogin_idの登録はできない仕様。 ※ 他にも様々なメソッドが用意されているが、資料用に一部抜粋
  43. 76 Exceptionが発生するかを確認するテストケースの作成方法 tests/Unit/Models/UserTest.php class UserTest extends TestCase { public function

    test_registerDuplicatedLoginId() { $faker = \Faker\Factory::create('ja_JP'); $loginId = $faker->userName; $password = $faker->password(); $birthday = $faker->dateTimeBetween('1day', '20year')->format('Y-m-d'); User::register($loginId, 'name', 'password', '1990-01-01'); $this->expectException(QueryException::class); User::register($loginId, 'name2', 'password2', '1991-01-01'); } }
  44. 77 Exceptionが発生するかを確認するテストケースの作成方法 tests/Unit/Models/UserTest.php class UserTest extends TestCase { public function

    test_registerDuplicatedLoginId() { $faker = \Faker\Factory::create('ja_JP'); $loginId = $faker->userName; $password = $faker->password(); $birthday = $faker->dateTimeBetween('1day', '20year')->format('Y-m-d'); User::register($loginId, 'name', 'password', '1990-01-01'); $this->expectException(QueryException::class); User::register($loginId, 'name2', 'password2', '1991-01-01'); } } 重複したlogin_idを登録しようとした場合のテストケースのみ抜粋。 そのため、特定の $loginIdでユーザデータを作成し、 その後もう一度同じ $loginIdでユーザデータを作成しようとした時にExceptionが発生することをテストする。
  45. 78 Exceptionが発生するかを確認するテストケースの作成方法 tests/Unit/Models/UserTest.php class UserTest extends TestCase { public function

    test_registerDuplicatedLoginId() { $faker = \Faker\Factory::create('ja_JP'); $loginId = $faker->userName; $password = $faker->password(); $birthday = $faker->dateTimeBetween('1day', '20year')->format('Y-m-d'); User::register($loginId, 'name', 'password', '1990-01-01'); $this->expectException(QueryException::class); User::register($loginId, 'name2', 'password2', '1991-01-01'); } } Exceptionを意図的に起こすために、 ユーザレコードを作成。
  46. 79 Exceptionが発生するかを確認するテストケースの作成方法 tests/Unit/Models/UserTest.php その後、再度重複したユーザデータを作成する前に、expectExceptionにより、起こりうるエラーを検知できる状態にする。 (今回のメソッドでは、QueryExceptionがthrowされる) class UserTest extends TestCase {

    public function test_registerDuplicatedLoginId() { $faker = \Faker\Factory::create('ja_JP'); $loginId = $faker->userName; $password = $faker->password(); $birthday = $faker->dateTimeBetween('1day', '20year')->format('Y-m-d'); User::register($loginId, 'name', 'password', '1990-01-01'); $this->expectException(QueryException::class); User::register($loginId, 'name2', 'password2', '1991-01-01'); } }
  47. 80 Exceptionが発生するかを確認するテストケースの作成方法 tests/Unit/Models/UserTest.php class UserTest extends TestCase { public function

    test_registerDuplicatedLoginId() { $faker = \Faker\Factory::create('ja_JP'); $loginId = $faker->userName; $password = $faker->password(); $birthday = $faker->dateTimeBetween('1day', '20year')->format('Y-m-d'); User::register($loginId, 'name', 'password', '1990-01-01'); $this->expectException(QueryException::class); User::register($loginId, 'name2', 'password2', '1991-01-01'); } } 準備段階で作成した $loginId で再度ユーザ登録を行う。
  48. 81 外部接続を行うメソッドの場合(mockの活用方法) class Forecast { /** * 天気予報の概要を取得します * *

    @param code: string * @return string */ public function loadOverviewText($code) { $client = new \GuzzleHttp\Client(); $response = $client->request( 'GET', $this->makeOverviewUrl($code) ); if ($response->getStatusCode() != 200) return ''; $jsonObj = json_decode($response->getBody()->getContents(), true); return $jsonObj['text']; } } app/Libraries/Api/Forecast.php
  49. 82 外部接続を行うメソッドの場合(mockの活用方法) class Forecast { /** * 天気予報の概要を取得します * *

    @param code: string * @return string */ public function loadOverviewText($code) { $client = new \GuzzleHttp\Client(); $response = $client->request( 'GET', $this->makeOverviewUrl($code) ); if ($response->getStatusCode() != 200) return ''; $jsonObj = json_decode($response->getBody()->getContents(), true); return $jsonObj['text']; } } app/Libraries/Api/Forecast.php このタイミングで天気予報取得のためのapiを叩いている (=外部サービスへのアクセスがある)
  50. 83 外部接続を行うメソッドの場合(mockの活用方法) tests/Unit/Libraries/Api/ForecastTest.php class ForecastTest extends TestCase { public function

    test_loadOverviewText() { $mock = Mockery::mock('overload:\GuzzleHttp\Client'); $mock->shouldReceive('request->getStatusCode') ->once() ->andReturn(200); $mock->shouldReceive('request->getBody->getContents') ->once() ->andReturn($this->getDummyJson()); $f = new Forecast(); $code = 12345; $result = $f->loadOverviewText($code); $this->assertEquals(json_decode($this->getDummyJson(), true)['text'], $result); Mockery::close(); } private function getDummyJson() { return json_encode( [ "publishingOffice" => "気象庁", "reportDatetime" => "2023-06-19T16:37:00+09:00", "targetArea" => "東京都", "headlineText" => "", "text" => "概要のダミーテキスト " ] ); } }
  51. 84 外部接続を行うメソッドの場合(mockの活用方法) tests/Unit/Libraries/Api/ForecastTest.php class ForecastTest extends TestCase { public function

    test_loadOverviewText() { $mock = Mockery::mock('overload:\GuzzleHttp\Client'); $mock->shouldReceive('request->getStatusCode') ->once() ->andReturn(200); $mock->shouldReceive('request->getBody->getContents') ->once() ->andReturn($this->getDummyJson()); $f = new Forecast(); $code = 12345; $result = $f->loadOverviewText($code); $this->assertEquals(json_decode($this->getDummyJson(), true)['text'], $result); Mockery::close(); } private function getDummyJson() { return json_encode( [ "publishingOffice" => "気象庁", "reportDatetime" => "2023-06-19T16:37:00+09:00", "targetArea" => "東京都", "headlineText" => "", "text" => "概要のダミーテキスト " ] ); } } loadOverviewTextのテストケース test_loadOverviewText内で利用するメソッド
  52. 85 外部接続を行うメソッドの場合(mockの活用方法) tests/Unit/Libraries/Api/ForecastTest.php class ForecastTest extends TestCase { public function

    test_loadOverviewText() { $mock = Mockery::mock('overload:\GuzzleHttp\Client'); $mock->shouldReceive('request->getStatusCode') ->once() ->andReturn(200); $mock->shouldReceive('request->getBody->getContents') ->once() ->andReturn($this->getDummyJson()); $f = new Forecast(); $code = 12345; $result = $f->loadOverviewText($code); $this->assertEquals(json_decode($this->getDummyJson(), true)['text'], $result); } private function getDummyJson() { return json_encode( [ "publishingOffice" => "気象庁", "reportDatetime" => "2023-06-19T16:37:00+09:00", "targetArea" => "東京都", "headlineText" => "", "text" => "概要のダミーテキスト " ] ); } } テストコードは外部接続を行わないように書くようにするべし なぜか? • あくまで自身が運用するテストを書きたいだけなので、外部依存のテストを書いてしまうと、 コントロールできなくなってしまうため ◦ 外部サービスがメンテナンス中などになってしまうと、テストがこけ続けてしまう 外部接続を行うメソッドのテストケースを考える場合、モックというものを用意する。 これを利用することで、実際の外部接続を避け、外部接続のタイミングでダミーデータを返す処理に置 き換えることができる。
  53. 86 外部接続を行うメソッドの場合(mockの活用方法) tests/Unit/Libraries/Api/ForecastTest.php class ForecastTest extends TestCase { public function

    test_loadOverviewText() { $mock = Mockery::mock('overload:\GuzzleHttp\Client'); $mock->shouldReceive('request->getStatusCode') ->once() ->andReturn(200); $mock->shouldReceive('request->getBody->getContents') ->once() ->andReturn($this->getDummyJson()); $f = new Forecast(); $code = 12345; $result = $f->loadOverviewText($code); $this->assertEquals(json_decode($this->getDummyJson(), true)['text'], $result); } private function getDummyJson() { return json_encode( [ "publishingOffice" => "気象庁", "reportDatetime" => "2023-06-19T16:37:00+09:00", "targetArea" => "東京都", "headlineText" => "", "text" => "概要のダミーテキスト " ] ); } } 実コード内で利用する箇所をモックし、ロジックに 支障が出ないようにする アサーションとは異なり、ロジックを動かす前に モックの設定を行う 実コード public function loadOverviewText($code) { $client = new \GuzzleHttp\Client(); $response = $client->request( 'GET', $this->makeOverviewUrl($code) ); if ($response->getStatusCode() != 200) return ''; $jsonObj = json_decode( $response->getBody()->getContents(), true ); return $jsonObj['text']; }
  54. 87 外部接続を行うメソッドの場合(mockの活用方法) tests/Unit/Libraries/Api/ForecastTest.php class ForecastTest extends TestCase { public function

    test_loadOverviewText() { $mock = Mockery::mock('overload:\GuzzleHttp\Client'); $mock->shouldReceive('request->getStatusCode') ->once() ->andReturn(200); $mock->shouldReceive('request->getBody->getContents') ->once() ->andReturn($this->getDummyJson()); $f = new Forecast(); $code = 12345; $result = $f->loadOverviewText($code); $this->assertEquals(json_decode($this->getDummyJson(), true)['text'], $result); } private function getDummyJson() { return json_encode( [ "publishingOffice" => "気象庁", "reportDatetime" => "2023-06-19T16:37:00+09:00", "targetArea" => "東京都", "headlineText" => "", "text" => "概要のダミーテキスト " ] ); } } 実コード内で利用する箇所をモックし、ロジックに 支障が出ないようにする アサーションとは異なり、ロジックを動かす前に モックの設定を行う 実コード public function loadOverviewText($code) { $client = new \GuzzleHttp\Client(); $response = $client->request( 'GET', $this->makeOverviewUrl($code) ); if ($response->getStatusCode() != 200) return ''; $jsonObj = json_decode( $response->getBody()->getContents(), true ); return $jsonObj['text']; } requeset->getStatusCode request->getBody->getContents の2つをモックすることで、実コード内のロジックが 動作するようにする
  55. 88 外部接続を行うメソッドの場合(mockの活用方法) tests/Unit/Libraries/Api/ForecastTest.php class ForecastTest extends TestCase { public function

    test_loadOverviewText() { $mock = Mockery::mock('overload:\GuzzleHttp\Client'); $mock->shouldReceive('request->getStatusCode') ->once() ->andReturn(200); $mock->shouldReceive('request->getBody->getContents') ->once() ->andReturn($this->getDummyJson()); $f = new Forecast(); $code = 12345; $result = $f->loadOverviewText($code); $this->assertEquals(json_decode($this->getDummyJson(), true)['text'], $result); } private function getDummyJson() { return json_encode( [ "publishingOffice" => "気象庁", "reportDatetime" => "2023-06-19T16:37:00+09:00", "targetArea" => "東京都", "headlineText" => "", "text" => "概要のダミーテキスト " ] ); } } このテストケース内で実コードが実行される際、 GuzzleHttp\Clientの • request結果のgetStatusCode()が ◦ 1度 (= once) ◦ 呼ばれるべきであり(= shouldReceive) ◦ 外部アクセスしたことにして、 200を返す(= andReturn)
  56. 89 外部接続を行うメソッドの場合(mockの活用方法) tests/Unit/Libraries/Api/ForecastTest.php class ForecastTest extends TestCase { public function

    test_loadOverviewText() { $mock = Mockery::mock('overload:\GuzzleHttp\Client'); $mock->shouldReceive('request->getStatusCode') ->once() ->andReturn(200); $mock->shouldReceive('request->getBody->getContents') ->once() ->andReturn($this->getDummyJson()); $f = new Forecast(); $code = 12345; $result = $f->loadOverviewText($code); $this->assertEquals(json_decode($this->getDummyJson(), true)['text'], $result); } private function getDummyJson() { return json_encode( [ "publishingOffice" => "気象庁", "reportDatetime" => "2023-06-19T16:37:00+09:00", "targetArea" => "東京都", "headlineText" => "", "text" => "概要のダミーテキスト " ] ); } } このテストケース内で実コードが実行される際、 GuzzleHttp\Clientの • request結果のgetBody()->getContents()が ◦ 1度 (= once) ◦ 呼ばれるべきであり(= shouldReceive) ◦ 外部アクセスしたことにして、 getDummyJsonを返す(= andReturn)
  57. 90 外部接続を行うメソッドの場合(mockの活用方法) tests/Unit/Libraries/Api/ForecastTest.php class ForecastTest extends TestCase { public function

    test_loadOverviewText() { $mock = Mockery::mock('overload:\GuzzleHttp\Client'); $mock->shouldReceive('request->getStatusCode') ->once() ->andReturn(200); $mock->shouldReceive('request->getBody->getContents') ->once() ->andReturn($this->getDummyJson()); $f = new Forecast(); $code = 12345; $result = $f->loadOverviewText($code); $this->assertEquals(json_decode($this->getDummyJson(), true)['text'], $result); } private function getDummyJson() { return json_encode( [ "publishingOffice" => "気象庁", "reportDatetime" => "2023-06-19T16:37:00+09:00", "targetArea" => "東京都", "headlineText" => "", "text" => "概要のダミーテキスト " ] ); } }   ダミーデータはできるだけ精巧に作るべし なぜか? このデータが精巧であればあるほど、実際の挙動に近いテスト となり、より安心感のあるものとなるため。 実コードでは、 return $jsonObj['text']; とありtextパラメータのみ利用する。 しかしgetDummyJsonメソッドでは、 実際のapiを叩いた場合に返ってくるデータも用意している。