步骤 17: 测试

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

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

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

  1. $ symfony composer req phpunit --dev

编写单元测试

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

  1. $ symfony console make:unit-test SpamCheckerTest

测试 SpamChecker 类有点挑战,因为我们当然不想测试的时候去调用 Akismet 的 API。我们会去 模拟 这个 API。

我们来测试下 API 返回错误的情况来作为第一个例子:

patch_file

  1. --- a/tests/SpamCheckerTest.php
  2. +++ b/tests/SpamCheckerTest.php
  3. @@ -2,12 +2,26 @@
  4. namespace App\Tests;
  5. +use App\Entity\Comment;
  6. +use App\SpamChecker;
  7. use PHPUnit\Framework\TestCase;
  8. +use Symfony\Component\HttpClient\MockHttpClient;
  9. +use Symfony\Component\HttpClient\Response\MockResponse;
  10. +use Symfony\Contracts\HttpClient\ResponseInterface;
  11. class SpamCheckerTest extends TestCase
  12. {
  13. - public function testSomething()
  14. + public function testSpamScoreWithInvalidRequest()
  15. {
  16. - $this->assertTrue(true);
  17. + $comment = new Comment();
  18. + $comment->setCreatedAtValue();
  19. + $context = [];
  20. +
  21. + $client = new MockHttpClient([new MockResponse('invalid', ['response_headers' => ['x-akismet-debug-help: Invalid key']])]);
  22. + $checker = new SpamChecker($client, 'abcde');
  23. +
  24. + $this->expectException(\RuntimeException::class);
  25. + $this->expectExceptionMessage('Unable to check for spam: invalid (Invalid key).');
  26. + $checker->getSpamScore($comment, $context);
  27. }
  28. }

MockHttpClient 类可用来模拟测试任何 HTTP 服务器。它接收一个元素为 MockResponse 实例的数组,这个数组包含期待的 HTTP 应答头和应答体。

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

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

  1. $ symfony php bin/phpunit

我们来增加测试用来,来测试代码正常运行的情况:

patch_file

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

PHPUnit 的 data providers 让我们可以在多个测试用例中复用同一个测试逻辑。

为控制器编写功能测试

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

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

tests/Controller/ConferenceControllerTest.php

  1. namespace App\Tests\Controller;
  2. use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
  3. class ConferenceControllerTest extends WebTestCase
  4. {
  5. public function testIndex()
  6. {
  7. $client = static::createClient();
  8. $client->request('GET', '/');
  9. $this->assertResponseIsSuccessful();
  10. $this->assertSelectorTextContains('h2', 'Give your feedback');
  11. }
  12. }

Symfony\Bundle\FrameworkBundle\Test\WebTestCase 来代替 PHPUnit\Framework\TestCase 作为测试的基类,这为我们的功能测试提供了一层抽象。

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

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

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

小技巧

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

注解

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

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

配置测试环境

默认情况下,PHPUnit 的测试运行在 Symfony 的 test 环境下,这是由 PHPUnit 的配置文件定义的:

phpunit.xml.dist

  1. <phpunit>
  2. <php>
  3. <ini name="error_reporting" value="-1" />
  4. <server name="APP_ENV" value="test" force="true" />
  5. <server name="SHELL_VERBOSITY" value="-1" />
  6. <server name="SYMFONY_PHPUNIT_REMOVE" value="" />
  7. <server name="SYMFONY_PHPUNIT_VERSION" value="8.5" />
  8. </php>
  9. </phpunit>

为了使测试能够运行,我们必须为 test 环境设置 AKISMET_KEY 这个秘钥:

  1. $ APP_ENV=test symfony console secrets:set AKISMET_KEY

注解

正如前面有一章里所示,APP_ENV=test 意味着 APP_ENV 环境变量是在命令的上下文中设置的。在 Windows 系统上要用 --env=testsymfony console secrets:set AKISMET_KEY --env=test

使用测试数据库

正如我们看到的,Symfony 的命令行会自动暴露 DATABASE_URL 这个环境变量。当 APP_ENV 的值是 test 时,比如在运行 PHPUnit 时设置的那样,它会把数据库的名字从 main 改为 main_test,这样的话测试会使用它们专门的数据库。这是很重要的,因为我们需要有稳定的数据来进行测试,而且我们当然也不希望测试会改写我们在开发环境数据库里存储的内容。

在可以运行测试之前,我们需要“初始化” test 数据库(创建这个数据库并对它迁移):

  1. $ APP_ENV=test symfony console doctrine:database:create
  2. $ APP_ENV=test symfony console doctrine:migrations:migrate -n

如果你现在运行测试,PHPUnit 不会再影响到开发环境里的数据库。如果只是运行新的测试,就要传入这些类的路径:

  1. $ APP_ENV=test symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

