Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

この記事について

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で〆、バリデーションとページ管理」です、お楽しみに!

nunulk
PHP, Laravel, オブジェクト指向プログラミング, デザインパターン, リファクタリング, 関数プログラミング, etc.
http://nunulk.hatenablog.com
phper-oop
ペチオブはオブジェクト指向ワーキンググループです。様々なエンジニアの方に参加頂いております。
https://phper-oop.connpass.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away