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

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

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

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

また、元となっている書籍はこちらとなります。

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

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

  • 9-2 データベーステスト
    • 9-2-1 テスト対象のテーブルとクラス
    • 9-2-2 データベーステストの基礎
    • 9-2-3 Eloquentクラスのテスト
    • 9-2-4 サービスクラスのテスト
    • 9-2-5 モックによるテスト(サービスクラス)

9-2 データベーステスト

データベースを利用したテストコードの実装

DBを利用するテストでは以下の様な手間がかかる作業が多い

  • テスト用DBの設定
  • テスト用レコードの登録
  • 対象クラスの処理後のレコード検証

本節はこれらを解説する


9-2-1 テスト対象のテーブルとクラス

本節で使うDBを確認する。あるアプリケーションの一部を見立てた会員のポイントを加算する処理を例に解説する

テーブル構成

9.2.1.1 テーブル構成を示すER図

f:id:sakamata:20190107172145p:plain

各tableの概要

テーブル名 概要
customers ユーザー情報
customer_points ユーザーの現在ポイントを収納
customer_point_events ポイント変化値とイベント名を収納

処理シナリオ

  • customer_point_events テーブルで加算イベント追加
  • customer_points テーブルが保持するポイントを加算
  • 1, 2, を同一トランザクションで実施
  • 処理失敗の際はロールバック

実装クラス

9.2.1.5 ポイント加算処理のクラス構成

f:id:sakamata:20190107171333p:plain

以下、各classのコードの記述があるが省略、詳しくは書籍、もしくはリンク先GitHubのコードを参照の事

app\Services\AddPointService.php
Serviceクラス : add メソッドで、複数のtableにtransaction付きでデータを書き込む複数メソッドの実行が記述されている

app\Eloquent\EloquentCustomerPointEvent.php
extends Model : customer_point_eventstableと関連付けし、registerメソッドで各カラム(cuntomer_id, event, point, created_at)にデータをsaveしている

app\Eloquent\EloquentCustomerPoint.php
extends Model : addPointメソッドでcustomer_pointテーブルの該当 customer_id のpointを更新している

app\Model\PointEvent.php
Model : 処理に必要な個別の項目の定義(DBカラム)と、呼び出し定義(getで始まるメソッド)記述のみをしている


9-2-2 データベーステストの基礎

テスト実行前にDBの状態を整える必要がある。テストに影響を与える環境を常に同じ状態に整えてからテストを実行することが重要。

テスト用データベースを利用

