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

Symfony Advent Calendar 2018 7日目の記事(遅刻)です。
昨日も私の記事でした :sweat_smile:

巨大なプロジェクトの構成要素を小さなコンポーネントに分けて開発するようになると、バリデーションをSymfonyプロジェクト外で書くことも増えます。

こういうときSymfonyがコンポーネントごとに分かれていて、独立して使用することもできるのが生きてきます。
昔のsymfony1時代との大きな違いですね!

実際にSymfony Validatorでバリデーションしてみる

下記のような Term クラスがあるとします。

<?php

namespace Quartetcom\DecBlogDemo\Entity;

class Term
{
    /**
     * @var \DateTime
     */
    private $from;

    /**
     * @var \DateTime
     */
    private $to;

    public function __construct(\DateTime $from, \DateTime $to)
    {
        $this->from = $from;
        $this->to = $to;
    }

    public function validateLessThan90Days()
    {
        if ($this->to->diff($this->from)->days > 90) {
            throw new \LogicException('Term is too long.');
        }
    }

    // ...
}

このTermクラスのバリデーションをSymfonyのValidatorコンポーネントを使って書き換えてみましょう。

まず、composerで symfony/validator への依存を追加します。

$ composer require symfony/validator

そしてvalidateLessThan90days() メソッドをSymfonyのValidator用の書き方に変更します。

<?php

//...
+use Symfony\Component\Validator\Constraints as Assert;
+use Symfony\Component\Validator\ExecutionContextInterface;


class Term
{
    // ...

+    /**
+     * @Assert\Callback
+     */
+    public function validateLessThan90Days(ExecutionContextInterface $context)
+    {
+        if ($this->to->diff($this->from)->days > 90) {
+            $context->buildViolation('term.too_long')
+                    ->atPath('to')
+                    ->addViolation()
+            ;
+        }
+    }
-    public function validateLessThan90Days()
-    {
-        if ($to->diff($from)->days > 90) {
-            throw new \LogicException('Term is too long.');
-        }
-    }

    // ...
}

テストを書いて、このバリデーションが期待通りに動くかどうか確認してみましょう。

<?php


namespace Quartetcom\DecBlogDemo\Entity;


use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;

class TermTest extends TestCase
{
    public function test_validate_90days()
    {
        $term = new Term(new \DateTime('2018-11-01'), new \DateTime('2019-01-30'));
        $this->assertValidTerm($term);
    }

    public function test_validate_91days()
    {
        $term = new Term(new \DateTime('2018-11-01'), new \DateTime('2019-01-31'));
        $this->assertInvalidTerm($term);
    }

    private function assertValidTerm(Term $term)
    {
        $this->assertEquals(0, $this->getViolationCount($term));
    }

    private function assertInvalidTerm(Term $term)
    {
        $this->assertGreaterThan(0, $this->getViolationCount($term));
    }

    /**
     * @param Term $term
     * @return int length of errors
     */
    private function getViolationCount(Term $term): int
    {
        $validator = Validation::createValidator();

        return count($validator->validate($term));
    }
}

2018-12-08 22 28 29

バリデーションが行われなかったようです… :cry:
おっと、Validator初期化時にアノテーションによるバリデーション設定を有効化する必要があるのを忘れていました。

<?php

namespace Quartetcom\DecBlogDemo;

// ...

class TermTest extends TestCase
{
    // ...

    private function getViolationCount(Term $term)
    {
-        $validator = Validation::createValidator();
+        $validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator();

        return count($validator->validate($term));
    }
}

今度こそバリデーションできるはず!
2018-12-08 22 35 11

今度は doctrine/annotationsdoctrine/cache が必要だというエラーが出てしまいました。
メッセージに従い、追加します。

$ composer require doctrine/annotations doctrine/cache

今度こそバリデーションできるはず!
2018-12-08 22 38 15

Symfony\Component\Validator\Constraints\Callback がクラスとして読み込めていないようです :thinking:
AnnotationRegistryにautoloadできるクラスが存在すればAnnotationとして利用できる設定を追加します。
※ この設定は https://github.com/doctrine/annotations/issues/103 によるとDoctrine v3から不要になるようです。

