プログラミング - 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のテストコードでみかけます)
  • メールのテストで面倒なブラウザ操作を何度も行う必要がなくなります(※最低限の動作確認は必要です)

SymfonyDIを利用してMail送信クラスをインテグレーションテストする:前置き編

コードの解説を行っている続編はこちら

http://okapon-pon.hatenablog.com/entry/symfony2-mail-test-2

前置き

皆さんテスト書いてますか?

TDDでバリバリ開発しています!という人は少ないのではないかと思います。

TDDをなかなか実践できない問題の1つにテストを書くメリットを感じ辛いというのがあるのではないかと思います。

確かに効果を感じ辛い部分もありますが、逆にすごく効果的に機能する場合もあります。

今回はメリットを感じやすいメールのインテグレーションテストを紹介したいと思います。

Mailのインテグレーションテストを行うメリット

普通にブラウザテストを行う場合、メールを送信するためにユーザー登録フォームに入力したり、申し込みフォームを入力したりと、面倒な操作を行わないといけないケースが大半だと思います。

ユーザー登録など1度しか行うことのできない操作の場合だと登録処理が完了した後、テストのためにDBのデータを削除して再度実行!なんて非常に面倒です。

操作するのが面倒なのでデバッグ用のコントローラーでも用意して、そこからメール送信のメソッドを呼び出すこともできますが、毎回そういうのを書くのも面倒です。

そこで是非やっていただきたいのが、登場するのがメールのインテグレーションテストです!

この記事の続編に書いてある方法を実践すると以下のメリットがあります。

  • ブラウザを開かなくても、メール送信ロジックの確認ができる(とはいえ開発時の話ですので、最終的にはブラウザテストも行うべきでしょう)
  • フォームの入力など面倒な操作が必要ないので、素早く何度でも確認できる

テストについて

タイトル通り扱うのはインテグレーションテストであり、ユニットテストではありません

このテストの目的は、ある引数を渡したら想定されたメールが送信されるか確認するためのテストを行いたいのです。 (ただし、続編記事で書いている通り実際にはメールを送らず代替手段で確認を行います)

なぜユニットテストではないのか?

  • どんなテンプレートを呼ぼうとしているか
  • どんな引数を渡しているか

は今回確認したいことではありません。

テンプレートの置き場所やファイル名が間違っていた。

変数は渡してたけど、Twig中での表記が間違っていて動作しなかった

では意味がないですよね。

テストで確認したいことは、

  • 実際にテンプレートが読み込まれ、レンダリングが行われること
  • 想定した宛先に想定した件名・本文でメールが送信されていること

だからです。

※ 決してユニットテストが不要と言っているわけではありません。 テストの目的が異なるだけです。

End-toEnd Testすれば?と思う方もいるかもしれませんが、ここではコントローラーのテストを行いたい訳ではないのでやりません。

コードの解説を行っている続編はこちら

http://okapon-pon.hatenablog.com/entry/symfony2-mail-test-2

「Blackfire」というPHPプロファイラーが出たので使ってみた(最速レビュー)

Blackfireとは?

SensioLabsからリリースされた、PHPのプロファイリングツールです。 プロファイリング結果をブラウザ上で簡単に見ることができるようになります。

Blackfireのデモ動画

以前は「SensioLabsProfiler」と呼ばれており、サービスを出すとだけ告知されていたのですがようやく利用できるようになりました。

現在はbeta版として提供されていて、DebianRed HatベースのLinuxMac OSが対応しています。

利用するためには、Blackfileの「Join the beta」からSensioLabsConnectに登録する必要があります。

アーキテクチャ(ざっくり)

  • CompanionというChorome拡張を使ってプロファイリングを実行
  • ProbeというPHP extensionでアプリケーションのプロファイルを計測
  • blackfire-agentでプロファイル結果を集めてBlackfireサーバー側にデータを送信
  • プロファイラーで集めた結果をブラウザで確認する

セットアップ

インストール方法は、マニュアルのGetting Started に丁寧に書かれています。 こちらを見ることをおすすめします。

私はMacにインストールしたので、以下Macにインストールする場合の話をします。

Chrome拡張のインストール

利用可能なブラウザはChromeだけのようです。 Googole Chromeをインストールしてない人はまずインストールしてください。

Chrome 拡張機能の「Blackfire Companion」をインストールします。

blackfire-agentのインストール

手順はMacだと以下

$ brew tap blackfireio/homebrew-blackfire
$ brew install blackfire-agent

DebianRed Hat 向けのリポジトリもありますので各環境に合わせてインストールしてください。

続いてServer IdとServer Tokenを登録します。

以下のコマンドを実行します。

$ blackfire-agent -register

Server IdとServer Tokenは、Getting started のページに記載されているので、それを入力します。