テスト用のDBを用意する 9.2.2.1 テスト用DBの作成例(MySQL

$ mysqladmin create app_test
# これでcreate database できるの知らんかった…

テスト時はこちらを使うようphpunit.xmlで設定する。

9.2.2.2 テスト用DBの設定(phpunit.xml 抜粋)

    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="QUEUE_DRIVER" value="sync"/>
        <!-- ここでテスト用データベースを指定 -->
        <env name="DB_DATABASE" value="app_test"/>
        <env name="MAIL_DRIVER" value="log"/>
    </php>


テスト用トレイトの利用

RefreshDatabaseトレイトについて
テスト実行時に自動でマイグレーションを行わせるには、RefreshDatabaseトレイトを使う。使用には以下の様に use させる

<?php
use Illuminate\Foundation\Testing\RefreshDatabase;

class EloquentCustomerPointEventTest extends TestCase
{
 use RefreshDatabase;  // 自動でマイグレーション実行
 // (省略)
}

現在のDBをテスト開始時に自動で migrate:refresh してくれる。
自動で(入れ子の)トランザクションがされるので、テスト時の内容は全てロールバックされ元に戻る

他にもDB制御するテスト用トレイトはある。詳細は書籍を見てください。


Factoryでテスト用レコードの準備

テストに必要なレコードを登録するにはFactoryが便利
利用は以下のコマンド make:factory を使用する
引数は生成するFactory名を指定
命名ルールは [Eloquentクラス名 + Factory] にすると良い。

9.2.2.4 make:factoryコマンドの実行例

$ php artisan make:factory EloquentCustomerFactory
Factory created successfully.

上記コマンドで以下にファイルが生成される
database\factories\EloquentCustomerFactory.php

9.2.2.5 生成されたEloquentCustomerFactory

<?php

use Faker\Generator as Faker;

$factory->define(Model::class, function (Faker $faker) {
    return [
        //
    ];
});

9.2.2.6 EloquentCustomer用に変更したCuntomerFactory

<?php
declare(strict_types=1);
// use で該当のEroquentクラスを呼ぶ
use App\Eloquent\EloquentCustomer;

// 5章.データベース 5-2-4で使用したFaker(ダミーデータライブラリ)を使用   
use Faker\Generator as Faker;

// define の第一引数に対象のEloquentクラス名を指定
$factory->define(EloquentCustomer::class, function (Faker $faker) {
    return [
        // Eroquantに設定するプロパティを連想配列で指定,Fakerでそれっぽい人名が入る
        'name' => $faker->name,
    ];
});

ちなみに use で呼ばれる該当のEroquentクラス App\Eloquent\EloquentCustomer;の中身はお馴染みの Model = table名でがっつり定義してるこれ

<?php
declare(strict_types=1);

namespace App\Eloquent;

use Illuminate\Database\Eloquent\Model;
/**
 * @property int $id
 * @property string $name
 */
final class EloquentCustomer extends Model
{
    protected $table = 'customers';
}

9.2.2.7 factory関数の利用例

<?php
// cuntomerテーブルに1レコード登録
factory(EloquentCustomer::class)->create();

// cuntomerテーブルに1レコード登録(nameを指定)
factory(EloquentCustomer::class)->create([
    'name' => '太郎',
]);

// cuntomerテーブルに3レコード登録
factory(EloquentCustomer::class, 3)->create();


データベースのアサーション

テスト中に変更したDBのレコード検証には、DBのアサーションメソッドを利用すると良い。
以下は実行例、使い方を見たまんまで書いてある

9.2.2.8 データベースのアサーションメソッド例

<?php
// customersテーブルにid=1のレコードが存在すれば成功
$this->assertDatabaseHas('customers', [
    'id' => 1,
]);

// customersテーブルにid=100のレコードが存在しなければ成功
$this->assertDatabaseHasMissing('customers', [
    'id' => 100,
]);

上記の様なレコードの有無で検証できないケース、例えばレコード数を測りたい場合はEloquentやクエリビルダを利用して検証する

9.2.2.9 クエリビルダを利用したアサーション

<?php
//customerテーブルに5件のレコードがあれば成功
$this->assertSame(5, \DB::table('customers')->count());


9-2-3 Eloquentクラスのテスト

では実際にDBのテストを行って行く
EloquentCustomerPointEventクラスのテストを行う。
app\Eloquent\EloquentCustomerPointEvent.php
(customer_point_eventstableと関連付けし、registerメソッドで各カラム(cuntomer_id, event, point, created_at)にデータをsaveしている)

テスト対象は registerメソッド
テストの際の事前条件と事後条件を想定する - 事前条件: customersテーブルに対象レコードがある。 - 事後条件: customer_point_eventsにレコードが追加されている。

以下は実装したテストクラス

9.2.3.1 EloquentCustomerPointEventTestクラス(書籍はEloquentCustomerPointTestとあり校正ミス)

<?php
declare(strict_types=1);

namespace Tests\Unit\AddPoint;

use App\Eloquent\EloquentCustomer;
use App\Eloquent\EloquentCustomerPointEvent;
use App\Model\PointEvent;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class EloquentCustomerPointEventTest extends TestCase
{
    use RefreshDatabase; // <---(1)

    /**
     * @test
     */
    public function register()
    {
        $customerId = 1;
        // (2) テストデータ登録
        // name入れてないのはP405 9.2.2.6 factory側でFakerで指定されてる為っぽい
        factory(EloquentCustomer::class)->create([
            'id' => $customerId,
        ]);
        // テスト対象メソッドの実行
        // コンストラクタの引数(DBカラム名)をそのままの順番で入れてる
        $event = new PointEvent(
            $customerId,
            '加算イベント',
            100,
            Carbon::create(2018, 8, 4, 12, 34, 56)
        );
        // test対象のクラスをnewしてDBに登録
        $eloquet = new EloquentCustomerPointEvent();
        $eloquet->register($event);

        // (4) データベースレコードのアサーション
        // 今登録したレコードがあるかを確認
        $this->assertDatabaseHas('customer_point_events', [
            'customer_id' => $customerId,
            'event'       => $event->getEvent(),
            'point'       => $event->getPoint(),
            'created_at'  => $event->getCreatedAt(),
        ]);
    }
}

本には書いてないがテストを実施してみる

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

.                                                                   1 / 1 (100%)

Time: 8.45 seconds, Memory: 16.00MB

OK (1 test, 1 assertion)

8秒以上かかった。初回はテスト用のDB作成からmigrateも走るので無理もないかも。


次にEloquentCustomerPointクラスのテストの実装

app\Eloquent\EloquentCustomerPoint.php
addPointメソッドでcustomer_pointテーブルの該当 customer_id のpointを更新している

addPointメソッドは下記の事前条件と事後条件を想定している

  • 事前条件1: customersテーブルに対象のレコードがある
  • 事前条件2: customer_pointテーブルに対象のレコードがある
  • 事後条件: customer_pointテーブルの対象レコードにpointが加算されている

これも一つ前のテストとほとんど同じ構成となっている。違いとしては addPointで元の 100ポイントから、10ポイント加算で110ポイントになっているかをテストしている。

9.2.3.2 EloquentCustomerPointTestクラス

<?php
declare(strict_types=1);

namespace Tests\Unit\AddPoint;

use App\Eloquent\EloquentCustomer;
use App\Eloquent\EloquentCustomerPoint;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class EloquentCustomerPointTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     */
    public function addPoint()
    {
        // (1) テストに必要なレコードを登録
        $customerId = 1;
       // 不要と思い込んでましたが、こっちのtableのidがPKなので必要ですね。
        factory(EloquentCustomer::class)->create([
            'id' => $customerId,
        ]);
       // ここは必須
        factory(EloquentCustomerPoint::class)->create([
            'customer_id' => $customerId,
            'point'       => 100,
        ]);

        // (2) テスト対象メソッドの実行
        $eloquent = new EloquentCustomerPoint();
        $result = $eloquent->addPoint($customerId, 10);

        // (3) テスト結果のアサーション
        $this->assertTrue($result);

        $this->assertDatabaseHas('customer_points', [
            'customer_id' => $customerId,
            'point'       => 110,
        ]);
    }
}