<?php

namespace Quartetcom\DecBlogDemo;

// ...
+use Doctrine\Common\Annotations\AnnotationRegistry;


class TermTest extends TestCase
{
    // ...

    private function getViolationCount(Term $term)
    {
+        AnnotationRegistry::registerLoader('class_exists');
        $validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator();

        return count($validator->validate($term));
    }
}

ついにバリデーションが動きました! :tada:
2018-12-08 22 44 17

まとめ

私はガッチリ密結合だったsymfony1.0の頃からsymfonyを使っているので、Symfonyのバージョンが1→2→3→4と進むにつれて、疎結合がどんどん加速しているのを感じます。
マイクロサービス志向・コンポーネント志向が進む中、安心して使うことができるSymfony Componentの役割はますます大きくなりそうです。

※ 本記事のサンプルコードを こちらのレポジトリ で公開しています。ご参考にどうぞ


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

Symfony Advent Calendar 2018 6日目の記事です!
昨日は @okapon_pon さんの Symfonyフレームワークにおけるデザインパターン活用 でした。
Symfony Advent Calendarはまだまだ枠に余裕があります!何かSymfonyネタのある方はこの機会にどうぞ!

前提

カルテット開発部のPHPチームは、通常、エンティティのprimary keyとなる $id にsetterを書きません。(習慣的に)
特に規約があるわけではないですが、primary keyはデータベース側でauto_increment/serial型で入れることがほとんどなので、アプリケーション側からIDをセットしないということを表すために、自然と習慣化されてきました。

IDのsetterがなくて困ること

ズバリ、エンティティを操作するサービスのユニットテスト書くときに困っています。

  • エンティティのインスタンスを複数扱うときにそれぞれ違うIDを指定したい
  • エンティティのID以外のフィールドにも値を出し入れする必要がある

という場合に、ベストプラクティスというべき方法がなかったのです。

サンプルコード

具体的には、idとstatusという2つのフィールドを持つ Job エンティティがあり、

<?php


namespace Quartetcom\DecBlogDemo\Entity;


class Job
{
    /**
     * @var int
     */
    private $id;

    /**
     * @var string
     */
    private $status;

    /**
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getStatus()
    {
        return $this->status;
    }

    /**
     * @param string $status
     * @return $this
     */
    public function setStatus(string $status): Job
    {
        $this->status = $status;

        return $this;
    }
}

Job::$idJob::$status を操作するサービスを作っているケースです。

<?php


namespace Quartetcom\DecBlogDemo;

use Doctrine\ORM\EntityManagerInterface;
use Quartetcom\DecBlogDemo\Entity\Job;

class DecBlogDemo
{
    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * @var StatusCheckerInterface
     */
    private $statusChecker;

    /**
     * @var StatusNotifierInterface
     */
    private $notifier;

    /**
     * @param EntityManagerInterface $em
     * @param StatusCheckerInterface $statusChecker
     * @param StatusNotifierInterface $notifier
     */
    public function __construct(
        EntityManagerInterface $em,
        StatusCheckerInterface $statusChecker,
        StatusNotifierInterface $notifier
    ) {
        $this->em = $em;
        $this->statusChecker = $statusChecker;
        $this->notifier = $notifier;
    }


    /**
     * @param Job[] $jobs
     */
    public function notifyIfStatusHasChanged(array $jobs): void
    {
        $updatedIds = [];
        foreach ($jobs as $job) {
            $previousStatus = $job->getStatus();
            $currentStatus = $this->statusChecker->computeCurrentStatus($job);

            if ($previousStatus !== $currentStatus) {
                $job->setStatus($currentStatus);
                $updatedIds[] = $job->getId();
            }
        }

        $this->em->flush();

        if (count($updatedIds) > 0) {
            $this->notifier->notify($updatedIds);
        }
    }
}

下記のようにステータス更新がある場合・ない場合の動作をテストしたいのですが、テスト時の Job のインスタンスをどのように作ればよいでしょうか?

<?php

namespace Quartetcom\DecBlogDemo;