请注意,即便在运行 PHPUnit 时,我们也明确设置了 APP_ENV,这样 Symfony 命令行才能把数据库名设置为 main_test

小技巧

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

定义 Fixtures 数据

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

安装 Doctrine Fixtures 这个 bundle:

  1. $ symfony composer req orm-fixtures --dev

安装的过程会创建新目录 src/DataFixtures/,里面有一个样例类,你可以去定制它。现在先加上 2 个会议和 1 条评论:

patch_file

  1. --- a/src/DataFixtures/AppFixtures.php
  2. +++ b/src/DataFixtures/AppFixtures.php
  3. @@ -2,6 +2,8 @@
  4. namespace App\DataFixtures;
  5. +use App\Entity\Comment;
  6. +use App\Entity\Conference;
  7. use Doctrine\Bundle\FixturesBundle\Fixture;
  8. use Doctrine\Persistence\ObjectManager;
  9. @@ -9,8 +11,24 @@ class AppFixtures extends Fixture
  10. {
  11. public function load(ObjectManager $manager)
  12. {
  13. - // $product = new Product();
  14. - // $manager->persist($product);
  15. + $amsterdam = new Conference();
  16. + $amsterdam->setCity('Amsterdam');
  17. + $amsterdam->setYear('2019');
  18. + $amsterdam->setIsInternational(true);
  19. + $manager->persist($amsterdam);
  20. +
  21. + $paris = new Conference();
  22. + $paris->setCity('Paris');
  23. + $paris->setYear('2020');
  24. + $paris->setIsInternational(false);
  25. + $manager->persist($paris);
  26. +
  27. + $comment1 = new Comment();
  28. + $comment1->setConference($amsterdam);
  29. + $comment1->setAuthor('Fabien');
  30. + $comment1->setEmail('[email protected]');
  31. + $comment1->setText('This was a great conference.');
  32. + $manager->persist($comment1);
  33. $manager->flush();
  34. }

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

  1. --- a/src/DataFixtures/AppFixtures.php
  2. +++ b/src/DataFixtures/AppFixtures.php
  3. @@ -2,13 +2,22 @@
  4. namespace App\DataFixtures;
  5. +use App\Entity\Admin;
  6. use App\Entity\Comment;
  7. use App\Entity\Conference;
  8. use Doctrine\Bundle\FixturesBundle\Fixture;
  9. use Doctrine\Persistence\ObjectManager;
  10. +use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
  11. class AppFixtures extends Fixture
  12. {
  13. + private $encoderFactory;
  14. +
  15. + public function __construct(EncoderFactoryInterface $encoderFactory)
  16. + {
  17. + $this->encoderFactory = $encoderFactory;
  18. + }
  19. +
  20. public function load(ObjectManager $manager)
  21. {
  22. $amsterdam = new Conference();
  23. @@ -30,6 +39,12 @@ class AppFixtures extends Fixture
  24. $comment1->setText('This was a great conference.');
  25. $manager->persist($comment1);
  26. + $admin = new Admin();
  27. + $admin->setRoles(['ROLE_ADMIN']);
  28. + $admin->setUsername('admin');
  29. + $admin->setPassword($this->encoderFactory->getEncoder(Admin::class)->encodePassword('admin', null));
  30. + $manager->persist($admin);
  31. +
  32. $manager->flush();
  33. }
  34. }

小技巧

如果你不确定要为某个任务使用哪个服务,可以用 debug:autowiring,再加上一些关键词:

  1. $ symfony console debug:autowiring encoder

载入 fixture 数据

test 环境对应的数据库载入 fixture 数据:

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

在功能测试中爬取网站

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

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

patch_file

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

让我们用大白话来描述下这个测试做了什么:

  • 就像第一个测试那样,我们来到首页;
  • 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. $ APP_ENV=test symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

在功能测试中提交表单

你想把水平再提高一个台阶吗?试试看在测试中模拟一次表单提交,提交的数据是一条评论和一个会议的照片。这看上去很有雄心壮志,不是吗?看一下所需的代码,它并不比我们已经写过的更复杂:

patch_file

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

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

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

  1. $ APP_ENV=test symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

如果你想要在浏览器中检查结果,停止 Web 服务器,并且在 test 环境下重新运行它:

  1. $ symfony server:stop
  2. $ APP_ENV=test symfony server:start -d

步骤 17: 测试 - 图1

重新载入 fixture 数据

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

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

用 Makefile 来自动化你的工作流

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

使用 Makefile 就是自动化执行一组命令的方法之一:

Makefile

  1. SHELL := /bin/bash
  2. tests: export APP_ENV=test
  3. tests:
  4. symfony console doctrine:database:drop --force || true
  5. symfony console doctrine:database:create
  6. symfony console doctrine:migrations:migrate -n
  7. symfony console doctrine:fixtures:load -n
  8. symfony php bin/phpunit [email protected]
  9. .PHONY: tests

警告

在一个 Makefile 规则里,缩进 必须 由单独一个 tab 组成,而不是由空格组成。

注意 Doctrine 命令里的 -n 选项,它是 Symfony 命令的一个全局选项,让命令采用非交互模式运行。

无论何时你要进行测试,就使用 make tests

  1. $ make tests

在每次测试后重置数据库

每次测试后重置数据库,这样很好,但是让测试真正独立运行,那就更好了。我们不希望一个测试依赖于前面测试的结果。改变测试的顺序不应该改变结果。现在我们会看到,目前这一点还没有做到。

testConferencePage 测试移动到 testCommentSubmission 测试后面:

patch_file

  1. --- a/tests/Controller/ConferenceControllerTest.php
  2. +++ b/tests/Controller/ConferenceControllerTest.php
  3. @@ -15,21 +15,6 @@ class ConferenceControllerTest extends WebTestCase
  4. $this->assertSelectorTextContains('h2', 'Give your feedback');
  5. }
  6. - public function testConferencePage()
  7. - {
  8. - $client = static::createClient();
  9. - $crawler = $client->request('GET', '/');
  10. -
  11. - $this->assertCount(2, $crawler->filter('h4'));
  12. -
  13. - $client->clickLink('View');
  14. -
  15. - $this->assertPageTitleContains('Amsterdam');
  16. - $this->assertResponseIsSuccessful();
  17. - $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
  18. - $this->assertSelectorExists('div:contains("There are 1 comments")');
  19. - }
  20. -
  21. public function testCommentSubmission()
  22. {
  23. $client = static::createClient();
  24. @@ -44,4 +29,19 @@ class ConferenceControllerTest extends WebTestCase
  25. $client->followRedirect();
  26. $this->assertSelectorExists('div:contains("There are 2 comments")');
  27. }
  28. +
  29. + public function testConferencePage()
  30. + {
  31. + $client = static::createClient();
  32. + $crawler = $client->request('GET', '/');
  33. +
  34. + $this->assertCount(2, $crawler->filter('h4'));
  35. +
  36. + $client->clickLink('View');
  37. +
  38. + $this->assertPageTitleContains('Amsterdam');
  39. + $this->assertResponseIsSuccessful();
  40. + $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
  41. + $this->assertSelectorExists('div:contains("There are 1 comments")');
  42. + }
  43. }