f:id:okapon_pon:20141103203024p:plain

現在、1アカウントにつき1つのAgentしか登録できないようです。 ただし、Blackfire Accountページに「Register an Agent」ボタンがあるので将来的には複数agentを登録できるようになるのでしょう。

f:id:okapon_pon:20141103234655p:plain

blackfire-agent が自動起動するように登録します。

$ ln -sfv /usr/local/opt/blackfire-agent/*.plist ~/Library/LaunchAgents

今回は手動で起動

$ launchctl load -w ~/Library/LaunchAgents/homebrew.mxcl.blackfire-agent.plist

Probeのインストール

Probeのインストールは環境によってはちょっと面倒ですし、場合によっては利用さえできない可能性があります。

  • 現時点で、LInux環境ではPHP5.6に未対応
  • PHPのビルド時に、--enable-debug つけて有効化していると利用不可
  • XDebugXHProf を利用していると干渉するかも(確認していない)

「homebrew-phpを利用していれば特に問題ないよ」とは書かれてました。

ちなみに、私は--enable-debugとしてPHPをビルドしていたので、PHPをインストールし直しました。

ドキュメントにも書かれていますが、自前でビルドしたPHPMac標準のPHPを使う場合には、以下のコマンドを実行します。

$ brew install blackfire-php55 --without-homebrew-php

homebrew-phpでインストールしている場合には「--without-homebrew-php」を消すと付けずに実行します。

一応モジュールのインストールを確認

$ php -m | grep blackfire
blackfire

使い方

試しにSymfonyフレームワークのプロファイルを取ってみます。

プロファイリングしたいページにアクセスし「Blackfire Companion」の拡張ボタンをクリック。続いてprofileボタンをクリックします。

f:id:okapon_pon:20141103231921p:plain

プロファイリングの進行状況はページの上部に表示され、完了するとプロファイリング結果のページヘのリンクが表示されます。

f:id:okapon_pon:20141103233249p:plain

プロファイリング結果(profile-slots)のページへアクセスすると、コールグラフなどが表示されます。

f:id:okapon_pon:20141103233426p:plain

左側のメソッドと、右側のコールグラフが連動していて、右側のグラフをクリックするとその部分の詳細を見ていくことができます。 xhprofのコールグラフはあくまで画像として出力されるだけなので、リンクになっていて該当メソッドの場所にジャンプできるのは便利ですね。

f:id:okapon_pon:20141103233526p:plain

実行時間、ディスクI/O、使用メモリーの順で並び替えたり、コストの高いメソッドを抽出したりとボトルネックとなっている部分を探しやすいようになっています。

f:id:okapon_pon:20141104000951p:plain

既存のプロファイラーだと実行時間ぐらいしか見ることができなかったので、これはありがたいですね。

もう少し使いこんでみようと思います。

「クロコスにおけるSymfony2 のお話」というネタでブログを書きました。

タイトルの通りですが、クロコスでどのようにSymfony2が使われているのか書きました。

社外でのSymfonyの使われ方について、なかなか見たり聞いたりする機会は少ないと思います。

Symfony2にはいわゆるレールというものがなく、開発者に何かを強要するようなことはありません。 設計は開発者に委ねられます。

しかしその柔軟さゆえ「どのように開発するのが正解なのかわからない」という話を聞くことがあります。

Symfonyで開発するにあたり、正解というものはないと思います。 自分のプロジェクトにあった好きなスタイルで開発するのがよいでしょう。

クロコスでの開発スタイルはあくまで1つの事例として紹介したにすぎません。 こういうやり方や考え方もあるんだと参考になれば幸いです。

クロコスにおけるSymfony2 のお話 :: Crocos Engineering Blog

Symfony勉強会#10に参加しEventListenerについて発表してきました

先日7月26日に、Symfony 勉強会#10 が開催されました。

会場は株式会社ヒトメディアさんにご提供いただきました。 @imunew さん、会場提供の申し出ありがとうございました。 また準備に協力いただいたヒトメディアの皆様ありがとうございました。

今回開催された「勉強会#10 」ですが、前回の勉強会#9 が4/19から約3ヶ月での開催となりました。

ここ数年私の記憶ではではSymfony勉強会は、半年とか1年に1回ぐらいペースでしか開催されていなかったのでとても早い間隔での開催でした。

勉強会の内容は「Silexワークショップ」と「参加者LT」の2本立てでした。

前半 Silexワークショップ

Creating A Simple REST Application With Silexの写経やコールドリーディングなど行いました。

参加者にSllexについて聞いてみたところ、知らないという方がわりと多かったようです。

ワークショップ中は私の画面を映しならが進めていきました。

composerでsilexのインストールを行うところから始めて、コードを書いていきました。

  • $app->abort()と書くのが例外を投げているのが分かりづらい
  • 写経する元のコードが間違っている。ハマりどころが多いから自分で考えて書けということかな?
  • ここはHTTP status code 204であってるんだっけ?
  • HTTP_CREATED 200はおかしくない? 201だよね

と画面を見ながら色々話しながら進めていきました。

元の記事には書いてなかったのですが、個人的にSilexのテストコードを書いて見たかったので写経から逸れてやってみることに。

テストコードを書いている途中でsymfony/browser-kitが必要になったので、composerでインストールする。

http://silex-users-jp.phper.jp/doc/html/testing.htmlに習い、

"symfony/browser-kit": ">=2.3,<2.4-dev"

をcomposer.jsonに追記し(browser-kitのversionが古いという問題はさておき)インストール

$ composer update

しかしcomposerがまったく反応せずインストールが進まない。。

f:id:okapon_pon:20140729010125p:plain

この状態で数分間固まる。処理が進んでいるのかも分からず。。

まさにcomposerの闇を垣間見た瞬間でした。。

結果的にはその後の@_nishigoriさんの発表に繋がったのでそれはそれで良かったと思います。

時間はかかりましたが最終的にはインストールも完了し、写経も最後まで進めることができました。

後半 LT

発表は計8名の方が行いました。

タイトル 発表者
4年ほどSilexを使ってきた経験の話 @Tetsujin
1000万ユーザーのソーシャルゲームのバックエンドにSymfonyを使った話 @modeelf
EventListener使いこなし術 @okapon_pon
そろそろComposerについてひとこと言っておくか @_nishigori
Symfony2 を drone.io でCI する @HideyukiTakei
パーフェクトRailsで個人的に良かったところ @tdakak
Symfonyでの実装パターン、のような話 @hidenorigoto
DBマイグレーション管理どうしてますか? @brtriver

私は3番目で「EventListener使いこなし術」というタイトルで発表してきました。

使いこなし術と題しつつ、比較的初心者向きのお話でした。

簡単にまとめると

  • コントローラー実行前の共通処理はEventListenrに書きましょう
  • EventListenerを使えばアイデア次第で色んなことを実現できます。
  • Symfony はHWに全て従う必要はないので、必要に応じてオレオレ作ってもいいじゃない

という内容でした。

資料にも書いていますが、サンプルコードは以下のGistにあります。

コントローラーにbefore()メソッド持たせるためのサンプル。皆さんは真似しないように(笑) https://gist.github.com/okapon/fd2dfdacf87e417f7957

オレオレ認証をEventListen使って作ってみた https://gist.github.com/okapon/88f5bd56a886c287a7d1

LTで気になった話

個人的には@hidenorigotoさんの「Symfonyでの実装パターン、のような話」が気になりました。

Criteriaパターンと言うのを初めて聞いたのですが、動的条件をCriteriaで表しておき、Specificationにインジェクションすることで「仕様を1箇所にまとめておくことができる」のがメリットだと理解しました。

ですがRepositoryが肥大化しても実際の開発でさほど問題を感じてないのが現状で、むしろCriteriaを導入するには以下の2つのデメリットがあるように思えました。

  • CriteriaをRepositoryとは別に書かなければならない実装コスト
  • DBの検索条件がRepository一箇所に固まってないことで、どんな条件で検索されているのか逆に分かりづらくなる(気がする)

それらを上回るメリットがありそうかというと直感的には理解できなかったです。

懇親会で聞いてみたかったのですが、チャンスを逃してしまいもったいない無いことをしてしまいました。

また今度聞いてみることにします。

懇親会

「懇親会からが本番」というSymfony勉強会の多分に漏れず長い懇親会となりました。

懇親会LTで@imunew さんが「恋と愛のちがい」を発表され大変盛り上がっていました。

『恋や愛の対象もオブジェクト』 『愛はDI、恋はサービスロケータ』

これで火がついた@hidenorigotoさんが 「そして概念メタファーのお話」へ発展させました。

恋は奪うもの、奪いあうものと言えば資源があり、恋愛は戦争メタファーであると。

Symfonyにかかれば、全てのお話はオブジェクトや概念、モデリングのお話に帰結されてしまいます(笑)

やっぱりSymfonyコミュニティーは面白いですね。

感想

Symfony勉強会は相変わらず濃い勉強会でした。 発表は話きれていない部分が多かったのでもう少しうまくなりたいのと、資料はもう少し作りこまないといけないなと思いました。 次回こそ事前準備がんばろう。。

最近は自分も含めて色々な人が発表なり参加されるようになってそれも楽しく今後が楽しみです。

次回はおそらくSymfony温泉!楽しみ!