use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Quartetcom\DecBlogDemo\Entity\Job;

class DecBlogDemoTest extends TestCase
{
    /**
     * @var EntityManagerInterface|ObjectProphecy
     */
    private $em;
    
    /**
     * @var StatusCheckerInterface|ObjectProphecy
     */
    private $statusChecker;

    /**
     * @var StatusNotifierInterface|ObjectProphecy
     */
    private $statusNotifier;

    public function setUp()
    {
        $this->em = $this->prophesize(EntityManagerInterface::class);
        $this->statusChecker = $this->prophesize(StatusCheckerInterface::class);
        $this->statusNotifier = $this->prophesize(StatusNotifierInterface::class);
    }

    public function tearDown()
    {
        $this->em = null;
        $this->statusChecker = null;
        $this->statusNotifier = null;
    }

    public function test_ステータス更新あり()
    {
        $job1 = $this->getJob(1, 'pending');
        $job2 = $this->getJob(2, 'running');

        $this->statusChecker->computeCurrentStatus($job1)->willReturn('running')->shouldBeCalled();
        $this->statusChecker->computeCurrentStatus($job2)->willReturn('running')->shouldBeCalled();
        $this->em->flush()->shouldBeCalled();
        $this->statusNotifier->notify([1])->shouldBeCalled();

        $this->getSUT()->notifyIfStatusHasChanged([$job1, $job2]);
    }

    public function test_ステータス更新なし()
    {
        $job1 = $this->getJob(1, 'pending');
        $job2 = $this->getJob(2, 'running');

        $this->statusChecker->computeCurrentStatus($job1)->willReturn('pending')->shouldBeCalled();
        $this->statusChecker->computeCurrentStatus($job2)->willReturn('running')->shouldBeCalled();
        $this->em->flush()->shouldBeCalled();
        $this->statusNotifier->notify(Argument::any())->shouldNotBeCalled();

        $this->getSUT()->notifyIfStatusHasChanged([$job1, $job2]);
    }

    private function getJob(int $id, string $prevStatus): Job
    {
        // どう書く?
    }

    private function getSUT()
    {
        /** @var EntityManagerInterface $em */
        $em = $this->em->reveal();
        /** @var StatusCheckerInterface $statusChecker */
        $statusChecker = $this->statusChecker->reveal();
        /** @var StatusNotifierInterface $statusNotifier */
        $statusNotifier = $this->statusNotifier->reveal();

        return new DecBlogDemo($em, $statusChecker, $statusNotifier);
    }

}

手法あれこれ

テストをどう書けば良いか、PHPチーム内で議論して様々なやり方を検討しました。

手法その1 getId()をスタブにする

エンティティのモックをJobとして使い、 getId() をスタブにする手法です。

サンプルコード1 PHPUnitのMockObjectを使う

<?php

// ...

class DecBlogDemoTest extends TestCase
{
    // ...
    
    private function getJob(int $id, string $prevStatus): Job
    {
        $job = $this->createMock(Job::class);
        $job->expects($this->once())->method('getId')->willReturn($id);
        $job->expects($this->once())->method('getStatus')->willReturn($prevStatus);
        
        return $job;
    }

テスト対象のサービスが依存する他のサービス StatusCheckerInterfaceStatusNotifierInterface のテストには prophecy を使っているので、ここだけ突然MockObjectが出てくるとやや混乱しますね。 また、 Job::setStatus() の呼び出しは条件によって違うので記述していませんが、もし記述するとしたら $prevStatus !== $currentStatus という実際のサービス内にあるのと同じif文がここにも登場することになってしまいます。

サンプルコード2 prophecyを使う

<?php

// ...

class DecBlogDemoTest extends TestCase
{
    // ...
    
    public function test_ステータス更新あり()
    {
-        $job1 = $this->getJob(1, 'pending');
-        $job2 = $this->getJob(2, 'running');
+        $job1 = $this->getUpdatedJob(1, 'pending', 'running');
+        $job2 = $this->getPreservedJob(2, 'running');

        // ...
    }    
    
