作りたいものがありすぎる

40歳を過ぎてプログラミングを始めた人の顛末とこれからなど

【輪読会資料】PHPフレームワーク Laravel Webアプリケーション開発 9章テスト 9-1『ユニットテスト』

以下の記事は2019年1月10日、コワーキングスペース秋葉原Weeybleにて行われる [秋葉原] Laravel Webアプリケーション開発 輪読会 (9章 テスト)の輪読会資料の一部となります。 今回は 9-1 章部分の『ユニットテスト』部分の記事をアップします。

また、元になっている書籍は以下となります。

PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応

PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応

PHPフレームワーク Laravel Webアプリケーション開発 輪読会資料

Chapter 9 テスト

テストコード実装の基礎と実践

目次

  • 9-1 ユニットテスト
    • 9-1-1 テスト対象クラス
    • 9-1-2 テストクラスの生成
    • 9-1-3 テストメソッドの実装
    • 9-1-4 データプロバイダの活用
    • 9-1-5 例外のテスト
    • 9-1-6 テストの前処理・後処理
    • 9-1-7 テストの設定
  • 9-2 データベーステスト
    • 9-2-1 テスト対象のテーブルとクラス
    • 9-2-2 データベーステストの基礎
    • 9-2-3 Eloquentクラスのテスト
    • 9-2-4 サービスクラスのテスト
    • 9-2-5 モックによるテスト(サービスクラス)
  • 9-3 WebAPIテスト
    • 9-3-1 WebAPIテスト機能
    • 9-3-2 テスト対象のAPI
    • 9-3-3 APIテストの実装
    • 9-3-4 WebAPIテストに便利な機能


なお、説明中のサンプルコードは以下のリンクより取得したものです。
PHPフレームワーク Laravel Webアプリケーション開発 laravel-socym/chapter09 by GitHub

9-1 ユニットテスト

Lravelでサポートされるテスト機能


余談

いつも見るサンプルコードの冒頭の奴declare(strict_types=1);は、 厳密な型チェックモードに設定している。 これを記述することによって型指定をして、間違った値が入るとエラーを出すそうです。

9-1-1 テスト対象クラス

ここではポイント算出のメソッドを元にtestを行う
app\Services\CalculatePointService.php

<?php
declare(strict_types=1);

namespace App\Services;

use App\Exceptions\PreConditionException;

final class CalculatePointService
{
    /**
     * @param int $amount
     * @return int
     * @throws PreConditionException
     */
    public static function calcPoint(int $amount): int
    {
        if ($amount < 0) {
            throw new PreConditionException('購入金額が負の数');
        }

        if ($amount < 1000) {
            return 0;
        }

        if ($amount < 10000) {
            $basePoint = 1;
        } else {
            $basePoint = 2;
        }

        return intval($amount / 100) * $basePoint;
    }
}

要約すると以下の処理をするメソッド
買い物をした際に付く、〇〇ポイントの算出をする機能。以下のルールで行う

購入金額 ポイント
0~999 ポイント無し
1,000~9,999 100円につき1ポイント
10,000以上 100円につき2ポイント


9-1-2 テストクラスの生成

ユニットテストを記述するテストクラスは以下のコマンドで生成する

$ php artisan make:test CalculatePointServiceTest --unit
Test created successfully.

以下のパスにファイルが生成される
tests\Unit\CalculatePointServiceTest.php

9.1.2.4 生成されたCalculatePointServiceTestクラス

<?php

namespace Tests\Unit;

use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class CalculatePointServiceTest extends TestCase
{
    /**
     * A basic test example.
     *
     * @return void
     */
    public function testExample()
    {
        $this->assertTrue(true);
    }
}

ここで大事な補足

書籍にはないが、 Testクラスを作成したらテスト対象となるクラスを use させないと駄目。 今回のテスト対象はApp\Services\CalculatePointServiceなので以下の一行をTestファイルのクラス宣言前に追加する。

<?php

namespace Tests\Unit;

use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
// この行を追加
use App\Services\CalculatePointService;

class CalculatePointServiceTest extends TestCase
{
    // 省略
}

リスト9.1.2.2 testsディレクトリの構成

tests
├─Feature                                // フィーチャ機能テストのディレクトリ
│  └─ExampleTest.php
├─Unit                                   // ユニットテストのディレクトリ
│  ├─CalculatePointServiceTest.php      // 生成されたテストクラス(これ以外はデフォルト)
│  └─ExampleTest.php
├──CreatesApplication.php
└──TestCase.php                         // テスト基底クラス

