このエントリーをはてなブックマークに追加

Symfony Advent Calendar 2016 19日目の記事です。

はじめに

Symfony2でアプリケーションを開発している人たちの多くは、アプリケーションのファクショナルテストを書いているかと思いますが、 今回はアプリケーションのファンクショナルテストではなくて、バンドルのファンクショナルテストについて書きたいと思います。

バンドル単体のファンクショナルテストがない場合の問題

バンドルはKernelにインストールされて使用されます。
バンドルをファンクショナルテストしないままリリースしてしまうと、アプリケーションのKernelにインストールされてから問題に直面する可能性が上がってしまいます。

  • DIコンテナのコンパイルに失敗する
  • Deprecation Warningが出てしまう
  • 期待した通りに動かない
  • などなど・・・

問題は修正してしまえば良いのですが、Symfonyの文化圏内だとそう簡単には後方互換を捨てられません。
後方互換を維持したまま修正できれば何の問題もないです。もしそうでない場合はメジャーバージョンを上げるべきでしょうが、そうホイホイ上げたくありませんよね。

問題1: DIコンテナのコンパイルに失敗する

OSSで提供されているバンドルの多くは、Symfonyの複数のバージョンをサポートしています。
Symfonyはv2.3から後方互換性を維持してくれている ので、そこまで神経質にならなくても大丈夫ですが、メジャーバージョンを跨いで複数のSymfonyをサポートしていると、バンドルをアプリケーションのKernelにインストールした結果、DIコンテナのコンパイルに失敗するようになってしまう可能性は低くありません。

メジャーバージョンが変更される際に、削除が予定されているサービスは削除されるので、もし参照が残っていたりするとこれに該当します。

問題2: Deprecation Warningが出てしまう

Symfonyのアップグレードをスムーズに行わせる仕組みの1つにDeprecation Warningを出力する仕組みがあります。さらに、テスト結果からDeprecation Warningを確認できる仕組みも提供されています。

なのでSymfonyユーザーはDeprecation Warningに敏感です。
あるバージョンのSymfonyでバンドルを利用しているユーザーは大丈夫だけれども、より新しいバージョンのSymfonyを利用しているユーザーだとそうではない場合があります。

あるバージョンで非推奨となったAPIや、サービスを実行したり参照したりするとこれに該当します。

問題3: 期待した通りに動かない

Symfonyはプラガブルにフレームワークの振る舞いを変更する事ができ、もちろんアプリケーションだけでなくバンドルからも変更する事が可能ですが、それらはバンドル自身の機能ではありません。
例えばEventDispatcherはSymfonyのコアに組み込まれていますが、サービスとしてはFrameworkBundleが提供していますし、KernelのイベントもHttpコンポーネントが発行しています。

つまり、フレームワークの振る舞いを変更する機能がバンドル内にある場合、ユニットテストだけでは想定通りのタイミングで動作するのか、想定通りな値を受け取れるのか、想定通りに振る舞いを変更できているのか、そもそも動作するのか、何も保証できないという事です。アプリケーションの場合と同じですね。

どうやってファンクショナルテストするか

手順は基本的にアプリケーションの時と同じです。

  • テスト用のKernelを作る
  • テスト用のKernelにバンドルをインストールする
  • テスト用の設定をテスト用のKernelにロードさせる
  • テスト用のKernelでファンクショナルテストする

テスト用のKernelを作る

普段のアプリケーションと同じように、Symfony\Component\HttpKernel\Kernelを継承したKernelを作ります。

<?php

use Symfony\Component\HttpKernel\Kernel;

class TestKernel extends Kernel
{
}

テスト用のKernelにバンドルをインストールする

テストしたいバンドルと、普段のアプリケーションと同じようにFrameworkBundleもインストールします。

<?php

use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\HttpKernel\Kernel;

class TestKernel extends Kernel
{
+    public function registerBundles()
+    {
+        return [
+            new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
+            new Your\Bundle\YourSpecialBundle(),
+        ];
+    }
}

テスト用の設定をテスト用のKernelにロードさせる

どの設定ファイルをロードするか指定します。(もちろんテストしたい設定も追加してください)

<?php

+use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\HttpKernel\Kernel;

class TestKernel extends Kernel
{
    public function registerBundles()
    {
        return [
            new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
            new Your\Bundle\YourSpecialBundle(),
        ];
    }

+    public function registerContainerConfiguration(LoaderInterface $loader)
+    {
+        $loader->load(__DIR__.'/config.yml');
+    }
}

