Edited at
LaravelDay 1

Laravel でテスト〜ユニットテスト編


この記事について

Laravel Advent Calendar 2018 - Qiita の 1日目 の記事です。

Laravel でテストシリーズのユニットテスト編です。

とりあげる内容は、ドメインロジックに対するテストをしたいケースで、どうやって単体テストを書くか、という基礎的な内容で、Laravel はあまり関係なくて、どちらかというと PHPUnit を使って単体テストを書くやり方、みたいなかんじです。


はじめに


概要

とあるサービスの料金計算をテストしたい、という動機があるとき、どうやってテストを書きやすい構成にし、テストしたい部分を局所的にテストできるようにするか、というのを考えてみます。


環境


  • PHP: 7.1.19

  • Laravel: 5.7.15

  • PHPUnit: 7.4.4


要求と仕様


シナリオ

「ユーザーはサービスの予約をする」

入力には、サービス種別、サービス利用開始日および終了日があり、その間の日数が 7 日を超えると 10% のディスカウント(1円未満の端数が出た場合は切り捨て)が発生するとします。

ケース1. 利用期間は 7 日で、ディスカウントあり

ケース2. 利用期間は 6 日で、ディスカウントなし

基本料金はサービス種別によって固定です。


データベース

単体テストなのでデータベースは関係ないんですが、最後の方にちょろっと Eloquent の話を書くのでその際に使います。

テーブル名: reservations

論理名
物理名

説明

サービス種別
service_type
smallint

利用開始日
start_on
date

利用終了日
finish_on
date

基本利用料
base_charge
int

割引
discount
int


テスト対象


  1. 割引の条件を満たすかどうかを判別するクラス

  2. 割引計算をするクラス


実装


1. 割引の条件を満たすかどうかを判別するクラス

まず、入力は2つの日付がやってくるわけですが、ここではひとつのクラスにまとめてしまいます。

PeriodOfUse とします。

value() というメソッドが日数を返します。

こいつのテストもついでに書いちゃいましょう。

<?php

namespace Tests\Unit;

use App\Models\PeriodOfUse;
use Carbon\Carbon;
use Tests\TestCase;

class PeriodOfUseTest extends TestCase
{
public function testValue()
{
$period = new PeriodOfUse(Carbon::today(), Carbon::tomorrow());
$this->assertSame(1, $period->value());
}
}

このくらいであれば、別に「レッド→グリーン」方式でやる必要はないのでサクッと実装しちゃいます。

<?php

namespace App\Models;

use Carbon\Carbon;

class PeriodOfUse
{
private $startOn;
private $finishOn;

public function __construct(Carbon $startOn, Carbon $finishOn)
{
$this->startOn = $startOn;
$this->finishOn = $finishOn;
}

public function value(): int
{
return $this->finishOn->diffInDays($this->startOn);
}
}

実行。

$ ./vendor/bin/phpunit tests/Unit/PeriodOfUseTest.php

PHPUnit 7.4.4 by Sebastian Bergmann and contributors.

. 1 / 1 (100%)

Time: 255 ms, Memory: 10.00MB

OK (1 test, 1 assertion)

で、「7日以上なら」という条件を表現をする、Specification クラスのテストをつくります。

Specification については PHPで学ぶデザインパターン Advent Calendar 2018 - Qiita の2日目の記事で解説します。

Specification パターン - Qiita

Specification クラスは、 satisfied() という論理値を返すメソッドを唯一の公開メソッドとして持ち、引数が条件を満たすかどうかを判定します。

テストはこんなかんじになるでしょう。

<?php

namespace Tests\Unit;

use App\Models\PeriodOfUse;
use App\Models\Specifications\DiscountSpecification;
use Carbon\Carbon;
use Tests\TestCase;

class DiscountSpecificationTest extends TestCase
{
public function testSatisfied()
{
$period = new PeriodOfUse(Carbon::today(), Carbon::today()->addDays(7));
$specification = new DiscountSpecification();

$this->assertTrue($specification->satisfied($period));
}
}

これもすぐに実装しちゃいます。

<?php

namespace App\Models\Specifications;

use App\Models\PeriodOfUse;

class DiscountSpecification
{
public function satisfied(PeriodOfUse $period): bool
{
return $period->value() >= 7;
}
}

で、「7日未満なら」 false を返すケースも書きましょう。

毎度おなじみ dataProvider を使って書き直します。

<?php

namespace Tests\Unit;

use App\Models\PeriodOfUse;
use App\Models\Specifications\DiscountSpecification;
use Carbon\Carbon;
use Tests\TestCase;

class DiscountSpecificationTest extends TestCase
{
/**
* @param int $days
* @param bool $expected
* @dataProvider dataSatisfied
*/

public function testSatisfied(int $days, bool $expected)
{
$period = new PeriodOfUse(Carbon::today(), Carbon::today()->addDays($days));
$specification = new DiscountSpecification();

$this->assertSame($expected, $specification->satisfied($period));
}

public function dataSatisfied()
{
return [
'条件を満たすケース' => [
'days' => 7,
'expected' => true,
],
'条件を満たさないケース' => [
'days' => 6,
'expected' => false,
],
];
}
}


2. 割引計算をするクラス

続いて、割引条件を満たした場合に割引計算を行うクラスをテストします。

基本料金と割引額が利用期間によって 10% になるケースと 0 になるケースをテストします、10%になるケースは1円未満の端数があるケースとないケースもやっておきましょう。

テストコード。

<?php

namespace Tests\Unit;

use App\Models\PeriodOfUse;
use App\Models\Services\Calculators\DiscountCalculator;
use Carbon\Carbon;
use Tests\TestCase;