テストクラスを実装する際は Tests\TestCase クラスを継承することが多いが、フレームワークの機能を使わない場合は PHPUnit\Framework\TestCase クラスを直接敬称しても問題ない。

9.1.2.3 テストクラスのクラス図

f:id:sakamata:20190105192040p:plain

生成されたtestClassには以下の様な test... で始まるメソッドが作られる
test... から始まるメソッドがテストメソッドとして実行される。
9.1.2.4 抜粋 生成された CalculatePointServiceTest クラス

<?php
    public function testExample()
    {
        $this->assertTrue(true);
    }

別の方法でコメントに @test アノテーションを付ける方法もある。作者はこっちをお勧めしてる
参考: アノテーションについて
9.1.2.5 @testアノテーション

<?php
    /**
    * @test
    */
    public function Example()
    {
        $this->assertTrue(true);
    }

さらにアノテーションと、メソッド名を日本語にすると(え!?) test結果の判別が付けやすくなる

<?php
    /**
    * @test
    */
    public function divide_除数がゼロなら例外を投げる()
    {
        // (略)
    }

9.1.2.6 テストの実行例
phpunitコマンドに続けてテストクラスファイルを指定する

$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest.php
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 1.94 seconds, Memory: 10.00MB

OK (1 test, 1 assertion)

9.1.2.8 テストの失敗例

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest.php
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

F                                                                   1 / 1 (100%)

Time: 1.04 seconds, Memory: 10.00MB

There was 1 failure:

1) Tests\Unit\CalculatePointServiceTest::Example
Failed asserting that false is true.

/home/vagrant/larabook/chapter09/tests/Unit/CalculatePointServiceTest.php:19

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

コマンドの際 phpunit コマンドの引数を省略すると 全テストが実行される。よく使うので覚えておくと良い

9.1.2.9 全てのテストの実行例

$ ./vendor/bin/phpunit

但し、アプリケーションの規模が大きい場合は、テスト実行時間が恐ろしい事になるので安易やらない方が良い。(Ruby on Railsでの経験則)


9-1-3 テストメソッドの実装

ポイント付与のサンプルコードを元にテストメソッドを実装する
ここでは、ポイント算出ルールの境界値に沿ってテストを記述する
まず、購入金額が0円のパターンを検査する
0円の場合ポイントが0になるか?

9.1.3.2 主なアサーションメソッド(抜粋)

メソッド 内容
assertSame 型も含めて期待値と値が一致するかを検証
assertTrue 値がtrueかどうかを検証
assertReqExp 値が正規表現にマッチするかどうかを検証
assertArrayHasKey 値が配列の場合、指定したキーが存在するかを検証

tests\Unit\CalculatePointServiceTest.phpに以下のメソッドを追加

<?php
    /**
     * @test
     */
    public function calcPoint_購入金額が0ならポイントは0()
    {
        $result = CalculatePointService::calcPoint(0);
        $this->assertSame(0, $result); // $result が0である事を検証
    }

テストを実行すると2つのtestが通る事を確認できる

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

..                                                                  2 / 2 (100%)

Time: 4.43 seconds, Memory: 10.00MB

OK (2 tests, 2 assertions)

同様に今度は購入金額1000円でのテストを書く

<?php
    /**
     * @test
     */
    public function calcPoint_購入金額が1000ならポイントは10()
    {
        $result = CalculatePointService::calcPoint(1000);
        $this->assertSame(10, $result); // $result が10である事を検証
    }

同様にテストが3つ通る事を検証

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 1.03 seconds, Memory: 10.00MB

OK (3 tests, 3 assertions)

こんな感じでテスト項目を増やすって事らしい


9-1-4 データプロバイダの活用

テストメソッドは引数と戻り値の組み合わせのみだが、データプロバイダは同じ処理に対して、異なるパラメータや引数を渡してテストする事ができて便利。
テストメソッドに渡すパラメータを指定するメソッドを用意する
データプロバイダメソッドは public にする必要がある

9.1.4.1 データプロバイダメソッドの例

<?php
    public function dataProvider_for_calcPoint(): array
    {
        return [
            '購入金額が0なら0ポイント'       => [0, 0],
            '購入金額が999なら0ポイント'     => [0, 999],
            '購入金額が1000なら10ポイント'   => [10, 1000],
        ];
    }