    public function test_ステータス更新なし()
    {
-        $job1 = $this->getJob(1, 'pending');
-        $job2 = $this->getJob(2, 'running');
+        $job1 = $this->getPreservedJob(1, 'pending');
+        $job2 = $this->getPreservedJob(2, 'running');

        // ...
    }  
    
    private function getUpdatedJob(int $id, string $prevStatus, string $currentStatus): Job
    {
        $job = $this->prophecy(Job::class);
        $job->getId()->willReturn($id)->shouldBeCalled();
        $job->getStatus()->willReturn($prevStatus)->shouldBeCalled();
        $job->setStatus($currentStatus)->shouldBeCalled();
        
        return $job->reveal();
    }
    
    private function getPreservedJob(int $id, string $prevStatus): Job
    {
        $job = $this->prophecy(Job::class);
        $job->getId()->willReturn($id)->shouldBeCalled();
        $job->getStatus()->willReturn($prevStatus)->shouldBeCalled();
        
        return $job->reveal();
    }

prophecyに統一した版です。 prophecyでは複数のメソッド呼び出しをすべて記述することになるので、やや冗長になります。

手法その2 機能テストだけにする

エンティティを対象にしたテストなので実際の値の出し入れそのものを確認してしまおうという手法です。

サンプルコード

<?php

// ...
use Liip\FunctionalTestBundle\Test\WebTestCase;

class DecBlogDemoTest extends WebTestCase
{
    /**
-     * @var EntityManagerInterface|ObjectProphecy
+     * @var EntityManagerInterface
     */
    private $em;
    
    // ...

    public function setUp()
    {
+        $this->loadFixtureFiles([]);    
-        $this->em = $this->prophesize(EntityManagerInterface::class);
+        $this->em = $this->getContainer()->get('doctrine')->getManager();

        // ...
    }

    // ...
    
    private function getJob(int $id): Job
    {
        return $this->em->find(Job::class, $id);
    }
    
    // ...
}    

悪くはないですが、エンティティを操作するすべてのサービスについて機能テストを書いていくとなると、アプリケーション全体のテストが完了するまでの時間がどんどん長くなっていくのでオススメできません。
また、このサービスを使うコントローラやコマンドのテストでも機能テストを書くことになれば、重複したテストとなってしまいます。

手法その3 setId()を書く

習慣を捨ててエンティティに対してIDのsetterを追加しておき、テストには実際のエンティティのインスタンスを使う手法です。

サンプルコード

Job::setId() を追加して、

<?php

class Job
{
    // ...
    
    /**
     * FOR TEST
     * @param int $id
     * @return Job
     */
    public function setId(int $id): Job
    {
        $this->id = $id;
        
        return $this;
    }    
}

テストではsetId()を使います。

<?php

// ...

class DecBlogDemoTest extends TestCase
{
    // ...
    
    private function getJob(int $id, string $prevStatus): Job
    {
        $job = new Job();
        $job
          ->setId($id)
          ->setStatus($prevStatus)
        ;
        
        return $job;
    }

ID以外のフィールド操作はわざわざスタブにする必要がなくなり、良くなりました! なお、setId()にはFOR TESTとコメントしておき、プロダクションコードから使わないように注意する必要があります。 人の注意力に依存するのはイマイチですね。

手法その4 IDをコンストラクタで渡せるようにする

コンストラクタでIDを指定できるようにし、テスト時のみ使うようにする手法です。

サンプルコード

Job のコンストラクタでIDをセットできるようにして、

<?php

class Job
{
    // ...
    
    /**
     * @param int $id FOR TEST
     */
    public function __construct(int $id = null)
    {
        $this->id = $id;
    }    
}

テストではコンストラクタからIDを注入します。

<?php

// ...

class DecBlogDemoTest extends TestCase
{
    // ...
    
