LaravelのDIでファット(太った)コントローラを撲滅しよう

2021-03-11
4 min read

コントローラクラスで DI していますが? DI をしていなければ、コントローラクラスのメソッドが極端に長くなり見通しが悪くなっていないでしょうか? いわゆるファット(太った)コントローラと言われる状態です。

DI を利用することで、コントローラをスリムにできますよ。

具体例

初期画面でキャンペーンメッセージを表示するとします。 対象は、都心に住んでいるユーザーです。 ただし、すでにキャンペーンに応募している場合は、メッセージを出しません。

このロジックをコントローラ以外に実装してテストをしてみます。

ここでは、App\Servicesの下にCampaignServiceというクラスを作ります。


<?php

namespace App\Services;

use App\Models\User;
use App\Models\UserDetail;

class CampaignService
{

    public const MSG_BODY = 'body';
    public const MSG_LINK = 'link';
    public const CAMPAIGN_ID = "1";
    private $userDetail;

    public function getCampaignMassage(User $user): array
    {
        $this->userDetail = $user->detail;

        if (!$this->isInCentralTokyo()) {
            return [];
        }

        if ($this->hasEntered()) {
            return  [];
        }

        return [ 0 => $this->createMsg("キャンペーン応募", "camp") ];
    }
    private function isInCentralTokyo()
    {
        $target = collect([
            ['name' => '千代田区 131024', 'code' => '131016'],
            ['name' => '中央区', 'code' => '131024'],
            ['name' => '港区', 'code' => '131032']
        ]);

        if ($target->contains('code', $this->userDetail->municipality_code)) {
            return true;
        }

        return false;
    }

    private function hasEntered()
    {
        if (count($this->userDetail->enteredCampaign) === 0) {
            return false;
        }
        return true;
    }
    protected function createMsg(string $msg, string $routeName)
    {
        return  [self::MSG_BODY => $msg, self::MSG_LINK  => $routeName] ;
    }
}
   

getCampaignMassageメソッドは、ログインユーザーがキャンペーンメッセージの対象者であればメッセージを返します。 このメソッドの中で呼ばれるisInCentralTokyohasEnteredの 2 つのメソッドは、このクラスの private メソッドとして定義しています。

コントローラからビジネスロジッククラスをどのように利用するか?

Laravel の DI を利用すると、下記のように記述できます。


class HomeController extends Controller
{
    public function index(CampaignService $camp)
    {
        $massages = $camp->getCampaignMassage(Auth::user());

        return view('welcome', compact('massages'));
    }

ポイントは、CampaignService のインスタンスを引数として受け取るところです。 Laravel では、メソッドの引数にタイプヒントを付与することで、インスタンス生成を Laravel に任せることができます。 しかも、特別な設定は不要です。ただメソッドの引数にタイプヒントを付与するだけです。

DI を利用しないとすると、メソッド内にてインスタンスを生成することになります。

    public function index()
    {
        $camp = CampaignService();
        $massages = $camp->getCampaignMassage(Auth::user());
    }

テスト時のコード

DI を利用して CampaignService のインスタンスを生成している場合、テストのときにテスト用の CampaignService クラスに置き換えることができます。

    public function test_example()
    {
        $stub = Mockery::mock(CampaignService::class);

        $stub->shouldReceive('getCampaignMassage')
             ->once()
             ->andReturn([ 0 => [CampaignService::MSG_BODY => "キャンペーン応募", CampaignService::MSG_LINK  => "camp"] ]);

        $this->instance(CampaignService::class, $stub);
        
        $user = User::factory()->create();
        //actingAsで認証
        $response = $this->actingAs($user)->get(route('home'));

        $response->assertStatus(200);

        $response->assertSeeText("キャンペーン応募");
    }

Mockery にて、CampaignService のスタブを作り、getCampaignMassage メソッドの戻り値を指定します。 $this->instance がポイントです。 CampaignService::classという名前にて、Mockery で作ったスタブをサービスコンテナに登録しています。 これにより、サービスコンテナに問い合わせがあった場合、本来の CampaignService クラスではなくスタブを返すことになります。

データモデルや CampaignService クラスの詳細な仕様を理解していなくとも、CampaignService クラスの getCampaignMassage メソッドの戻り値だけを知っていれば、簡単にテストを行うことができます。

ロジッククラスのテスト

ロジックを記述したクラスのテストは、ユニットテストを行うことが d けいます。 いくつかのテストケースが想定できますが、テストコードのイメージをつかんでもらうためですので、2 つだけ実装してみます。

class CampaignServiceTest extends TestCase
{
    use RefreshDatabase;

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

    /**
     * 
     * @test
     * @return void
     */
    public function test_都心住まい_キャンペーンエントリー済み()
    {

        $user = User::factory()->create();
        $detail = UserDetail::factory()->make(['municipality_code' => '131016']);
        $user->detail()->save($detail);
        $enteredCampaign = EnteredCampaign::factory()->make();

        $detail->enteredCampaign()->save($enteredCampaign);

        $camp = new CampaignService();
        $this->assertCount(0, $camp->getCampaignMassage($user));
    }
    /**
     * 
     * @test
     * @return void
     */
    public function test_都心住まい_キャンペーンエントリーなし()
    {

        $user = User::factory()->create();
        $detail = UserDetail::factory()->make(['municipality_code' => '131016']);
        $user->detail()->save($detail);

        $camp = new CampaignService();
        $this->assertCount(1, $camp->getCampaignMassage($user));
    }
}

RefreshDatabase を利用したテストケースの実装になります。 データベースが絡むとテストデータを用意するのが手間だと思ってしまうのですが、factory を利用すれば簡単に用意できます。 そして、アサートが楽です。機能テストだと VIEW を通してアサートするか、レスポンスから値を取得してアサートすることになり面倒です。

DI を実践で使う最も簡単な方法を紹介しました。 Laravel の DI を紹介する記事は、ほとんどがサービス・プロバイダへの登録が必須のように記載されていますが、実際はサービス・プロバイダへ登録せずとも DI できます。

また、コントローラにロジックがあると、ロジックをテストするのに機能テストが必要になってきます。しかし、ロジックを外出しすることで機能テストではなくユニットテストで対応できるため便利です。 そして、ロジックに追加や修正があればロジッククラスを修正するだけでコントローラクラスに手を入れる必要がありません。