Slide 1

Slide 1 text

テストコード書いてみませんか? PHP Conference Japan 2024 おのぽん @onopon_engineer

Slide 2

Slide 2 text

X(旧Twitter)への投稿は、 #phpcon #track5 とつけていただけると嬉しいです! また、本セッションの対象者は「テストコードを知りたい方」も含むので、 基礎的な部分にも触れる内容となっております。 2 はじめに

Slide 3

Slide 3 text

自己紹介 株式会社ウエディングパーク ValueDevelopment 本部 おのぽん(@onopon_engineer) ・2022年6月にウエディングパークに入社 ・前職では   - 大規模ソーシャルゲームのサーバエンジニア   - 人工知能コミュニケーションロボットのサーバ全般   - Androidアプリ開発  を行い、業務でのPHP開発は未経験 ・エンジニア以外の時は大体卓球をしています🏓 ・パラ卓球選手のコーチもしていました! ・個人としても港区代表になったり  年代別で東京代表になったり頑張っています🔥 3

Slide 4

Slide 4 text

Photorait 全国のフォトウエディング・前撮りの撮影スタジオ・サロン情報と共にクチコミを掲載している 日本最大級のフォトウエディング専門クチコミ情報サイト 4

Slide 5

Slide 5 text

5 突然ですが、皆様テストコード書いてますか??

Slide 6

Slide 6 text

6 独学で書けるようになりましたか?

Slide 7

Slide 7 text

7 また、テストコードを書いたことがない・書くのが苦手な方が チームにジョインした際、教えるためのリソースは割けますか?

Slide 8

Slide 8 text

8 テストコード初心者が1人でもテストコードを学習できる リポジトリを手に入れよう! テストコードを書く上での考え方・テクニック を身につけよう! 本セッションのゴール

Slide 9

Slide 9 text

● テストコードとは ● リポジトリ紹介 ● テストコードの書き方・ワンポイントテクニック ● 保守性しやすいテストコードを目指して ● まとめ 9 アジェンダ

Slide 10

Slide 10 text

10 そもそも

Slide 11

Slide 11 text

11 テストコードとは あるプログラムを書いた時に、そのロジックの整合性を担保するために書く コードのことを指す。 いわばロジックの仕様をコード化するイメージ。

Slide 12

Slide 12 text

12 なぜテストコードを書くべきなのか? 様々な理由がありますが、 最も大きな理由はエンジニアの心理的安全性を保つためと考えます。

Slide 13

Slide 13 text

13 エンジニアがよく思わないもの バグ ● デプロイ時、誰しもバグってないかどうか不安になる 機能改修 ● 簡単にみえる修正だとしても、実は影響範囲が広い可能性がある ○ その場合あれこれ考えるのは一苦労・・・ バグ怖い・・・ 修正したらどこ壊れるかわからない・・・ 機能改修したくない。。

Slide 14

Slide 14 text

14 テストコードを書いておくと ● 作ったメソッドの分だけテストコードを用意しておけば挙動の保証ができる → バグが少なくなる ● 改修後、テストコードが正常終了できるかチェックできる ○ 仮にテストコードが正常に終了しなかった(落ちた)場合、考慮漏れ箇所が 存在することを意味する ○ リリース前にエラーに気づける   → 安心して機能改修がしやすくなる バグ・機能改修の心配が減る = エンジニアがハッピーになる

Slide 15

Slide 15 text

15 初心者はなぜテストコードでつまずくのか ● 実コードと考え方が異なるため ○ 実コードでは動くようにプログラムを書く ○ テストコードは実コードが期待した通り動くことを確認する ● 書き方や考え方を教えてくれる人が周りにいない ● 個人開発であれば、テストコードの導入が検討されない ● 導入しようにもテストコードを導入しづらいコードを書いている可能性がある ○ 密結合のコードのテストコードは書きづらい ○ テストコードを書き慣れたエンジニアは疎結合でコードを書くことが多い

Slide 16

Slide 16 text

16 Hello, TC(TestCode) practice

Slide 17

Slide 17 text

17 https://github.com/onopon/tc_practice onopon tc_practice

Slide 18

Slide 18 text

18 tc_practiceの概要 ● Laravel + PHPUnitの問題集 全12問(2024/12月現在) ● 初心者でも取り組みやすい内容設計 ● シンプルかつ実践的なデモページ(ユーザログインページ・マイページ) ● コンテナ環境により、誰でも同じ開発環境を構築可能

Slide 19

Slide 19 text

19 tc_practice ログインページ http://localhost:8000/user/login ● ログインに失敗するとこのページ にリダイレクトされる ● ログインに成功するとマイページ に遷移する ○ この時ユーザセッションが作 成され、セッションがある間 はログイン状態が保持される

Slide 20

Slide 20 text

