Edited at

Laravel でテスト〜リファクタリング編


この記事について

Laravel #2 Advent Calendar 2018 - Qiitaの 24 日目の記事です。

リファクタリング編といいつつ、ほとんどリファクタリングしておらず「モックを使った依存関係の差し替え」的な内容になってしまっていますが、クリスマスイブなのでご容赦ください。


はじめに


概要

こちらの記事で、ユニットテストといいながらユニットテストになっていない箇所があったので、ユニットテストになるように変更していきます。

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


環境


  • Laravel: 5.7.15

  • PHP: 7.1.19

  • PHPUnit: 7.4.4


実装


依存オブジェクトをコンストラクタで受け取るように変更

念のため、元のコードを載せておきます。

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

DiscountSpecification を new してローカル変数に代入する代わりに、コンストラクタでインスタンスを受け取ってプロパティに入れるようにします。

<?php

namespace App\Models\Services\Calculators;

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

class DiscountCalculator
{
private $specification;

public function __construct(DiscountSpecification $specification)
{
$this->specification = $specification;
}

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

Specification は interface にしてもいいんですが、今回は具象クラスはひとつしかないのでそのままにしておきます。


依存オブジェクトをモック化するいくつかの方法

続いて DiscountCalculatorTest::testRun() の修正です。 $specification を生成しているところを以下のように変更します。

// Mockery

$specification = Mockery::mock(Context::class)
->shouldReceive('satisfied')
->once()
->andReturn($satisfied)
->getMock();

// or

// PHPUnit MockObject
$specification = $this->createConfiguredMock(
Context::class,
['satisfied' => $satisfied]
);

$calculator = new DiscountCalculator($specification);
$discount = $calculator->run($period, $baseCharge);

Laravel にはあらかじめ Mockery が組み込まれているので、 Mockery::mock() でもいいですし、PHPUnit 組み込みのモッククラスを使う $this->createMock()$this->createConfiguredMock() を呼ぶこともできます。

さらに、Mockery や MockObject の代わりに PHP7 から導入された無名クラスを使うこともできます。


$specification = new class extends DiscountSpecification {
public $satisfied;
public function satisfied(): bool {
return $this->satisfied;
}
};
$specification->satisfied = $satisfied;

せっかく同梱されてるので、Mockery でいいとは思いますが、無名クラスを使ったやり方も知っておいて損はないと思います。

Specification::satisfied の振る舞いは2種類しかないので(真か偽か)、それらをテストパターンとして DataProvider から渡してやります。

- public function testRun(int $baseCharge, int $days, int $expected)

+ public function testRun(int $baseCharge, bool $satisfied, int $expected)

日数はもはや DiscountCalculator の依存ではなくなったので、適当に入れちゃってください(これを機に Specification のコンストラクタへ渡すように変更してもいいかもしれません)。

これで DiscountCalculator のみをテストする、ちゃんとしたユニットテストになりました。


余談

DI コンテナを使ったケースでは、以下のように書くこともできます。


DiscountCalculatorTest.php

$specification = Mockery::mock(DiscountSpecification::class)

->shouldReceive('satisfied')
->once()
->andReturn($satisfied)
->getMock();

$this->app->bind(DiscountSpecification::class, function () use ($specification) {
return $specification;
});

$this->app->bind('calculator', DiscountCalculator::class);
$calculator = app('calculator');

$discount = $calculator->run($period, $baseCharge);
$this->assertSame($expected, $discount);


プロダクションコード内で DI コンテナを使っていなくても、テスト時にテスト対象とモック対象を両方ともコンテナを利用して初期化すると、依存オブジェクト(上の例では DiscountSpecification)を自動的にモックで差し替えてくれるので便利です。


最終的なテストコード

日数は依存から取り除かれたので、テストパターンからも除外します。


DiscountCalculatorTest.php

<?php

namespace Tests\Unit;

use App\Models\PeriodOfUse;
use App\Models\Services\Calculators\DiscountCalculator;
use App\Models\Specifications\DiscountSpecification;
use App\Models\Specifications\Specification;
use Carbon\Carbon;
use Tests\TestCase;
use Mockery;

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

public function testRun(int $baseCharge, bool $satisfied, int $expected)
{
$period = new PeriodOfUse(Carbon::today(), Carbon::today()->addDays(1));
$specification = Mockery::mock(DiscountSpecification::class)
->shouldReceive('satisfied')
->once()
->andReturn($satisfied)
->getMock();

$calculator = new DiscountCalculator($specification);
$discount = $calculator->run($period, $baseCharge);

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

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



おわりに

モック化の手段は色々ありますが、基本は Mockery を使うのがおすすめで、場合によっては無名クラスを使う方が楽なケースもあるかもしれません(といいつつ、パッと思い浮かばないので、アイデアある方はコメントいただけると助かります :bow:)。

Happy testing!

明日は最終日、 @jiyuujin さんの「LARAVELで〆、バリデーションとページ管理」です、お楽しみに!