SymfonyWorld Online 2020
100% online
30+ talks + workshops
Live + Replay watch talks later

ステップ 17: テストをする

5.0 version
Maintained

テストをする

アプリケーションにどんどん機能を追加し始めているので、テストについて話す適切なタイミングでしょう。

面白いことに、このチャプターでテストを書いている時に私はバグを見つけました。

Symfony は、PHPUnit を使ってユニットテストをしています。インストールしておきましょう:

1
$ symfony composer req phpunit

ユニットテストを書く

SpamChecker が、最初にテストを書くクラスです。ユニットテストを生成します:

1
$ symfony console make:unit-test SpamCheckerTest

SpamChecker のテストで Akismet API を叩かないようにするのは少々困難です。ここでは、Akismet API を モック します。

API がエラーを返した際のテストを書いてみましょう:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
--- a/tests/SpamCheckerTest.php
+++ b/tests/SpamCheckerTest.php
@@ -2,12 +2,26 @@

 namespace App\Tests;

+use App\Entity\Comment;
+use App\SpamChecker;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\MockResponse;
+use Symfony\Contracts\HttpClient\ResponseInterface;

 class SpamCheckerTest extends TestCase
 {
-    public function testSomething()
+    public function testSpamScoreWithInvalidRequest()
     {
-        $this->assertTrue(true);
+        $comment = new Comment();
+        $comment->setCreatedAtValue();
+        $context = [];
+
+        $client = new MockHttpClient([new MockResponse('invalid', ['response_headers' => ['x-akismet-debug-help: Invalid key']])]);
+        $checker = new SpamChecker($client, 'abcde');
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Unable to check for spam: invalid (Invalid key).');
+        $checker->getSpamScore($comment, $context);
     }
 }

MockHttpClient クラスを使えば、 HTTP server をモックすることができます。 MockHttpClient は、期待するボディとレスポンスヘッダーを含んでいる MockResponse インスタンスの配列を取ります。

getSpamScore() メソッドを呼び出し、PHPUnit の exepectException() メソッドから例外が投げられたかチェックします。

テストを実行し、成功することを確認してください:

1
$ symfony php bin/phpunit

正常系のテストを追加してください:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
--- a/tests/SpamCheckerTest.php
+++ b/tests/SpamCheckerTest.php
@@ -24,4 +24,32 @@ class SpamCheckerTest extends TestCase
         $this->expectExceptionMessage('Unable to check for spam: invalid (Invalid key).');
         $checker->getSpamScore($comment, $context);
     }
+
+    /**
+     * @dataProvider getComments
+     */
+    public function testSpamScore(int $expectedScore, ResponseInterface $response, Comment $comment, array $context)
+    {
+        $client = new MockHttpClient([$response]);
+        $checker = new SpamChecker($client, 'abcde');
+
+        $score = $checker->getSpamScore($comment, $context);
+        $this->assertSame($expectedScore, $score);
+    }
+
+    public function getComments(): iterable
+    {
+        $comment = new Comment();
+        $comment->setCreatedAtValue();
+        $context = [];
+
+        $response = new MockResponse('', ['response_headers' => ['x-akismet-pro-tip: discard']]);
+        yield 'blatant_spam' => [2, $response, $comment, $context];
+
+        $response = new MockResponse('true');
+        yield 'spam' => [1, $response, $comment, $context];
+
+        $response = new MockResponse('false');
+        yield 'ham' => [0, $response, $comment, $context];
+    }
 }

PHPUnit のデータプロバイダーを使うと、複数のテストケースで同じテストのロジックを再利用することができます:

コントローラーのファンクショナルテストを書く

コントローラーのテストは 一般的な PHP のクラスのテストとは少し異なります。コントローラーのテストでは、 HTTP リクエストのコンテキスト内で実行する必要があるからです。

ファンクショナルテストで必要になる依存パッケージをインストールしてください:

1
$ symfony composer require browser-kit --dev

Conference コントローラーのファンクショナルテストを作成してください:

tests/Controller/ConferenceControllerTest.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class ConferenceControllerTest extends WebTestCase
{
    public function testIndex()
    {
        $client = static::createClient();
        $client->request('GET', '/');

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('h2', 'Give your feedback');
    }
}

最初のテストは、ホームページが HTTP Response が 200 を返すか調べることです。

$client 変数は、ブラウザをシミュレートします。 サーバーへのHTTP 呼び出しをするのではなく、 Symfony アプリケーションを直接呼び出します。この方法を使うことの利点は次の通りです。クライアントとサーバーの間の往復をしないので処理が速くなることです。そして、各HTTPリクエストの後のサービスの状態を調べるテストが可能になることです。

PHPUnit のみならず、さらに assertResponseIsSuccessful のようなアサーションを使うことで確認作業が楽になります。Symofny によって定義されたこういったアサーションはたくさんあります。

ちなみに

ルーターから生成するのではなく、 / を URL として使ってきました。エンドユーザーの URL をテストとするため、故意にそうしていました。ルートパスを変更すると、テストは失敗するようになります。そして、失敗することが、サーチエンジンや Web サイトにリンクがあった際に、古い URL を新しい URL にリダイレクトさせるようにするべきということに気づくリマンドになります。

注釈

Maker バンドルからテストを生成することも可能です:

1
$ symfony console make:functional-test Controller\\ConferenceController

PHPUnit テストは、専用の test 環境で実行されます。 この環境用の AKISMET_KEY シークレットをセットする必要があります:

1
$ APP_ENV=test symfony console secrets:set AKISMET_KEY

クラスへのパスを渡して、新しいテストを実行してください:

1
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

ちなみに

テストが失敗した際は、レスポンスオブジェクトを調べると良いです。 $client->getResponse() でレスポンスオブジェクトを取得し echo してどうなっているか確認してください。

フィクスチャを定義する

コメントの一覧、ページネーション、フォーム投稿のテストをするには、データをデータベースへ投入する必要があります。そして、テスト実行の間同じデータにしておきたいです。このニーズを満たしてくれるフィクスチャの出番です。

Doctrine Fixture bundle をインストールしてください:

1
$ symfony composer req orm-fixtures --dev

インストールすると、 src/DataFixtures/ ディレクトリとサンプルクラスが作成されますので、カスタマイズしてください。ここでは、カンファレンスを2つ、コメントを1つ追加します:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
--- a/src/DataFixtures/AppFixtures.php
+++ b/src/DataFixtures/AppFixtures.php
@@ -2,6 +2,8 @@

 namespace App\DataFixtures;

+use App\Entity\Comment;
+use App\Entity\Conference;
 use Doctrine\Bundle\FixturesBundle\Fixture;
 use Doctrine\Common\Persistence\ObjectManager;