现在测试不能通过了。

为了在测试之间重置数据库,我们来安装 DoctrineTestBundle:

  1. $ symfony composer req "dama/doctrine-test-bundle:^6" --dev

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

  1. Symfony operations: 1 recipe (d7f110145ba9f62430d1ad64d57ab069)
  2. - WARNING dama/doctrine-test-bundle (>=4.0): From github.com/symfony/recipes-contrib:master
  3. The recipe for this package comes from the "contrib" repository, which is open to community contributions.
  4. Review the recipe at https://github.com/symfony/recipes-contrib/tree/master/dama/doctrine-test-bundle/4.0
  5. Do you want to execute this recipe?
  6. [y] Yes
  7. [n] No
  8. [a] Yes for all packages, only for the current installation session
  9. [p] Yes permanently, never ask again for this project
  10. (defaults to n): p

启用 PHPUnit 的监听器:

patch_file

  1. --- a/phpunit.xml.dist
  2. +++ b/phpunit.xml.dist
  3. @@ -27,6 +27,10 @@
  4. </whitelist>
  5. </filter>
  6. + <extensions>
  7. + <extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" />
  8. + </extensions>
  9. +
  10. <listeners>
  11. <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
  12. </listeners>

完成了。对数据库所做的任何改变在测试结束时都会自动回滚。

测试应该再次通过了:

  1. $ make tests

用真正的浏览器来进行功能测试

功能测试用一个特殊的浏览器来直接调用 Symfony 层。但你也可以借助 Symfony Panther,用一个真正的浏览器和真正的 HTTP 层。

  1. $ symfony composer req panther --dev

进行如下改动,你就可以让测试调用真正的谷歌 Chrome 浏览器:

  1. --- a/tests/Controller/ConferenceControllerTest.php
  2. +++ b/tests/Controller/ConferenceControllerTest.php
  3. @@ -2,13 +2,13 @@
  4. namespace App\Tests\Controller;
  5. -use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
  6. +use Symfony\Component\Panther\PantherTestCase;
  7. -class ConferenceControllerTest extends WebTestCase
  8. +class ConferenceControllerTest extends PantherTestCase
  9. {
  10. public function testIndex()
  11. {
  12. - $client = static::createClient();
  13. + $client = static::createPantherClient(['external_base_uri' => $_SERVER['SYMFONY_PROJECT_DEFAULT_ROUTE_URL']]);
  14. $client->request('GET', '/');
  15. $this->assertResponseIsSuccessful();

SYMFONY_PROJECT_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.