テスト用のKernelでファンクショナルテストする

Symfonyが提供しているファンクショナルテスト用のクラスSymfony\Bundle\FrameworkBundle\Test\WebTestCaseがありますが、そのクラスにはテストに使うKernelを指定する事ができます。
毎回Kernelを指定しても問題ないですが手間なのでファンクショナルテスト用のサブクラスを作っておくと便利です。

<?php

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase as BaseWebTestCase;

class WebTestCase extends BaseWebTestCase
{
    protected static function getKernelClass()
    {
        return TestKernel::class;
    }
}

あとはいつも通りの手順でバンドルのファンクショナルテストができます。

<?php

class YourFunctionalTest extends WebTestCase
{
    public function testFeature()
    {
        $client = self::createClient();
        $client->request('GET', '/path/to/feature');
    }
}

複数バージョンのSymfonyでファンクショナルテストする

特定のSymfonyだけでなく、サポートする全てのバージョンのSymfonyでテストできればより良いですよね。 そんな時に Travis CI を使えば、複数の環境を1度にテストできて大変捗るのでオススメです。

Travis CI には Build Matrix 機能があり、下図のように全てのバージョンのSymfonyにインストールされた状態を、全てのPHPバージョンでテストする事ができ、殆ど完璧な状態を保つ事が簡単にできます。

  PHP 5.5 PHP 7.0
Symfony v2.8.x Symfony v2.8.x - PHP 5.5 Symfony v2.8.x - PHP 7.0
Symfony v3.1.x Symfony v3.1.x - PHP 5.5 Symfony v3.1.x - PHP 7.0
Symfony v3.2.x Symfony v3.2.x - PHP 5.5 Symfony v3.2.x - PHP 7.0

どのように設定するかはSymfony本家FriendsOfSymfonyのリポジトリが大変参考になります。

まとめ

基本的にはバンドルもアプリケーションと同じ方法でファンクショナルテストができますが、Symfony Standard Edition と違い、初めからファイルが準備されていないので少しだけ準備が必要だというだけでした。本当によくできていて感心します。


このエントリーをはてなブックマークに追加

はじめに

WebアプリケーションフレームワークとしてのSymfonyには、フォームやORM、バリデーションetc…といった機能があります。今回は少し視点をずらして、これらの機能の中から、アプリケーション内の何かと何かの「あいだにあるもの」に着目してみます。実は、この「あいだにあるもの」たちの機能をうまく使うことで、アプリケーションコードが想定するデータの形を整えることができます。

例えば、YAMLから設定データを読み込んで、PHPの連想配列で扱うプログラムを記述しているとしましょう。設定データを扱うあらゆるメソッドで、その都度キーの存在有無をチェックしているようなコードを想像してみてください。とてもイヤな感じですね。このようなコードがあったら、単純に無駄なコードが多いという表面的な問題だけでなく、正しいデータの有り様が定まっていないという問題が潜んでいる可能性が大いにあります。そしてこの種の潜在的な問題は、ボディーブローのようにバグの温床になっていきます。

この問題を解決する1つの手段として、ある機能への特に入力の前処理段階でデータを正規化(Normalize)するアプローチが一般的です。Symfonyアプリケーションにおいて、機能の一部として正規化の役割を担う次の5つのコンポーネントについて紹介します。

  • Form
  • JMSSerializer
  • Doctrine
  • Config
  • OptionsResolver

全体図としては以下のようになります。

それぞれの使われ方を簡単に説明します。

Form

アプリケーションのEntityに対して、Webフォームとの入出力の間に位置するのがFormです。名前から「Webフォームとして出力するタグを定義する」というイメージを持ってしまいがちです。しかし、それはどちらかというとオマケと捉えておき、Entityの構造とWebフォームとして入出力する情報の構造とのマッピングや変換を記述していると見た方が素直に扱えます。

Formの中で、データの正規化機能として重要なのは、DataTransformerやフォームのイベントです。また、フォームのデータ変換機能をまとめて処理するDataMapperを独立して作ることもできます(※1)。以下は参考となるドキュメントのリンクです。

※1 フォームのDataMapperの機能は別の記事で解説予定です。