    private function getJob(int $id, string $prevStatus): Job
    {
        $job = new Job($id);
        $job->setStatus($prevStatus);
        
        return $job;
    }

setId()を作ったときと同様、ID以外のフィールド操作は良いですね。 コンストラクタでIDをセットするというのはプロダクションコードで書くことはあまりないでしょうが、やはりコンストラクタのphpdocのparam欄にはFOR TESTとコメントしておき、うっかり使わないように注意する必要があるでしょう。人の注意力依存がやめられませんでした…。

手法その5 エンティティの形を変えずにIDをreflectionでセットする

エンティティのIDはprivateのまま、setterを作らずコンストラクタからも注入させないまま、リフレクションを使ってテスト時のみ特別にIDをセットする手法です。

サンプルコード

<?php

// ...

class DecBlogDemoTest extends TestCase
{
    // ...
    
    private function getJob(int $id, string $prevStatus): Job
    {
        $job = new Job();
        $job->setStatus($prevStatus);
        
        $reflection = new \ReflectionObject($job);
        $idProperty = $reflection->getProperty('id');
        $idProperty->setAccessible(true);
        $idProperty->setValue($job, $id);
        
        return $job;
    }

ID以外のフィールド操作は良い状態を保ったまま、実際のプロダクションコードに副作用なくテストできるようになりました。

まとめ

開発部PHPチームの現時点での結論は 手法5: エンティティの形を変えずにIDをリフレクションでセットする がベストプラクティスということになりました。
Lisketの開発開始から現在までの間に、各自の裁量で書いたテスト(それぞれの時点ではレビューも通っていた)の中には、手法1〜4で書かれたものも多いため、今後、技術的負債返済の時間を使って少しずつベストプラクティスに揃えていきたいと考えています。 

カルテット開発部では、単に要件を満たせばよいのではなく、より良いコードの書き方・テストの書き方についても議論しながら開発を進めています。
このような環境が楽しそうだと思った方は、ぜひ一度 開発部Slackに遊びに来てください :smile:


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

Symfony Advent Calendar 2018 4日目の記事です!
昨日は @polidog 先生の SimpleApiBundleを作っている話 でした。

Symfony Advent Calendarはまだまだ枠が空いてますので、Symfonyネタでなにか書きたい人はぜひ登録を!

はじめに

ちょっとした社内用のツールなんかを作るときに、以前は Silex をよく使っていたのですが、Silexは 2018年1月をもってEOLとなり、今後はSymfony4を軽量に使うことで代替しましょうということになりました。

Silexを使っていた頃は Silex-Skeletonフォークして bootstrap4ベースのテンプレートやフォームテーマをあらかじめ組み込んでおいてすぐにそれなりのアプリが作れるようにしていたのですが、Silexの終了に伴い、Symfony4で同じことをやるためのスケルトンを作りました。(昨年末に)

symfony-micro-skeleton

それが ttskch/symfony-micro-skeleton です。

内容は、composer create-project symfony/skeleton して作ったプロジェクトに、毎回必ずやるような肉付け をあらかじめしておいただけのものです。

主な特徴を挙げると、

  • bootstrap4ベースのテンプレートが準備されている
  • bootstrap4ベースのフォームテーマが準備されている(オフィシャルのものに多少CSSの調整など手を加えてある)
  • font-awesomeインストール済み
  • symfony/translationが準備されている
  • select2 インストール済み
  • select2用の bootstrap4テーマ インストール済み
  • symfony/webpack-encore がインストールされていて、scssでcssを書ける

といったところでしょうか。

使い方

$ composer create-project ttskch/symfony-micro-skeleton:@dev {プロジェクト名}

で、スケルトンを元にプロジェクトを作成できます。

作られたプロジェクトで

$ bin/console server:run

すれば、すでにそれなりのアプリケーションができています。

image

image

エラー(Deprecation)が3になっていますが、SensioFrameworkExtraBundleの こちらのPR がリリースされれば直る予定です。

このプロジェクトを編集する形でアプリケーションを作っていくという流れになります。

おわりに

意外と面倒なbootstrapでのベーステンプレートの準備や細かな見た目の調整などが済んだ状態で開発を始められるので、ちょっとしたアプリケーションなら小一時間もあれば作れてしまいます。

小さなアプリをささっと作りたいということが割とよくあるので、我ながら重宝しています。

もしよろしければ、使ってみてフィードバックなどいただけるとありがたいです。