プログラミング - THIS IS IT !

より良い開発をすべく日々奮闘しているプログラマーのブログです。設計に興味があります。主にPHPネタを書いてます 日本Symfonyユーザー会

SymfonyのDIを利用してMail送信クラスをインテグレーションテストする:コード編

【追記】

新しく書いたこちらの記事を参考にしたほうが良いです。 Symfonyでメールのインテグレーションテストを行う - プログラミング - THIS IS IT !

追記終わり

この記事はSymfony Advent Calender 2014 8日目の記事になります。

昨日は ohsawa0515さんの「StofDoctrineExtensionsBundle について使ったり調べてみた」でした。

今年のSymfony Advent Calender 2014は盛り上がってますね。既に全枠埋まっています。 一昨年、枠が埋まらず何周も記事を書いてた方がたくさんいらっしゃった事を考えると感動ものです(笑)

追記

この記事の内容より新しく書いた以下の記事を参考にすることをおすすめします。

okapon-pon.hatenablog.com

追記終わり


さて本題

SymfonyのDIを利用してMail送信クラスをインテグレーションテストする

前置き

書いていたらあまりに超大作になってしまったので前編を作りました。興味がある方は読んでみてください。

SymfonyDIを利用してMail送信クラスをインテグレーションテストする:前置き編 - プログラミング - THIS IS IT !

本稿で取り上げる内容

サンプルコード

サンプルコードはgithubにアップしています。

GitHub - okapon/mail-test-sample: Symfony2でメール送信クラスをテストするコードサンプル

以下の環境で動作確認をしています。

Symfony 2.6.1
php 5.5.18
phpunit 3.7.38

github からチェックアウトして下記コマンドを実行することでテストを実行できます。 といっても実行されるテストは1つだけですが。

$ composer install
$ ./vendor/phpunit/phpunit/phpunit.php -c app

コードの解説

プロダクトコード

まずプロダクトコードですが、Symfonyの公式ドキュメント「サービスコンテナ」の章で、NewsletterManager サービスの作り方が紹介されているので、これに習ってMailService作っている方が多いのではないでしょうか。

サービスコンテナ | Symfony2日本語ドキュメント

これと似たコードですが、今回は以下のようなプロダクトコードを考えてみます。ユーザー登録を行った際に送信される、メールの持ち主確認を行うアクティベーションメールを送るものを想定してます。

<?php

namespace AppBundle\Service;

use AppBundle\Entity\User;

class MailService
{
    protected $mailer;
    protected $twig;

    /**
     * Constructor
     *
     * @param \Swift_Mailer $mailer
     * @param \Twig_Environment $twig
     */
    public function __construct(\Swift_Mailer $mailer, \Twig_Environment $twig)
    {
        $this->mailer = $mailer;
        $this->twig = $twig;
    }

    public function sendRegistrationMail(User $user)
    {
        // 認証URLを作成する、ここではサンプルコードなので固定で記述しています
        $activationUrl = 'http://example.com/activation?code=abcdefg';

        $body = $this->render('AppBundle:Mail:register.txt.twig', [
            'user' => $user,
            'activationUrl' => $activationUrl,
        ]);
        $subject = '[xxx サービス] ご登録ありがとうございます';
        $fromEmail = 'info@example.com';
        $userEmail = $user->getEmail();

        $message = \Swift_Message::newInstance()
            ->setFrom($fromEmail)
            ->setTo($userEmail)
            ->setSubject($subject)
            ->setBody($body)
        ;

        return $this->sendMessage($message);
    }

    /**
     * sendMessage
     *
     * @param \Swift_Message $message
     * @return void
     */
    protected function sendMessage(\Swift_Message $message)
    {
        $this->mailer->send($message);
    }

    /**
     * render
     *
     * @param string $template
     * @param array $vars
     * @return string
     */
    protected function render($template, array $vars = [])
    {
        return $this->twig->loadTemplate($template)->render($vars);
    }
}

このコードに対するテストコードを書いていこうと思いますが、ここで問題となるのがtestモードでテストを実行した場合にはSwiftMailerは、デフォルトではメール送信処理を行いません。

【参考】 開発中におけるメール送信の扱い方#メール送信を無効にする | Symfony2日本語ドキュメント

かといってテストコードを実行するたびに外部にメールを送信してしまうのはよい作法とはいえまません。 実際にメール送信は行いたくはない、けれどもメールの確認はしたい。

この問題を解決するためのヒントはSymfonyのドキュメントにあります。

英語のドキュメントでまだ日本語に翻訳されていませんが、ファンクショナルテスト中でメール送信を確認する方法が紹介されています。

How to Test that an Email is Sent in a functional Test(英語)

How to Test that an Email is Sent in a Functional Test (Symfony Docs)

しかしメールのテストをするために、$client = static::createClient();して、コントローラーのテストまでしないといけないというのは少し面倒です。

そこで今回の記事の本題である、SymfonyDIを使って少し工夫することで、上記のようなメールのテストを行う方法を紹介したいと思います。

テストコード

1 パラメーターを使ってMailServiceクラスを指定するようにします。

※サービスコンテナのコンフィギュレーションはymlで記述するものとします。

service.yml

parameters:
    app.mail_service.class: AppBundle\Service\MailService