同様にテストを実施してみた。今度は2秒程度。テスト用のtableが出来た状態からのテストなので早かった。

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

.                                                                   1 / 1 (100%)

Time: 2.38 seconds, Memory: 16.00MB

OK (1 test, 2 assertions)

このようにDBを利用するEloquentクラスのテストもFactoryやデータベースアサーションを利用することで手軽なテストができる。


9-2-4 サービスクラスのテスト

AddPointServiceクラスのテスト方法について

app\Services\AddPointService.php
add メソッドで、複数のtableにtransaction付きでデータを書き込む複数メソッドの実行が記述されている

addメソッドの事前条件と事後条件の想定は以下の通り

  • 事前条件1: customersテーブルに対象レコードがある
  • 事前条件2: customer_pointテーブルに対象レコードがある
  • 事後条件1: customer_point_eventsテーブルに対象レコードがある
  • 事後条件2: customer_pointsテーブルの対象レコードにpointが加算されている

9.2.4.1 AddPointServiceTestクラス

<?php
declare(strict_types=1);

namespace Tests\Unit\AddPoint;

use App\Eloquent\EloquentCustomer;
use App\Eloquent\EloquentCustomerPoint;
use App\Model\PointEvent;
use App\Services\AddPointService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;

class AddPointServiceTest extends TestCase
{
    use RefreshDatabase;

    const CUSTOMER_ID = 1;

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

        // (1) テストに必要なレコードを登録
        factory(EloquentCustomer::class)->create([
            'id' => self::CUSTOMER_ID,
        ]);
        factory(EloquentCustomerPoint::class)->create([
            'customer_id' => self::CUSTOMER_ID,
            'point'       => 100,
        ]);
    }


    /**
     * @test
     * @throws \Throwable
     */
    public function add()
    {
        // (2) テスト対象メソッドの実行
        $event = new PointEvent(
            self::CUSTOMER_ID,
            '加算イベント',
            10,
            Carbon::create(2018, 8, 4, 12, 34, 56)
        );
        /** @var AddPointService $service */
        $service = app()->make(AddPointService::class);
        $service->add($event);

        // (3) テスト結果のアサーション
        $this->assertDatabaseHas('customer_point_events', [
            'customer_id' => self::CUSTOMER_ID,
            'event'       => $event->getEvent(),
            'point'       => $event->getPoint(),
            'created_at'  => $event->getCreatedAt(),
        ]);
        $this->assertDatabaseHas('customer_points', [
            'customer_id' => self::CUSTOMER_ID,
            'point'       => 110,
        ]);
    }
}

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