@@ -9,8 +11,24 @@ class AppFixtures extends Fixture
 {
     public function load(ObjectManager $manager)
     {
-        // $product = new Product();
-        // $manager->persist($product);
+        $amsterdam = new Conference();
+        $amsterdam->setCity('Amsterdam');
+        $amsterdam->setYear('2019');
+        $amsterdam->setIsInternational(true);
+        $manager->persist($amsterdam);
+
+        $paris = new Conference();
+        $paris->setCity('Paris');
+        $paris->setYear('2020');
+        $paris->setIsInternational(false);
+        $manager->persist($paris);
+
+        $comment1 = new Comment();
+        $comment1->setConference($amsterdam);
+        $comment1->setAuthor('Fabien');
+        $comment1->setEmail('[email protected]');
+        $comment1->setText('This was a great conference.');
+        $manager->persist($comment1);

         $manager->flush();
     }

フィクスチャをロードすると、管理者ユーザーも含め、全てのデータは削除されます。フィクスチャに管理者ユーザーも追加しておきましょう:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
--- a/src/DataFixtures/AppFixtures.php
+++ b/src/DataFixtures/AppFixtures.php
@@ -2,13 +2,22 @@

 namespace App\DataFixtures;

+use App\Entity\Admin;
 use App\Entity\Comment;
 use App\Entity\Conference;
 use Doctrine\Bundle\FixturesBundle\Fixture;
 use Doctrine\Common\Persistence\ObjectManager;
+use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;

 class AppFixtures extends Fixture
 {
+    private $encoderFactory;
+
+    public function __construct(EncoderFactoryInterface $encoderFactory)
+    {
+        $this->encoderFactory = $encoderFactory;
+    }
+
     public function load(ObjectManager $manager)
     {
         $amsterdam = new Conference();
@@ -30,6 +39,12 @@ class AppFixtures extends Fixture
         $comment1->setText('This was a great conference.');
         $manager->persist($comment1);

+        $admin = new Admin();
+        $admin->setRoles(['ROLE_ADMIN']);
+        $admin->setUsername('admin');
+        $admin->setPassword($this->encoderFactory->getEncoder(Admin::class)->encodePassword('admin', null));
+        $manager->persist($admin);
+
         $manager->flush();
     }
 }

ちなみに

実行しようとするタスクで、どのサービスが必要か覚えていないときは、キーワードと debug:autowiring で確認してください:

1
$ symfony console debug:autowiring encoder

フィクスチャをロードする

フィクスチャをデータベースへロードしてください。データベースに格納されている 全ての データを削除するので、 注意してください 。削除したくない際の方法は後に説明します。

1
$ symfony console doctrine:fixtures:load

ファンクショナルテスト内で Web サイトをクロールする

これまで見てきたように、テストで使用する HTTP クライアントは、ブラウザをシミュレートしますので、ヘッドレスブラウザを使っているかのように Webサイトをナビゲートすることができます。

ホームページから特定のカンファレンスページをクリックするテストを新しく追加してください:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -14,4 +14,19 @@ class ConferenceControllerTest extends WebTestCase
         $this->assertResponseIsSuccessful();
         $this->assertSelectorTextContains('h2', 'Give your feedback');
     }
+
+    public function testConferencePage()
+    {
+        $client = static::createClient();
+        $crawler = $client->request('GET', '/');
+
+        $this->assertCount(2, $crawler->filter('h4'));
+
+        $client->clickLink('View');
+
+        $this->assertPageTitleContains('Amsterdam');
+        $this->assertResponseIsSuccessful();
+        $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
+        $this->assertSelectorExists('div:contains("There are 1 comments")');
+    }
 }

このテストで何が行われたかを説明しましょう:

  • 最初のテストのようにホームページを開きます;
  • request() メソッドは、ページ内の要素(リンクやフォームなど CSS セレクターや XPath で探せるもの全て)を探すのに便利な Crawler インスタンスを返します;
  • CSS セレクターを使って、ホームページにカンファレンスが2つ表示されているのを確認することができます;
  • そして、 "View" リンクをクリックします(同時に複数のリンクをクリックできないので、 Symfony は最初に見つけたリンクを選択します);
  • ページタイトル、レスポンス、ページの <h2> が正しいページのものであるかアサートします(ルートがマッチするかも確認することができます);
  • 最後に、ページにコメントが1つあることをアサートします。 div:contains() は、CSSセレクターとしては無効ですが、 Symfony には jQuery の機能から一部持ってきた便利な追加機能があります。

テキスト(すなわち View)をクリックしなくても、 CSSセレクターを使ってリンクを選択することもできます:

1
$client->click($crawler->filter('h4 + p a')->link());

新しいテストが通ることを確認してください:

1
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

テストデータベースを使う

デフォルトでは、phpunit.xml.dist ファイルでSymfony 環境を test として定義しており、test 環境でテストは実行されます:

phpunit.xml.dist
1
2
3
4
5
<phpunit>
    <php>
        <server name="APP_ENV" value="test" force="true" />
    </php>
</phpunit>

テストで別のデータベースを使用するには、 .env.test ファイルにある DATABASE_URL 環境変数をオーバーライドしてください:

1
2
3
4
5
6
7
8
--- a/.env.test
+++ b/.env.test
@@ -1,4 +1,5 @@
 # define your env variables for the test env here
+DATABASE_URL=postgres://main:[email protected]:32773/test?sslmode=disable&charset=utf8
 KERNEL_CLASS='App\Kernel'
 APP_SECRET='$ecretf0rt3st'
 SYMFONY_DEPRECATIONS_HELPER=999999

test 環境のデータベースにフィクスチャをロードしてください:

1
$ APP_ENV=test symfony console doctrine:fixtures:load

このステップの残りの場所では、環境変数 DATABAE_URL は再定義しません。テストするのに dev 環境と同じデータベースを使用するといくつかの利点があります。次のセクションで紹介します。

ファンクショナルテストでフォームを投稿する

フォームの投稿をシミュレートしてカンファレンスに写真付きのコメントを追加してみましょう。以下の必要なコードを見てください。今までに書いたものと同じように複雑ではありません:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -29,4 +29,19 @@ class ConferenceControllerTest extends WebTestCase
         $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
         $this->assertSelectorExists('div:contains("There are 1 comments")');
     }
+
+    public function testCommentSubmission()
+    {
+        $client = static::createClient();
+        $client->request('GET', '/conference/amsterdam-2019');
+        $client->submitForm('Submit', [
+            'comment_form[author]' => 'Fabien',
+            'comment_form[text]' => 'Some feedback from an automated functional test',
+            'comment_form[email]' => '[email protected]',
+            'comment_form[photo]' => dirname(__DIR__, 2).'/public/images/under-construction.gif',
+        ]);
+        $this->assertResponseRedirects();
+        $client->followRedirect();
+        $this->assertSelectorExists('div:contains("There are 2 comments")');
+    }
 }

submitForm() でフォームをサブミットするのに、ブラウザの 開発ツールもしくは、Symfonyのプロファイラパネルから input の名前を見つけてください。工事中のイメージが再利用されているのに気づきましたか?

テストをもう一度実行し、全てパスすることを確認してください:

1
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

テストをするのに "dev" データベースを使用する利点の1つは、ブラウザで結果を確認できることです:

フィクスチャをリロードする

テストをもう一度走らせると、テストは失敗します。それは、データベースにコメントが追加されたからで、コメントの数を調べるアサーションが壊れてしまっているからです。テスト実行の前にフィクスチャをリロードして、テスト実行毎にデータベースの状態をリセットする必要があります:

1
2
$ symfony console doctrine:fixtures:load
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

Makefile を使ってワークフローを自動化する

テスト実行のコマンドの順番を覚えておく必要があるのは、面倒ですね。少なくともドキュメント化しておいて欲しいですが、ドキュメントは最後の手段ですので別の方法を考えましょう。毎日のアクティビティを自動化することでドキュメントとしても役立ちます。こうすることで、他の開発者が見つけやすくなったり、助けになります。

コマンドを自動化する方法の1つとして、Makefile を使用します:

Makefile
1
2
3
4
5
6
SHELL := /bin/bash

tests:
    symfony console doctrine:fixtures:load -n
    symfony php bin/phpunit
.PHONY: tests

Doctrine コマンドには、 -n フラグが付いています。これは、Symfony コマンドのグローバルなフラグで、インタラクティブにならないようにします。

テストを実行したいときは、 make tests を使用してください:

1
$ make tests

各テストの後にデータベースをリセットする

各テストを実行した後にデータベースをリセットするのは便利ですが、テストの依存を無くす方がベターです。前のテストの結果に次のテストを依存させるといったことはしたくはありません。テストの順番を変更しても結果は同じであるべきです。今のところは問題となっていませんが、ここで見てみましょう。

