步骤 17: 测试

5.0 version
Maintained

测试

我们开始在程序中加入越来越多的功能,很可能现在是谈谈测试的时候了。

一件有趣的事:我在本章中写测试的时候,发现了之前的一个错误。

Symfony 使用 PHPUnit 进行单元测试。我们来安装它:

1
$ symfony composer req phpunit

编写单元测试

SpamChecker 是我们第一个要测试的类。生成一个单元测试:

1
$ symfony console make:unit-test SpamCheckerTest

测试 SpamChecker 类有点挑战,因为我们当然不想测试的时候去调用 Akismet 的 API。我们会去 模拟 这个 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 服务器。它接收一个元素为 MockResponse 实例的数组,这个数组包含期待的 HTTP 应答头和应答体。

然后,我们调用 getSpamScore() 方法,再用 PHPUnit 的 expectException() 方法来检查是否有异常抛出。

运行测试来检查它们是否通过:

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 的 data providers 让我们可以在多个测试用例中复用同一个测试逻辑。

为控制器编写功能测试

测试控制器与测试一个“常规”的 PHP 类稍有不同,因为我们要在一个 HTTP 请求上下文中来测试控制器。

为功能测试安装一些另外的依赖包:

1
$ symfony composer require browser-kit --dev

为会议的控制器创建一个功能测试:

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');
    }
}

这第一个测试先检查首页是否返回 200 状态码的 HTTP 应答。

$client 变量模拟一个浏览器。它会直接调用 Symfony 应用,而不是发送 HTTP 请求给 web 服务器。这个策略有一些优点:相比客户端和服务器端之间的来来回回,它更加快速;而且每次 HTTP 请求完成后,测试可以探查到服务的状态。

类似 assertResponseIsSuccessful 这样的断言被包装在 PHPUnit 之上,以便简化你的测试工作。Symfony 定义了很多这样的断言。

小技巧

我们硬编码了 / 这个 URL,而非用路由来生成它。这样做是有意为之的,因为测试用户所使用的URL也是我们测试工作的一部分。如果修改了路由的路径,测试就会失败,这会是个很好的提醒,让你知道或许很有必要把老的 URL 重定向到新的 URL,这样对于搜索引擎和已经链接到你网站的第三方网站会比较友好。

注解

我们其实也可以用 Maker Bundle 来生成这个测试:

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

小技巧

当一个测试失败时,探查应答对象会很有用。用 echo 来输出 $client->getResponse(),看看它里面有什么。

定义 Fixtures 数据

我们需要用一些数据来填充数据库,这样才能测试评论列表、分页和表单提交。我们需要在不同的测试间确保数据是一样的。Fixtures 就是我们需要的。

安装 Doctrine Fixtures 这个 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();
     }

当我们载入 fixture 数据时,所有之前的数据都会被移除,包括管理员账户的数据。为了避免这种情况,让我们来把管理员账户加进 fixture 里:

 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

载入 fixture 数据

把fixture数据载入数据库。再 警告 一次,这会清空数据库里目前的 所有 数据(如果你想避免这一点,请接着读下去)。

1
$ symfony console doctrine:fixtures:load

在功能测试中爬取网站

如我们看到的那样,测试中用到的 HTTP 客户端会模拟浏览器,所以我们可以用它来浏览网站,就好像用一个无头浏览器一样。

新建一个测试,用它在首页上点击一个会议页面的链接。

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() 方法返回一个 Crawler 实例,该实例可以用来找到页面中的元素(比如链接、表单或任何你可以用 CSS 选择器或 XPath 找到的元素)。
  • 借助于 CSS 选择器,我们断言首页上列出了 2 个会议;
  • 然后我们点击 “View” 链接(Symfony 不能同时点击多于一个链接,所以它会自动选择找到的第一个链接);
  • 我们验证了页面标题,返回的应答和页面的 <h2> 标签,以确保我们是在正确的页面上(我们也可以验证匹配的路由);
  • 最后,我们验证页面上有一条评论。div:contains() 并不是合规的 CSS 选择器,但 Symfony 借鉴了 jQuery,对 CSS 选择器做了一些增强。

我们也可以用 CSS 选择器来选择一个链接,而不是通过点击链接文本(也就是本例中的 View):

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

检查新的测试能否通过:

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

使用测试数据库

默认情况下,测试是在 test 环境下运行的,这个环境在 phpunit.xml.dist 文件中定义:

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 环境对应的数据库载入 fixture 数据:

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

在后续的步骤中,我们不会重新定义 DATABASE_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")');
+    }
 }

先用浏览器的开发者工具或者 Symfony 分析器里的 Form 面板来找到 input 元素的名字,然后才能用 submitForm() 方法来提交表单。注意我们聪明地重用了“在建中”图片!

再运行下测试,检查测试是否通过:

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

dev 数据库的好处之一,就是你能在浏览器中查看结果:

重新载入 fixture 数据

如果你再运行一次测试,它应该会失败。由于现在数据库里有更多的评论,检查评论数量的断言就不成立了。我们需要在多次测试之间重置数据库的状态,这是通过在每次测试之前重新载入 fixture 数据来实现的。

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

用 Makefile 来自动化你的工作流

被迫记住一组命令才能运行测试很烦人。至少这些应该要记录在文档里。但文档应该是最后才考虑的方案。把日常的工作自动化,而不是用文档,你觉得如何?它能实现文档的目的,帮助其他开发者了解项目,也能让他们的生活更轻松,完成工作更迅速。

使用 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

你需要确认 recipe 的执行(因为它不是一个 官方 支持的 bundle):

 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 层。

警告

我写本书的时候,在 Symfony 5 上还不能安装 Panther,因为有一个依赖包还没有兼容 Symfony 5。

1
$ symfony composer req panther --dev

进行如下改动,你就可以让测试调用真正的谷歌 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 播放器。你除了能运行功能测试以外,还可以运行性能测试。

参考关于“性能”的步骤来了解更多这方面的内容。


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