20 tc_practice マイページ http://localhost:8000 ● 上部はユーザデータの表示 ● 下部はapiを叩くことにより取得できる 今日の天気 ● Logoutボタンを押下することでユーザ セッションが削除され、 http://localhost:8000 へのアクセスは 全て /user/login へリダイレクトされ る

Slide 21

Slide 21 text

21 tc_practice ログアウトapi http://localhost:8000/user/logout ● apiとして存在するroute ● ユーザセッションを削除し、 /user/login にリダイレクトされる

Slide 22

Slide 22 text

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コンテナ経由で実行するためのスクリプト

Slide 23

Slide 23 text

23 tc_practice で 学習環境を整える手順 ● Docker をインストールする ※ Apple シリコンの場合は、Dockerの「Use Rosetta for 〜〜〜」をONにしてください ● tc_practice をcloneする ● tc_practice 内にある initial_run.sh を実行する $ sh ./initial_run.sh ○ どんなスクリプトが回っているかはAppendixにも記載 以上。

Slide 24

Slide 24 text

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 $@

Slide 25

Slide 25 text

25 用意される設問 ● ライブラリのテスト ● モデルのテスト ● コントローラのテスト ● mockの活用 ● 時間が余った人向けのやや時間のかかるロジック・テスト

Slide 26

Slide 26 text

26 想定時間 ● 初心者の方が全ての設問に取り組むと4-6時間程度 ● 必要最低限の問題(☆マークのついている問題)に取り組むと2-3時間程度 ○ Q1, 2, 3, 4, 6, 7, 9, 10

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

28 問題に取り組む流れ ● 全体実行する場合 $ ./phpunit ● 単体実行する場合 $ ./phpunit test/Unit/Library/UserUtilTest.php QUESTIONS.md の問題を読む 該当ファイルを開く ロジックを埋める テストを実行する 実作業にそっくりなシチュエーション

Slide 29

Slide 29 text

29 何もしないでmasterブランチのテストを実行すると テストはスキップされたり失敗する . : Success。全て「.」になることを目指す F: Failure。テストが通らなかった(= 落ちた)ことを示す E: Error。テスト実行時に何かしらのエラーが生じたことを示す S: Skip。テストが実行されなかったことを示す R: Risky。テストを実行したが、評価する式(=アサーション)が存在しないことを示す。

Slide 30

Slide 30 text

30 解答例の確認方法 ● answerブランチの該当ファイルに模範解答を用意しています ○ あくまでも解答例であり、必ずしも一致してなければならないわけではあ りません master answer

Slide 31

Slide 31 text

31 弊社でのtc_practice活用事例 ● チーム内で新入社員を出迎えたタイミング(必要あらば) ● 新卒研修や他部署の方へのレクチャー

Slide 32

Slide 32 text

32 テストコードの基礎とワンポイントテクニック

Slide 33

Slide 33 text

33 テストコードが利用されるタイミング コードレビュー依頼時にCIが動く - 少なくともテストコードの範囲内では 安全に動くことが保証される 開発時に手動でチェック - 開発時に適宜チェックしながら 実装を進めることができる

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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 が存在

Slide 37

Slide 37 text

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()

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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_正しく照合できる

Slide 40

Slide 40 text

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:事後処理(ゴミデータの削除など)

Slide 41

Slide 41 text

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() となる

Slide 42

Slide 42 text

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メソッ ドが用意されているが、スライドでは割愛

Slide 43

Slide 43 text

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テストケースに複数のアサーションメソッドを書く ことが多い

Slide 44

Slide 44 text

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を返すような変更を加えてしまったら システム的には違うパスワードを入力してもログインできる状況と なってしまう。 しかし、その場合はこのテストケースが落ちるため、誤った変更であ ることに気づける

Slide 45

Slide 45 text

45 保守しやすいテストコードを目指して

Slide 46

Slide 46 text

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)); } }

Slide 47

Slide 47 text

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を通ったり) テスト自体が複雑になるとメンテナンスが難しくなったり、可読性が低くなる。

Slide 48

Slide 48 text

48 分岐したい場合はテストケースを分けて書く if分岐していたコードを2つのテストケースに分割。 これにより各テストケースで 必ず同じアサーションを通るように。 実測値が不規則であることは より実際に利用される状況に近づくので◯ public function test_isEven_numberIsEven() { $target = new Target(); $num = 2 * rand(1, 10); $this->assertTrue($target->isEven($num)); } public function test_isEven_numberIsOdd() { $target = new Target(); $num = 2 * rand(1, 10) + 1; $this->assertFalse($target->isEven($num)); }

Slide 49

Slide 49 text

考え得るテストケース 愚直にテストケースを考えると、 各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 … … … … …

Slide 50

Slide 50 text

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 ) { } } }

Slide 51

Slide 51 text

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 ) { } } } 複雑な条件分岐をメソッド化