なお、次に紹介するJMSSerializerを使ってJSONのリクエスト/レスポンスを扱っている場合でも、受け取ったデータの基本的な正規化をFormで行うようにしておくと役割がスッキリします。

JMSSerializer

アプリケーションのEntityに対して、JSONのリクエスト/レスポンスとの入出力の間に位置するのがJMSSerializerです。Entityクラスのプロパティやメソッドにアノテーションを記述するだけで、JSON上のプロパティ名などにマッピングできます(@SerializedName)。@TypeでJSON向けのデータ型を指定したり、@VirtualPropertyでEntity側には存在しないプロパティをJSON向けに作るといった機能もあります。これらの機能を使って簡易なデータ正規化は可能ですが、Symfonyで普通に行うEntityクラスに記述したバリデーションの実行なども含めて、通常はFormも合わせて使います。

Doctrine

アプリケーションのEntityに対して、RDBとの入出力の間に位置するのがDoctrine ORMです。これは普通ですね。

Symfony/Doctrineでちょっと複雑なValueObjectを扱う例のような変換をDoctrineのレイヤーに仕込むことで、アプリケーションのEntityをより表現豊かにすることなどもできます。

Config

YAMLやXML形式の設定ファイルを読み込んで扱う場合に、SymfonyではバンドルのExtensionという機能のレイヤで処理することになります。読み込んだYAMLやXMLはPHPコード内では連想配列になっており、これを正規化するのがConfigです。

多くの場合、Extensionの段階で何らかのサービス定義に作用させる等を行うため、ここで読み込んで正規化した連想配列をアプリケーションコード側で直接利用することはありません。しかし、YAMLで編集する際の構造と、プログラム内で扱う構造の違いを吸収し、かつ、イレギュラーな設定を弾いたり正規化したりする場所があることは有用です。

Config の利用シーンは次に紹介する OptionsResolver と似ていますが、Config はツリー全体の定義が可能で、変形操作を定義できるなど高機能です。

OptionsResolver

他の機能と少しレイヤが異なりますが、連想配列の正規化というミクロな目的に絞れば、SymfonyのOptionsResolverが有用です。もしも自分のPHPプロジェクトで、コンポーネント間や複数のメソッドに渡って連想配列でデータを受け渡しており、各所に連想配列内のキーのチェックが散らばっているようであれば、どこか1箇所でOptionsResolverを通してデータを正規化する処理を入れるとスッキリします。使い方は簡単で、以下のようにして必ず定義されているキーや、値が必須のキー、キーごとのデフォルト値などを記述しておきます。最後に $options = $resolver->resolve($options); のようにして連想配列データを OptionsResolver に通します。

$resolver = new OptionsResolver();
$resolver->setRequired(array('host', 'username', 'password'));
$resolver->setDefaults(array(
    'host'     => 'smtp.example.org',
    'username' => 'user',
    'password' => 'pa$$word',
    'port'     => 25,
));

$options = $resolver->resolve($options);

resolve() メソッドの実行時点で形式のチェックが行われ、エラーがある場合は例外(UndefinedOptionsException など)がスローされます。

TIPS 階層を持った連想配列に対して構造を定義する場合は、階層ごとに OptionsResolver の定義インスタンスを作り、値を取り出しながら適用していきます。

おわりに

Symfonyのいくつかの機能を、正規化という役割に着目して紹介しました。アプリケーションの各所で受け渡されるデータを、適切な場所で正規化することで、処理するメソッド内では、考慮しなければならない範囲(スコープ)を狭めることができます。同時に、データの有り様を事前に明らかにする活動にも焦点を当てることができます。

まだまだ型の強制力が弱いPHPでは、このような道具が役立つ場面が多くあるかと思います。ご参考になれば幸いです。


このエントリーをはてなブックマークに追加

この記事は Symfony Advent Calendar 2016 15日目の記事です。

はじめに

ステータスを管理する時などに便利なSymfony/Workflowコンポーネントを紹介したいと思います。

インストール

Composerで楽々インストール出来ます。

$ composer require symfony/workflow

今回のゴール

以下のPull Requestのワークフローのステータス管理を実現したいと思います。

pr-workflow

準備

PullRequestクラスの作成

<?php

class PullRequest {

    private $marking = 'ready';

    public function getMarking()
    {
        return $this->marking;
    }
    public function setMarking($marking)
    {
        $this->marking = $marking;
    }
}

