Loggerクラスをテストのたびにmockを用意しなくて済むための工夫

この記事は、Symfonyアドベントカレンダー2015の19日目の記事です。でも今日は20日ですね。(ごめんなさい。。忘れてました。。

18日はnaoyesさんのSymfonyプロジェクトのテストにCodeceptionを使ってみるでした。

今日は11/24 に開催された PHP BLT #1 でLTしてきた内容ですが、まだ発表内容をまとめていなかったので書きます。

Symfony では yaml でサービス構成の定義ができる

Symfony で 何かサービスを作るとき、必要な外部サービスを次のようにyamlで定義することができ、DI コンポーネントによって hoge_manager が用意されます。

    hoge_manager:
        class: HogeBundle\Service\HogeManager
        arguments:
            - @doctrine.orm.entity_manager
            - @report
            - @slack_notification
            - @loger

そして、HogeManager クラスはargumentsで定義した何かをコンストラクタで受け取るようにすればいいわけです。

<?php
/**
 * Hoge Manager
 */
class HogeManager
{
    public function __construct($em, $reporter, $slack, $logger)
    {
        $this->em = $em;
        $this->reporter = $reporter;
        $this->slack = $slack;
	$this->logger = $logger;
    }

コントローラーからは InjectParams が便利

また、このように名前をつけた何かは、コントロラーからなどは `InjectParams` を使えばコンストラクタなどで利用することもできます。

<?php

namespace HogeBundle\Controller\Hoge;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use JMS\DiExtraBundle\Annotation as DI;

/** @Route("/hoge") */
class HogeController extends Controller
{
    private $hoge;

    /**
     * @DI\InjectParams({
     *     "hoge" = @DI\Inject("hoge_manager")
     * })
     */
    public function __construct($hoge)
    {
        $this->hoge = $hoge;
    }

外部の何かを利用したクラスのテストはmockが活躍

さて、このHogeManager クラスのテストを書いてみましょう。このマネージャのテストを書きたいので、
外部の何かはmockにすることになります。Phakeを使うとこんな感じでしょうか

<?php
namespace HogeBundle\Tests\Service;

use HogeBundle\Service\HogeManager;

class HogeManagerTest extends \PHPUnit_Framework_TestCase
{
    /**
     * setup
     */
    public function setUp()
    {
        $this->logger = \Phake::mock('Psr\Log\LoggerInterface');
    	...

ここで `$this->logger` を毎回用意しないといけなくなります。
もはや mock を毎回書かなくてもテスト書けるように慣れると幸せですよね。というわけで Loggable Trait を作ってみます。

Loggable Trait

<?php
namespace Hoge\HelperBundle\Logger;

use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Log\NullLogger;

trait Loggable
{
    /** @var LoggerInterface */
    private $logger;

    public function setLogger($logger)
    {
        $this->logger = $logger;
    }

    protected function logger()
    {
        if (is_null($this->logger)) {
            $this->logger = new NullLogger();
        }

        return $this->logger;
    }
}

コードはたったこれだけです。
loggerのsetterを用意しているのと、getterではsetされていなければNullLoggerを返しています。
なので、setされていなくてもエラーを履かずに動きます。

なので、yaml定義を以下のようにします。

    hoge_manager:
        class: HogeBundle\Service\HogeManager
        arguments:
            - @doctrine.orm.entity_manager
            - @report
            - @slack_notification
            - @loger
		calls:
			- [setLogger, ["@logger"]]

コンストラクタではなくsetLoggerをコールするように指定します。
HogeManager は Trait を使うので以下のようなコードに修正します

<?php
/**
 * Hoge Manager
 */
class HogeManager
{
use Loggable;
    public function __construct($em, $reporter, $slack)
    {
        $this->em = $em;
        $this->reporter = $reporter;
        $this->slack = $slack;
    }

コンストラクタでloggerを渡さなくてもよくなりましたし、logger関連のメソッドは全部Traitにあるのでuse Loggable するだけです。
これで、テストもmock作らなくてもNullLoggerが勝手に作成されるのでテストもすっきりします。

コンストラクタで渡す何かが増えすぎたら設計を考えなおす時期かもしれない

あと、このLoggableを使うとわかることは、そのサービス全体が依存しているものはコンストラクタで渡すのは自然で良いのですが、
サービスの一部だけが依存しているようなものまでもコンストラクタで渡すようにするとテストが辛いものになりやすくなります。

サービスの一部が利用するような外部の何かは、その一部で利用するメソッドで渡すなど別の手段を考えてみたほうが良いです。
Symfony では簡単に依存関係をコンストラクタで渡せるのでついついなんでも書いてしまいがちですが、
コンストラクタに大量に何か渡すような設計になってきたら、本当にそれはコンストラクタで渡すべきなのか、またはクラスの仕事が多すぎるなど設計を再度考える時期なのかもしれませんね。

というわけで、20日はimunewさんで「Doctrine DBALで取得したデータをキャッシュする」です!(既に公開されていますね!すばらしい!いや、ほんとごめんなさい。。
DBの問い合わせがWebアプリケーションで一番重くなることはよくあるので、キャッシュ大事ですよね!