Slide 52

Slide 52 text

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()の条件を満たさない場合は、アーリーリターン。 ネストもシンプルに。

Slide 53

Slide 53 text

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パターン となる。

Slide 54

Slide 54 text

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);

Slide 55

Slide 55 text

55 本セッションのゴール

Slide 56

Slide 56 text

56 テストコード初心者が1人でもテストコードを学習できる リポジトリを手に入れよう! テストコードを書く上での考え方・テクニック を身につけよう! 本セッションのゴール

Slide 57

Slide 57 text

57 https://github.com/onopon/tc_practice onopon tc_practice

Slide 58

Slide 58 text

58 テストコード初心者が1人でもテストコードを学習できる リポジトリを手に入れよう! テストコードを書く上での考え方・テクニック を身につけよう! 本セッションのゴール

Slide 59

Slide 59 text

59 本セッションのゴール テストコード初心者が1人でもテストコードを学習できる リポジトリを手に入れよう! テストコードを書く上での考え方・テクニック を身につけよう! → 今回説明しきれなかった資料はAppendixに掲載しております。   Xでも展開するので、後ほど見返していただけると嬉しいです!

Slide 60

Slide 60 text

60 まとめ ● テストコードの学習用リポジトリ「tc_practice」を作成・公開 ○ Laravel/PHPUnit 環境におけるテストコードの問題集 ● テストコードの基礎的な書き方やテクニックを紹介 ● Appendixの内容 ○ initial_run.sh により行われること ○ よく使われるアサーション一覧 ○ voidメソッドのテストケースの作成方法 ○ Exceptionが発生するかを確認するテストケースの作成方法 ○ 外部接続を行うメソッドの場合(mockの活用方法)

Slide 61

Slide 61 text

61 ご清聴ありがとうございました!

Slide 62

Slide 62 text

62 Appendix

Slide 63

Slide 63 text

63 initial_run.sh により行われること ● .env ファイルの作成 ● Dockerの準備・立ち上げ ○ app / db / phpunit コンテナが立ち上がる ● composer install ● .envファイルのアプリケーションキー作成 ● マイグレーション実行 ● ローカル環境にseederを実行 ● testユーザの作成

Slide 64

Slide 64 text

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');

Slide 65

Slide 65 text

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");

Slide 66

Slide 66 text

66 voidメソッドのテストケースの作成方法 voidの場合は、そのメソッドが叩かれるとどの ように変化するかをテストケースとして考え る。 今回の場合、該当のTargetのレコードの is_deletedに変化があるかどうかを考える。 class Target { /** * 期限切れのデータを一括削除する * @return void */ public function deleteAllIfExpired() { self::where('expired_at', '<', Carbon::now()) ->update(['is_deleted' => true]); } }

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

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); } }

Slide 69

Slide 69 text

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固定。

Slide 70

Slide 70 text

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)とする。

Slide 71

Slide 71 text

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メソッドのテストケースの作成方法 前データを用意し終えてから、テスト対象のメソッドを実行。

Slide 72

Slide 72 text

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)を呼び出す。

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

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の登録はできない仕様。

Slide 75

Slide 75 text

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の登録はできない仕様。 ※ 他にも様々なメソッドが用意されているが、資料用に一部抜粋

Slide 76

Slide 76 text

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'); } }

Slide 77

Slide 77 text

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が発生することをテストする。

Slide 78

Slide 78 text

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を意図的に起こすために、 ユーザレコードを作成。

Slide 79

Slide 79 text

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'); } }

Slide 80

Slide 80 text

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 で再度ユーザ登録を行う。

Slide 81

Slide 81 text

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

Slide 82

Slide 82 text

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を叩いている (=外部サービスへのアクセスがある)

Slide 83

Slide 83 text

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" => "概要のダミーテキスト " ] ); } }

Slide 84

Slide 84 text

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内で利用するメソッド

Slide 85

Slide 85 text

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" => "概要のダミーテキスト " ] ); } } テストコードは外部接続を行わないように書くようにするべし なぜか? ● あくまで自身が運用するテストを書きたいだけなので、外部依存のテストを書いてしまうと、 コントロールできなくなってしまうため ○ 外部サービスがメンテナンス中などになってしまうと、テストがこけ続けてしまう 外部接続を行うメソッドのテストケースを考える場合、モックというものを用意する。 これを利用することで、実際の外部接続を避け、外部接続のタイミングでダミーデータを返す処理に置 き換えることができる。

Slide 86

Slide 86 text

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']; }

Slide 87

Slide 87 text

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つをモックすることで、実コード内のロジックが 動作するようにする

Slide 88

Slide 88 text

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)

Slide 89

Slide 89 text

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)

Slide 90

Slide 90 text

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を叩いた場合に返ってくるデータも用意している。