ステータス情報を保持するmarkingプロパティだけを持ったシンプルなクラスになります。
markingプロパティは、自分で任意の名前に変更可能です。
ちなみにGetterSetterが無いと怒られます。

Placeの定義

今回のステータス情報に当たるものは、WorkflowコンポーネントではPlaceと表現されています。

<?php

// use Symfony\Component\Workflow\DefinitionBuilder;

$builder = new DefinitionBuilder();
$builder->addPlaces(['ready', 'wip', 'in-review', 'merged']);

DefinitionBuilder::addPlaces()メソッドで、ready, wip, in-review, mergedの4つのステータスを定義していることになります。

Transitionの定義

どのようにステータス(Place)が遷移していくかをTransitionクラスで定義します。

<?php

// use Symfony\Component\Workflow\Transition;

$builder->addTransition(new Transition('start-work', 'ready', 'wip'));
$builder->addTransition(new Transition('request-review', 'wip', 'in-review'));
$builder->addTransition(new Transition('feedback', 'in-review', 'wip'));
$builder->addTransition(new Transition('merge', 'in-review', 'merged'));

今回の場合は、start-workTransitionは、readyからwipPlaceへの遷移を表現していることになります。

Workflow(StateMachine)インスタンスの作成

Workflow(StateMachine)のインスタンスを作成します。
今回はStateMachineを作成しています。

<?php

// use Symfony\Component\Workflow\DefinitionBuilder;
// use Symfony\Component\Workflow\Transition;
// use Symfony\Component\Workflow\StateMachine;

$builder = new DefinitionBuilder();
$builder->addPlaces(['ready', 'wip', 'in-review', 'merged']);

$builder->addTransition(new Transition('start-work', 'ready', 'wip'));
$builder->addTransition(new Transition('request-review', 'wip', 'in-review'));
$builder->addTransition(new Transition('feedback', 'in-review', 'wip'));
$builder->addTransition(new Transition('merge', 'in-review', 'merged'));
$definition = $builder->build();

$workflow = new StateMachine($definition);

WorkflowとStateMachineの違い

デフォルトでセットされるMarkingStoreが異なるだけで他に違いはありません。
特にMarkingStoreを指定しない場合、StateMachineではSingleMarkingStoreがセットされ、WorkflowではMultipleMarkingStoreがセットされます。

ですので、以下のようにコードを変更しても完全に同じ振る舞いをします。

-$workflow = new StateMachine($definition);
+$marking = new SingleStateMarkingStore();
+$workflow = new StateMachine($definition, $marking);

SingleMarkingStoreとMultipleMarkingStoreの違い

SingleMarkingStoreは名前の通りmarkingに値を1つしか保持出来ませんが、MultipleMarkingStoreは同時に複数保持することが可能です。
今回の場合は1つで事足りると思いますので、SingleMarkingStoreをデフォルトで採用しているStateMachineを利用しています。

実行

それでは実行してみます。

<?php

$pr = new PullRequest();

$workflow->can($pr, 'hoge'); // Thow Symfony\Component\Workflow\Exception\LogicException

$workflow->can($pr, 'merge'); // False

$workflow->apply($pr, 'merge'); // Thow Symfony\Component\Workflow\Exception\LogicException

$workflow->can($pr, 'start-work'); // True

$workflow->apply($pr, 'start-work');
$workflow->can($pr, 'request-review'); // True

$workflow->getEnabledTransitions($pr); // ['feedback', 'merge']

定義したルールに添っていないと例外が投げられたり、Falseが返ってきたりして分かりやすいですね。
ちなみにTwig_Extensionも用意されているので、

{% if workflow_can(pr, 'merge') %}
    <a href="...">Merge</a>
{% endif %}

みたいに書くことも出来ます、便利ですね!

おわりに

ステータス管理にSymfony/Workflowコンポーネントを利用すると「どのように遷移すべきか」「どういった遷移を想定しているか」を明示的に定義することが出来ます。 特に外部サービスのAPIなどを利用している場合は、想定外のレスポンスやステータス遷移を引き起こしてしまう可能性もあるかと思いますので、このように宣言的に設定出来るのは良いプラクティスではないでしょうか。
Symfonyで使う場合は、ワークフローの定義をYamlファイルなどの外部ファイルで簡単に管理出来ますので、一度試してみては如何でしょうか。

補足資料

最終的に実行したコード

Pull Reuest Workflow Sample