services:
    app.mail_service:
        class: %app.mail_service.class%
        arguments:
            - @mailer
            - @twig

2 テストコードだけで読み込まれるservice_test.ymlを用意して、上記のMailServiceを置き換える

service_test.yml

parameters:
    app.mail_service.class: AppBundle\Tests\Mock\Service\TestMailService

3 service_test.ymlを読み込む

<?php

namespace AppBundle\DependencyInjection;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\DependencyInjection\Loader;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;

/**
 * This is the class that loads and manages your bundle configuration
 *
 * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html}
 */
class AppExtension extends Extension
{
    /**
     * {@inheritdoc}
     */
    public function load(array $configs, ContainerBuilder $container)
    {
        $configuration = new Configuration();
        $config = $this->processConfiguration($configuration, $configs);

        $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
        $loader->load('services.yml');

        // test環境だけファイルを読み込む
        $env = $container->getParameter("kernel.environment");
        if ($env === 'test') {
            $loader->load('services_test.yml');
        }
    }
}

4 置き換えたクラスでメールの確認ができるように拡張する

<?php

namespace AppBundle\Tests\Mock\Service;

use AppBundle\Service\MailService;
/**
 * TestMailService.
 *
 * 送信メールの内容をテストするため、Swift_Messageを内部に保持し
 * 後から取りだしすことをできるようにしたテスト用のクラス
 *
 */
class TestMailService extends MailService
{
    /**
     * @var Swift_Message[]
     */
    protected $messages = [];

    /**
     * {@inheritDoc}
     */
    public function sendMessage(\Swift_Message $message)
    {
        $this->messages[] = $message;  // ここがポイント

        return parent::sendMessage($message);
    }

    /**
     * @return Swift_Message[]
     */
    public function getMessages()
    {
        return $this->messages;
    }
}

ポイントはsendMessage()をオーバーライドしてクラスの内部変数にSwift_messageインスタンスを保持していることです。 また、そのインスタンスを取得できるようgetMessagesで取得してます。

テストコードは以下のようになりました。

<?php
namespace AppBundle\Service;

use Phake;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use AppBundle\Entity\User;
class MailServiceIntegrationTest extends WebTestCase
{
    protected $mailService;
    public function setUp()
    {
        $kernel = self::createKernel();
        $kernel->boot();
        $container = $kernel->getContainer();
        $this->mailService = $container->get('app.mail_service');
    }

    /**
     * @test
     */
    public function sendRegistrationMail()
    {
        $fromEmail = 'info@example.com';
        $userEmail = 'user.mail@example.com';
        $userName = '田中 太郎';
        $activationUrl = 'http://example.com/activation?code=abcdefg';
        $user = Phake::mock(User::class);
        Phake::when($user)->getUsername()->thenReturn($userName);
        Phake::when($user)->getEmail()->thenReturn($userEmail);

        // test (メール送信)
        $this->mailService->sendRegistrationMail($user);

        // 送信されたメッセージのインスタンスを取得
        $messages = $this->mailService->getMessages();
        $this->assertCount(1, $messages);

        // 1通目だけテスト
        $message = $messages[0];
        $this->assertInstanceOf('Swift_Message', $message);

        $expected = [
            'from' => $fromEmail,
            'to' => $userEmail,
            'subject' => '[xxx サービス] ご登録ありがとうございます',
            'body' => [
                $userName,
                $activationUrl,
            ]
        ];

        $this->verifyMail($message, $expected);
    }

    protected function verifyMail(\Swift_Message $message, array $expected)
    {
        // From & To アドレスを検証
        $this->assertEquals($expected['from'], key($message->getFrom()));
        $this->assertEquals($expected['to'], key($message->getTo()));
        // Subject
        $this->assertEquals($expected['subject'], $message->getSubject());
        // Body
        foreach ($expected['body'] as $body) {
            $this->assertContains($body, $message->getBody());
        }
    }
}

$this->message = $container->get('app.mail_service')で取得されるインスタンスは、AppBundle\Tests\Mock\Service\TestMailServiceに置き換わっています。

$messages = $this->mailService->getMessages(); で送信されたSwift_Messageインスタンスを取り出し想定通りのメッセージとなっているか確認します。

ここでは、Fromアドレス、Toアドレス、件名の完全一致、本文に「名前」「アクティベーションURL」が含まれているかを検証しています。

場合によっては本文を全文一致で検証してもいいですが、必要な情報が出力されていればいいとの判断です。文面がかわることもありますし。

本文を全て確認したい場合には、必殺のvar_dump($message->getBody())も使えますし、最終的にブラウザテストも必要なのでまた別に確認したりします。

※ 当然のようにPhakeを利用してますが、PhakeはPHPのモッキングライブラリです。 Welcome to Phake - PHP Mocking Framework’s documentation! — Phake - PHP Mocking Framework 1.0.3 documentation

まとめ

  • DIとクラスのパラメーター化を利用することで、テスト対象のクラスを容易に置き換えることができました。
  • このようにテストしにくい対象クラスを継承して、内部状態を検査するような手法はテストコードを書く技術としてしばしば利用されています。(実際Symfony2のテストコードでみかけます)
  • メールのテストで面倒なブラウザ操作を何度も行う必要がなくなります(※最低限の動作確認は必要です)