class DiscountCalculatorTest extends TestCase
{
/**
* @param int $baseCharge
* @param int $days
* @param int $expected
* @dataProvider dataRun
*/

public function testRun(int $baseCharge, int $days, int $expected)
{
$period = new PeriodOfUse(Carbon::today(), Carbon::today()->addDays($days));
$calculator = new DiscountCalculator();
$discount = $calculator->run($period, $baseCharge);

$this->assertSame($expected, $discount);
}

public function dataRun()
{
return [
'値引きあり,端数なし' => [
'baseCharge' => 5000,
'days' => 7,
'expected' => 500,
],
'値引きあり,端数あり' => [
'baseCharge' => 4999,
'days' => 7,
'expected' => 499,
],
'値引きなし' => [
'baseCharge' => 5000,
'days' => 6,
'expected' => 0,
],
];
}
}

プロダクションコード。

<?php

namespace App\Models\Services\Calculators;

use App\Models\PeriodOfUse;
use App\Models\Specifications\DiscountSpecification;

class DiscountCalculator
{
public function run(PeriodOfUse $period, int $baseCharge): int
{
$specification = new DiscountSpecification();
if (!$specification->satisfied($period)) {
return 0;
}
return (int)floor($baseCharge / 10);
}
}

上の例では DiscountSpecificationrun メソッド内で直接生成されているので、コンストラクタで受け取るようにして、今後値引き判定ルールが動的に差し替わることになってもいいようにしておく、というのも必要かもしれません。

2018-12-03 11:10 追記

上の例、実は「ユニット」テストになってなくて、 DiscountCalculatorDiscountSpecification を内包していることで、テストがそれに依存してしまっています(ルールが変わるとテストも変えなくてはいけなくなっている)。

そこで、 DiscountSpecification をコンストラクタで渡すようにして、実装に依存しないようにリファクタリングしてみます、というのを Laravel #2 Advent Calendar 2018 - Qiita の24日目の記事として書く予定です。更新したらリンクを張ります。

追記ここまで


おまけ

Eloquent なモデルにドメインモデルを渡して複数のプロパティの整合性を取るやり方をご紹介します。

今回、基本料金と割引額は整合性を維持する必要があるので、 base_chargediscount を直接セットすることができないようにしたいです。

<?php

namespace Tests\Unit;

use App\Models\Charges;
use App\Models\Reservation;
use Tests\TestCase;

class ReservationTest extends TestCase
{
/**
* 直接セットできない(fill を呼ぶ場合も同様)
*/

public function testConstructNotAllowed()
{
$reservation = new Reservation([
'base_charge' => 5000,
'discount' => 0,
]);
// null にセットするんじゃなくてコンストラクタで例外投げるようにした方がベターかも知れない
$this->assertNull($reservation->base_charge);
$this->assertNull($reservation->discount);
}

/**
* ドメインオブジェクト経由でならセットできる
*/

public function testConstructAllowed()
{
$reservation = new Reservation([
'charges' => new Charges(5000, 0),
]);
$this->assertSame(5000, $reservation->base_charge);
$this->assertSame(0, $reservation->discount);
}

/**
* 直接プロパティを書き換えることはできない
*
* @param string $key
* @param mixed $value
* @expectedException \BadMethodCallException
* @dataProvider dataSetAttributeNotAllowed
*/

public function testSetAttributeNotAllowed(string $key, $value)
{
$reservation = new Reservation();

$reservation->$key = $value;
}

public function dataSetAttributeNotAllowed()
{
return [
'base_charge' => [
'key' => 'base_charge',
'value' => 5000,
],
'discount' => [
'key' => 'discount',
'value' => 0,
],
];
}
}

プロダクションコードはこんなかんじ。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

/**
* Class Reservation
* @package App\Models
*
* @property Charges $charges
*/

class Reservation extends Model
{
protected $guarded = [
'id',
'base_charge',
'discount',
];

public function setChargesAttribute(Charges $charges)
{
$this->attributes['base_charge'] = $charges->base();
$this->attributes['discount'] = $charges->discount();
}

public function setBaseChargeAttribute(int $value)
{
throw new \BadMethodCallException('dot not call this method directly.');
}

public function setDiscountAttribute(int $value)
{
throw new \BadMethodCallException('dot not call this method directly.');
}
}

直接セットしたくないプロパティを $guarded に入れ、setAttribute が呼ばれないよう、例外を投げるだけのメソッドを用意します。

代わりに、ドメインオブジェクト(ここでは Charges を受け取って、2つのプロパティを同時に変更するミューテータを用意します。

もうちょっとコンフィギャラブルにやりたい場合は、以下のように別途プロパティを設けて、そちらに書くのもありかと思います。

<?php

protected $readonly = [
'base_charge',
'discount',
];

public function setAttribute($key, $value)
{
if (in_array($key, $this->readonly)) {
throw new \BadMethodCallException('dot not call this method directly.');
}
return parent::setAttribute($key, $value);
}


おわりに

Laravel アドベントカレンダー1日目、のっけからあまり Laravel 関係ない感じになってしまい、出鼻を挫くようなことにならなければいいな、とドキドキしています。

最後にちょろっと Eloquent モデルにおける複数プロパティ間の整合性を保持するやり方について書いたのでそれで勘弁していただければと思います。

明日は @sh-ogawa さんの「SQSキューワーカーがメモリリークする件について」です。

パート2 もありますので、そちらもぜひ。

Laravelアドベントカレンダー、書く方々も読む方々もみなさん楽しんでください!