データプロバイダを利用するには、テストメソッドに @dataProviderアノテーションを指定する。

9.1.4.2 データプロバイダを利用したテストメソッド

<?php
    /**
     * @test
     * @dataProvider dataProvider_for_calcPoint
     */
    public function calcPoint(int $expected, int $amount)
    {
        $result = CalculatePointService::calcPoint($amount);
        $this->assertSame($expected, $result); 
    }

仮に以下の値をあえて間違った上でテストを実行してみる '購入金額が1000なら10ポイント' => [0, 1000],

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/CalculatePointServiceTest
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

.....F                                                              6 / 6 (100%)

Time: 1.34 seconds, Memory: 10.00MB

There was 1 failure:

1) Tests\Unit\CalculatePointServiceTest::calcPoint with data set "購入金額が1000なら10ポイント" (0, 1000)
Failed asserting that 10 is identical to 0.

/home/vagrant/larabook/chapter09/tests/Unit/CalculatePointServiceTest.php:59

FAILURES!
Tests: 6, Assertions: 6, Failures: 1.

1エラーとなりエラーとなった配列のキー(日本語)と、配列の組み合わせ(0,1000)が出力されるので、エラー詳細が明快になる。

より多くのテストパターンを実装、数値の境界部分の値を追加している
9.1.4.6 データプロバイダメソッドに要素を追加

<?php
    public function dataProvider_for_calcPoint() : array
    {
        return [
            '購入金額が0なら0ポイント' => [0, 0],
            '購入金額が999なら0ポイント' => [0, 999],
            '購入金額が1000なら10ポイント' => [10, 1000],
            '購入金額が9999なら99ポイント' => [99, 9999],
            '購入金額が10000なら200ポイント' => [200, 10000],
        ];
    }

こんな感じでデータプロバイダメソッドでは複数の値、パラメータでテストが行える。


9-1-5 例外のテスト

throw で投げられた例外処理をテストするには、以下の利用方法がある

作者が薦めるのは3つめのアノテーション、ここでは

  • 例外がスローされるか?
  • スローされた例外が意図したものであるか?

を検証する

言葉の意味は expect Exception (期待される 例外)となる

以下3つの例はこのコードのみで完結する自己循環型のテスト例で、CalculatePointServiceをテストしている訳では無い

try/catchの利用

通常のPHPコードと同様にテスト対象を tryで囲む奴を使う

<?php
    /**
     * @test
     */
    public function exception_try_catch()
    {
        try {
            throw new \InvalidArgumentException('message', 200);
            $this->fail(); // (1)例外がスローされない時はテストを失敗させる
        } catch (\Throwable $e) {
            // 指定した例外クラスがスローされているか
            $this->assertInstanceOf(\InvalidArgumentException::class, $e);
            // スローされた例外のコードを検証
            $this->assertSame(200, $e->getCode());
            // スローされた例外のメッセージを検証
            $this->assertSame('message', $e->getMessage());
        }
    }

InvalidArgumentException 引数の型が期待する型と一致しなかった場合にスローされる例外。

expectException メソッドの利用

<?php
    /**
     * @test
     */
    public function exception_expectedException_method()
    {
        // 指定した例外クラスがスローされているか
        $this->expectException(\InvalidArgumentException::class);
        // スローされた例外のコードを検証
        $this->expectExceptionCode(200);
        // スローされた例外のメッセージを検証
        $this->expectExceptionMessage('message');

        throw new \InvalidArgumentException('message', 200);
    }

@expectedExcepsion アノテーションの利用

作者押しの例外テスト方法
アノテーション(コメント)部分に必要事項を書いてしまう

<?php
    /**
     * @test
     * @expectedException \InvalidArgumentException
     * @expectedExceptionCode 200
     * @expectedExceptionMessage message
     */
    public function exception_expectedException_annotation()
    {
        throw new \InvalidArgumentException('message', 200);
    }

9.1.5.4 購入金額が負数の場合のテスト

テスト例となる CalculatePointService classのメソッドに以下の様な throw 処理がある、これをテストする

<?php
        if ($amount < 0) {
            throw new PreConditionException('購入金額が負の数');
        }