testConferencePage` テストを testCommentSubmission テストの後に移動してください:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -15,21 +15,6 @@ class ConferenceControllerTest extends WebTestCase
         $this->assertSelectorTextContains('h2', 'Give your feedback');
     }

-    public function testConferencePage()
-    {
-        $client = static::createClient();
-        $crawler = $client->request('GET', '/');
-
-        $this->assertCount(2, $crawler->filter('h4'));
-
-        $client->clickLink('View');
-
-        $this->assertPageTitleContains('Amsterdam');
-        $this->assertResponseIsSuccessful();
-        $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
-        $this->assertSelectorExists('div:contains("There are 1 comments")');
-    }
-
     public function testCommentSubmission()
     {
         $client = static::createClient();
@@ -44,4 +29,19 @@ class ConferenceControllerTest extends WebTestCase
         $crawler = $client->followRedirect();
         $this->assertSelectorExists('div:contains("There are 2 comments")');
     }
+
+    public function testConferencePage()
+    {
+        $client = static::createClient();
+        $crawler = $client->request('GET', '/');
+
+        $this->assertCount(2, $crawler->filter('h4'));
+
+        $client->clickLink('View');
+
+        $this->assertPageTitleContains('Amsterdam');
+        $this->assertResponseIsSuccessful();
+        $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
+        $this->assertSelectorExists('div:contains("There are 1 comments")');
+    }
 }

テストは失敗するようになりました。

テスト間でデータベースをリセットするには、 DoctrineTestBundle をインストールしてください:

1
$ symfony composer require dama/doctrine-test-bundle --dev

DoctrineTestBundle は、"公式に" サポートされたバンドルではないので、レシピの実行を確認する必要があります:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Symfony operations: 1 recipe (d7f110145ba9f62430d1ad64d57ab069)
  -  WARNING  dama/doctrine-test-bundle (>=4.0): From github.com/symfony/recipes-contrib:master
    The recipe for this package comes from the "contrib" repository, which is open to community contributions.
    Review the recipe at https://github.com/symfony/recipes-contrib/tree/master/dama/doctrine-test-bundle/4.0

    Do you want to execute this recipe?
    [y] Yes
    [n] No
    [a] Yes for all packages, only for the current installation session
    [p] Yes permanently, never ask again for this project
    (defaults to n): p

PHPUnitリスナーを有効化してください:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -27,6 +27,10 @@
         </whitelist>
     </filter>

+    <extensions>
+        <extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" />
+    </extensions>
+
     <listeners>
         <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
     </listeners>

これで準備ができました。テストに変更があると、自動的に各テストの最後にロールバックするようになりました。

テストは再びグリーンになったはずです:

1
$ make tests

実際のブラウザを使用して、ファンクショナルテストをする

ファンクショナルテストは、Symfony を直接呼び出す特別なブラウザを使用しています。しかし、Symfony Panther を使えば、実際のブラウザと HTTP を使うことが可能です:

警告

このパラグラフを書いたときは、依存関係が一つ未解決だったため、 Symfony5 プロジェクトで Panther のインストールはできませんでした。

1
$ symfony composer req panther --dev

次の変更を、Google Chrome のブラウザを使用してテストを書くことができます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -2,13 +2,13 @@

 namespace App\Tests\Controller;

-use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+use Symfony\Component\Panther\PantherTestCase;

-class ConferenceControllerTest extends WebTestCase
+class ConferenceControllerTest extends PantherTestCase
 {
     public function testIndex()
     {
-        $client = static::createClient();
+        $client = static::createPantherClient(['external_base_uri' => $_SERVER['SYMFONY_DEFAULT_ROUTE_URL']]);
         $client->request('GET', '/');

         $this->assertResponseIsSuccessful();

環境変数 SYMFONY_DEFAULT_ROUTE_URL には、ローカルの Webサーバーの URL が入っています。

Blackfire でブラックボックスなファンクショナルテストを実行する

Blackfire player を使ってファンクショナルテストを実行することもできます。そうすれば、ファンクショナルテストに加えて、パフォーマンステストもすることができます。

詳細を知るには、 "パフォーマンス" のステップを参照してください。


  • « Previous ステップ 16: API でスパム対策をする
  • Next » ステップ 18: 非同期にする

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.