テストメソッド内に前提条件を書いても良いが、不明瞭になるので、setUpメソッドに事前条件を書くのが望ましい。

ここで、サービスクラスのインスタンス化をしてる?(この書き方良くわかってない)

<?php
/** @var AddPointService $service */
$service = app()->make(AddPointService::class);

テスト実行結果

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

.                                                                   1 / 1 (100%)

Time: 2.37 seconds, Memory: 16.00MB

OK (1 test, 2 assertions)


9-2-5 モックによるテスト(サービスクラス)

一つ前の 9-2-4 と別で、EloquentクラスをモックにしてDBにアクセスせずに、サービスクラスの処理のみをテストする方法もある。

無名クラスを使って、テスト対象のクラスが読み込む子供のクラスをその場で使い捨て利用する感じ。

9.5.5.1 モックを利用したAddPointSErviceクラスのテスト

<?php
declare(strict_types=1);

namespace Tests\Unit;

use App\Eloquent\EloquentCustomerPoint;
use App\Eloquent\EloquentCustomerPointEvent;
use App\Model\PointEvent;
use App\Services\AddPointService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;

class AddPointServiceWithMockTest extends TestCase
{
    use RefreshDatabase;

    private $customerPointEventMock;
    private $customerPointMock;

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

        // (1) Eloquentクラスのモック化
        $this->customerPointEventMock = new class extends EloquentCustomerPointEvent
        {
            /** @var PointEvent */
            public $pointEvent;

            public function register(PointEvent $event)
            {
                $this->pointEvent = $event;
            }
        };

        $this->customerPointMock = new class extends EloquentCustomerPoint
        {
            /** @var int */
            public $customerId;

            /** @var int */
            public $point;

            public function addPoint(int $customerId, int $point): bool
            {
                $this->customerId = $customerId;
                $this->point = $point;

                return true;
            }
        };
    }

    /**
     * @test
     * @throws \Throwable
     */
    public function add()
    {
        // (2) テスト対象メソッドの実行
        $customerId = 1;
        $event = new PointEvent(
            $customerId,
            '加算イベント',
            10,
            Carbon::create(2018, 8, 4, 12, 34, 56)
        );
        $service = new AddPointService(
            $this->customerPointEventMock,
            $this->customerPointMock
        );
        $service->add($event);

        // (3) テスト結果のアサーション
        $this->assertEquals($event, $this->customerPointEventMock->pointEvent);
        $this->assertSame($customerId, $this->customerPointMock->customerId);
        $this->assertSame(10, $this->customerPointMock->point);
    }
}

個人的な解釈
テスト対象のAddPointService.phpの一部はこうなっている

<?php
    public function __construct(
        // 2人の子供を起こして使える様に設計されているので...
        EloquentCustomerPointEvent $eloquentCustomerPointEvent,
        EloquentCustomerPoint $eloquentCustomerPoint
    ) {
        $this->eloquentCustomerPointEvent = $eloquentCustomerPointEvent;
        $this->eloquentCustomerPoint = $eloquentCustomerPoint;
        $this->db = $eloquentCustomerPointEvent->getConnection();
    }

テストコードは無名クラスとしてDBにアクセスするEloquentCustomerPointEventEloquentCustomerPointsetUp()メソッド内でnewして privateプロパティにしている。

AddPointServiceWithMockTest.php抜粋

<?php
private $customerPointEventMock;
private $customerPointMock;

protected function setUp()
{
        // 省略
    $this->customerPointEventMock = new class extends EloquentCustomerPointEvent
    {
        // 省略
    }

    $this->customerPointMock = new class extends EloquentCustomerPoint
    {
        // 省略
    }
}

これをadd()メソッド内で、AddPointServiceをnewさせる際に、コンストラクタインジェクションの引数に読み込んで利用している。
AddPointServiceWithMockTest.php抜粋

<?php
        $service = new AddPointService(
            $this->customerPointEventMock,
            $this->customerPointMock
        );

なんとか理解はできたが、自分で書いてサクサク利用できるようになるには、時間がかかりそう。