ちなみにPreConditionExceptionだが、ファイルを見ても特に何も書いていないが、ちゃんとしたアプリケーションならここにちゃんと例外を起こした際に書くべき処理を書けって事かな?ここでのテストはちゃんとそこに正しい値が投げられるようになっているかを検証するまでを行う。

app\Exceptions\PreConditionException.php

<?php
declare(strict_types=1);

namespace App\Exceptions;

final class PreConditionException extends \Exception
{

}

例外テストの具体例

<?php
    /**
     * @test
     * @expectedException \App\Exceptions\PreConditionException
     * @expectedExceptionMessage 購入金額が負の数
     */
    public function calcPoint_購入金額が負の数なら例外をスロー()
    {
        CalculatePointService::calcPoint(-1);
    }

マイナスの値がcalcPointに投げられた際を検証するために アノテーションで ’@expectedException'で使用される例外処理先\App\Exceptions\PreConditionExceptionを指定する

つまり『この異常な値を放り込んだら、この Exceptionにこのメッセージが飛んでる?』って事をテストしてる。当然だが、メッセージをちょっとでも変えるとテストは通らずエラーになる。


9-1-6 テストの前処理・後処理

テスト前にDBに必要なの値の仕込みや、テスト後に値を削除変更する必要がある場合、テストメソッドに書くと煩雑になるので、別途専用にメソッドが用意されている。PHPUnit\Framework\TestClassにあるテンプレートメソッド

  • 前処理 setUp メソッド
  • テスト中 testメソッド
  • 後処理 tearDown メソッド

の順で呼ばれて処理される

また、テストクラス毎に呼ばれる以下もある

  • setUpBeforeClass メソッド
  • tearDownAfterClass メソッド

これらはテストメソッドが属するテストクラス毎に1回だけ呼ばれる

9.1.6.1 テンプレートメソッドの動きを見るテスト
どんな順番で処理が実行されるかを確かめるメソッド群

<?php
declare(strict_types=1);

namespace Tests\Unit;

use App\Services\CalculatePointService;
use Tests\TestCase;

class TemplateMethodTest extends TestCase
{
    public static function setUpBeforeClass()
    {
        parent::setUpBeforeClass();

        echo __METHOD__, PHP_EOL;
    }

    protected function setUp()
    {
        parent::setUp();

        echo __METHOD__, PHP_EOL;
    }

    /**
     * @test
     */
    public function テストメソッド1()
    {
        echo __METHOD__, PHP_EOL;
        $this->assertTrue(true);
    }

    /**
     * @test
     */
    public function テストメソッド2()
    {
        echo __METHOD__, PHP_EOL;
        $this->assertTrue(true);
    }

    protected function tearDown()
    {
        parent::tearDown();

        echo __METHOD__, PHP_EOL;
    }

    public static function tearDownAfterClass()
    {
        parent::tearDownAfterClass();

        echo __METHOD__, PHP_EOL;
    }
}

上記のテストを実行すると以下の様な順序でテストが行われる事が確認できる

vagrant@homestead:~/larabook/chapter09$ ./vendor/bin/phpunit tests/Unit/TemplateMethodTest.php
PHPUnit 6.5.9 by Sebastian Bergmann and contributors.

Tests\Unit\TemplateMethodTest::setUpBeforeClass
.Tests\Unit\TemplateMethodTest::setUp
Tests\Unit\TemplateMethodTest::テストメソッド1
Tests\Unit\TemplateMethodTest::tearDown
.                                                                  2 / 2 (100%)Tests\Unit\TemplateMethodTest::setUp
Tests\Unit\TemplateMethodTest::テストメソッド2
Tests\Unit\TemplateMethodTest::tearDown
Tests\Unit\TemplateMethodTest::tearDownAfterClass


Time: 1.1 seconds, Memory: 10.00MB

OK (2 tests, 2 assertions)

これらのテンプレートメソッドを使う際はparentで継承元メソッドを呼ぶことを忘れないようにすること
parent::setUp()


9-1-7 テストの設定

Laravelのルートディレクトリにあうphpunit.xmlファイルで以下の設定項目の編集が可能

  • PHPUnitに関する設定の変更が可能
  • また、テストディレクトリやテスト対象ファイルの追加や変更を設定可能
  • テスト実行時にPHPの設定を変更する
  • 環境変数によってアプリケーション設定を変更する

詳細な設定に関しては PHPUnit の公式マニュアルを参照のこと