测试

每当你多写一行代码,你都在增加潜在的新bug。为了打造更好、更好可靠的程序,你需要对代码使用功能测试和单元测试。

PHPUnit测试框架 

Symfony整合了一个独立类库 – 名为PHPUnit – 带给你丰富的测试框架。本章不覆盖PHPUnit本身,不过它有自己的 极佳文档

推荐使用最新的稳定版PHPUnit,安装的是PHAR

每一个测试 – 不管是单元测试(unit test)还是功能测试(functional test) - 都是一个PHP类,存放在你的Bundle的 Tests/ 子目录下。如果你遵守这一原则,那么运行所有的程序级测试时,只需以下命令即可:

1
$  phpunit

PHPunit通过Symfony根目录下的 phpunit.xml.dist 文件进行配置。

代码覆盖(code coverage)可以通过 —coverage-* 选项来生成。要查看帮助信息,可使用 —help 来显示更多内容。

单元测试 

单元测试是针对单一PHP类的测试,这个类也被称为“单元(unit)”。如果你需要测试你的应用程序层级的整体行为,参考[功能测试] (#catalog2)(Functional Tests)。

编写Symfony单元测试和编写标准的PHPUnit单元测试并无不同。假设,你有一个极其简单的类,类名是 Calculator ,位于app bundle的 Util/ 目录下:

// src/AppBundle/Util/Calculator.phpnamespace AppBundle\Util; class Calculator{
    public function add($a, $b)
    {
        return $a + $b;
    }}

为了测试它,创建一个 CalculatorTest 文件到你的bundle的 tests/AppBundle/Util  目录下:

// tests/AppBundle/Util/CalculatorTest.phpnamespace Tests\AppBundle\Util; use AppBundle\Util\Calculator; class CalculatorTest extends \PHPUnit_Framework_TestCase{
    public function testAdd()
    {
        $calc = new Calculator();
        $result = $calc->add(30, 12);         // assert that your calculator added the numbers correctly!
        $this->assertEquals(42, $result);
    }}


根据约定, Tests/AppBundle 目录应该复制你的bundle下的单元测试文件所在的目录。因此,如果你要测试的类位于 src/AppBundle/Util 目录下,那么就把测试代码放在 tests/AppBundle/Util/ 目录下。



就像你的真实程序一样 – 自动加载被自动开启了,通过 bootstrap.php.cache 文件来完成(这部分的配置默认是在app/phpunit.xml.dist文件中)

针对指定文件或目录的测试也很简单:

# run all tests of the application# 运行程序中的所有测试$  phpunit
 # run all tests in the Util directory# 运行指定目录下的所有测试$  phpunit tests/AppBundle/Util
 # run tests for the Calculator class# 仅运行Calculator类的测试$  phpunit tests/AppBundle/Util/CalculatorTest.php
 # run all tests for the entire Bundle# 运行整个bundle的测试$  phpunit tests/AppBundle/

功能测试 

功能测试(Functional tests)检查程序不同层面的整合情况(从路由到视图)。这些层面本身并不因PHPUnit的介入而发生改变,只不过它们有着独特的工作流:

  • 制造一个请求(request);

  • 测试响应(response);

  • 点击一个链接,或提交一个表单;

  • 测试响应;

  • 清除然后重复。

你的第一个功能测试 

功能测试是存放在 Test/AppBundle/Controller 目录下的普通PHP文件。如果要测试由你的 PostController 处理的页面,先创建一个新的PostControllerTest.php文件,并继承一个特殊的 WebTestCase 类。

作为例程,这个测试看起来可能像下面代码:

// tests/AppBundle/Controller/PostControllerTest.phpnamespace Tests\AppBundle\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class PostControllerTest extends WebTestCase{
    public function testShowPost()
    {
        $client = static::createClient();         $crawler = $client->request('GET', '/post/hello-world');         $this->assertGreaterThan(
            0,
            $crawler->filter('html:contains("Hello World")')->count()
        );
    }}


为了运行上面的功能测试,  WebTestCase 类启动了程序内核。在多数情况下,这是自动完成的。但如果kernel位于非标准目录,你需要调整 phpunit.xml.dist 文件,重新设置 KERNEL_DIR 环境变量为你的kernel目录:


<?xml version="1.0" charset="utf-8" ?><phpunit>
    <php>
        <server name="KERNEL_DIR" value="/path/to/your/app/" />
    </php>
    <!-- ... --></phpunit>

createClient() 方法返回了一个client,用它来模拟浏览器,使得你能抓取网站页面:

1
$crawler = $client->request('GET', '/post/hello-world');

request()方法 (参考 更多请求方法)返回了一个 Crawler 对象,用于从响应内容中选择元素(select elements),完成点击链接或提交表单这样的动作。

响应信息必须是XML或HTML文档格式,抓取器(Crawler)才会工作。若需要不带文档格式的纯响应内容(raw content),请使用 $client->getResponse()->getContent()

需要点击链接时,先用抓取器选择它,可使用XPath表达式或CSS拾取器来完成选 择,然后再使用client行为来点击它:

$link = $crawler
    ->filter('a:contains("Greet")') // 选择所有含有"Greet"文本之链接
    ->eq(1) // 结果列表中的第二个
    ->link() // 点击它; $crawler = $client->click($link);

提交表单时非常相似:选取一个表单按钮,可选地覆写某些表单项的值,然后提交对应的表单:

$form = $crawler->selectButton('submit')->form(); // 设置一些值$form['name'] = 'Lucas';$form['form_name[subject]'] = 'Hey there!'; // 提交表单$crawler = $client->submit($form);

表单也可以处理上传,它还包含了用于填写不同类型的表单字段的方法(例如 select()tick() )。有关细节请参考后面小节 表单(Forms)

现在你可以很容易地穿梭于程序中,使用断言来测试页面是否按你期待的那样来执行。使用抓取器(Crawler)可令断言作用于DOM之上。

// Assert that the response matches a given CSS selector.// 断言响应内容匹配到一个指定的CSS拾取器$this->assertGreaterThan(0, $crawler->filter('h1')->count());

或者直接测试响应内容,如果你只是想对内容中是否包含某些文本来断言的话,又或只需知道响应内容并非XML/HTML格式而已:

$this->assertContains(
    'Hello World',
    $client->getResponse()->getContent());

有用的断言

为了让你尽快掌握测试,下面的例子列出了最常用且最有用的测试断言:

use Symfony\Component\HttpFoundation\Response; // ... // Assert that there is at least one h2 tag// with the class "subtitle"// 断言至少有一个h2标签,其class是subtitle$this->assertGreaterThan(
    0,
    $crawler->filter('h2.subtitle')->count()); // Assert that there are exactly 4 h2 tags on the page// 断言页面有4个h2标签$this->assertCount(4, $crawler->filter('h2')); // Assert that the "Content-Type" header is "application/json"// 断言Content-Type头是application/json$this->assertTrue(
    $client->getResponse()->headers->contains(
        'Content-Type',
        'application/json'
    )); // Assert that the response content contains a string// 断言响应信息中包含一个字符串$this->assertContains('foo', $client->getResponse()->getContent());// ...or matches a regex$this->assertRegExp('/foo(bar)?/', $client->getResponse()->getContent()); // Assert that the response status code is 2xx// 断言响应状态码是2xx$this->assertTrue($client->getResponse()->isSuccessful());// Assert that the response status code is 404// 断言响应状态码是404$this->assertTrue($client->getResponse()->isNotFound());// Assert a specific 200 status code// 断言响应状态码是特殊的200$this->assertEquals(
    200, // or Symfony\Component\HttpFoundation\Response::HTTP_OK
    $client->getResponse()->getStatusCode()); // Assert that the response is a redirect to /demo/contact// 断言响应是一个指向/demo/contact的跳转$this->assertTrue(
    $client->getResponse()->isRedirect('/demo/contact'));// ...or simply check that the response is a redirect to any URL// 或者仅言响应是一个跳转,不管指向的URL是什么$this->assertTrue($client->getResponse()->isRedirect());

从Symfony 2.4开始引入HTTP状态码常量(HTTP status code constants)

使用Test Client 

测试客户端(test client)模拟了一个HTTP客户端,就像一个浏览器,可以产生针对你Symfony程序的请求。

1
$crawler = $client->request('GET', '/post/hello-world');

request() 方法接受一个HTTP请求方式的参数和一个URL参数,返回一个Crawler实例。

对于功能测试来说,写死request链接是最佳实践。如果在测试中生成URL时使用Symfony路由,它将无法侦测到针对此URL页面的任何改变,这可能导致用户体验受到冲击。

以下是更多关于request()方法的例子:

request()方法的完整用例:

request(
    $method,
    $uri,
    array $parameters = array(),
    array $files = array(),
    array $server = array(),
    $content = null,
    $changeHistory = true)

其中的 server 数组存放了你可能期待使用的PHP超全局变量$_SERVER的原生键值。例如,设置 Content-TypeRefererX-Requested-With (HTTP头)时,你需要传入以下参数(注意用于非标准头的 HTTP_ 前缀):

$client->request(
    'GET',
    '/post/hello-world',
    array(),
    array(),
    array(
        'CONTENT_TYPE'          => 'application/json',
        'HTTP_REFERER'          => '/foo/bar',
        'HTTP_X-Requested-With' => 'XMLHttpRequest',
    ));

使用抓取器来找到响应内容中的DOM元素。这些元素可以被用在点击链接或提交表单等场合:

$link = $crawler->selectLink('Go elsewhere...')->link();$crawler = $client->click($link); $form = $crawler->selectButton('validate')->form();$crawler = $client->submit($form, array('name' => 'Fabien'));

此处的 click() 方法和 submit() 方法,都会返回一个 crawler 抓取器对象。这些方法是浏览你的程序页面的最佳方式,因为它们帮你做了很多事,像是从表单中侦测HTTP请求方式,或者给你提供一个很好的API用于文件上传。

在后面的 Crawler 小节中你将学习到更多关于链接Link和表单Form对象的知识。

request 方法也可以被用于直接模拟表单提交,或是执行更多复杂请求。一些有用的例子:

// Directly submit a form (but using the Crawler is easier!)// 直接提交表单(但是使用Crawler更容易些)$client->request('POST', '/submit', array('name' => 'Fabien')); // Submit a raw JSON string in the request body// 在请求过程中提交一个JSON原生串$client->request(
    'POST',
    '/submit',
    array(),
    array(),
    array('CONTENT_TYPE' => 'application/json'),
    '{"name":"Fabien"}'); // Form submission with a file upload// 文件上传表单的提交use Symfony\Component\HttpFoundation\File\UploadedFile; $photo = new UploadedFile(
    '/path/to/photo.jpg',
    'photo.jpg',
    'image/jpeg',
    123);$client->request(
    'POST',
    '/submit',
    array('name' => 'Fabien'),
    array('photo' => $photo)); // Perform a DELETE request and pass HTTP headers// 执行一个DELETE请求并传递HTTP头$client->request(
    'DELETE',
    '/post/12',
    array(),
    array(),
    array('PHP_AUTH_USER' => 'username', 'PHP_AUTH_PW' => 'pa$$word'));

最后提醒一点,你可以强制让每次请求在它们各自的PHP进程中被执行,以避免当同一脚本中有多个client时的副作用。

1
$client->insulate();

浏览(browsing) 

Client对象支持很多种真实浏览器中的操作:

$client->back();$client->forward();$client->reload(); // Clears all cookies and the history// 清除所有cookie和历史记录$client->restart();

使用内部对象 

getInternalRequest() 和 getInternalResponse() 方法从Symfony 2.3起被引入。  如果你使用client来测试你的程序,你可能想要使用client内部的一些对象:

$history = $client->getHistory();$cookieJar = $client->getCookieJar();

你还可以得到与最近一次请求相关的对象:

// the HttpKernel request instance// HttpKernel的请求实例$request = $client->getRequest(); // the BrowserKit request instance// 浏览器组件的请求实例$request = $client->getInternalRequest(); // the HttpKernel response instance// HttpKernel的响应实例$response = $client->getResponse(); // the BrowserKit response instance// 浏览器组件的响应实例$response = $client->getInternalResponse(); $crawler = $client->getCrawler();

如果你的请求没有处在隔离运行的状态下,你还可以使用 Container (容器)和 Kernel (内核):

$container = $client->getContainer();$kernel = $client->getKernel();

使用容器 

强烈推荐功能测试只测试响应。但在特定的罕见情况下,你可能要使用内部对象来书写断言。这时,你可以调用Dependency Injection Container(依赖注入容器,即服务容器):

1
$container = $client->getContainer();

注意,如果你是把client隔离运行或者使用一个HTTP layer的话,上述功能无效。程序中的可用服务列表,你可通过 debug:container 命令行工具来查看。

在Symfony2.6之前这个命令是 container:debug

如果你需要检查的信息在分析器(profiler)中可用,那么应使用profiler来替代container。

使用分析数据 

每一次请求,你都可以借助Symfony分析器(profiler)来收集关于该请求在内部被处理的有关信息。例如,分析器常被用于验证指定页面在加载过程中的数据库查询次数是否小于给定值。

为了得到最近一次请求的Profiler,按下例操作:

// enable the profiler for the very next request// 为接下来的请求开启profiler$client->enableProfiler(); $crawler = $client->request('GET', '/profiler'); // get the profile 得到分析器$profile = $client->getProfile();

在测试中使用分析器的某些细节,请参阅 如何在功能测试中使用分析器 一文。

重定向 

当一个请求返回的是重定向响应时,client并不自动跟进。你可以检测该响应信息,然后通过 followRedirect() 方法来强迫跟进:

1
$crawler = $client->followRedirect();

如果你需要client自动跟进所有重定向,可以强迫它实现,使用 followRedirects() 方法:

1
$client->followRedirects();

false 传给 followRedirects() 方法,重定向将不再被跟进:

1
$client->followRedirects(false);

抓取器(Crawler) 

每当你通过client制造一个请求时都有一个抓取器(Crawler)实例被返回。抓取器允许你遍历HTML文档,拾取DOM节点,找到链接和表单。

遍历(Traversing) 

就像JQuery,抓取器有各种方法来遍历HTML/XML文档的DOM。例如,下述方法能找到所有 input[type=submit] 元素,并选择页面中的最后一个,然后选择它的临近父元素:

$newCrawler = $crawler->filter('input[type=submit]')
    ->last()
    ->parents()
    ->first();

还有很多其他方法可以利用:

  • filter('h1.title')
  • 匹配CSS拾取器的所有节点。
  • filterXpath('h1')
  • 匹配XPath expression(表达式)的所有节点。
  • eq(1)
  • 指定索引的节点。
  • first()
  • 第一个节点。
  • last()
  • 最后一个节点。
  • siblings()
  • Siblings。
  • nextAll()
  • 后续所有siblings。
  • previousAll()
  • 之前所有siblings。
  • parents()
  • 返回所有父节点。
  • children()
  • 返回所有子节点。
  • reduce($lambda)
  • 匿名函数$lambda不返回false时的所有节点。

由于上述每个方法都返回一个 Crawler 抓取器对象,你可以通过对各方法的链式调用来减少节点拾取过程的代码:

$crawler
    ->filter('h1')
    ->reduce(function ($node, $i) {
        if (!$node->getAttribute('class')) {
            return false;
        }
    })
    ->first();

可以使用 count() 函数来得到存储在一个Crawler中的节点数量: count($crawler)

提取信息 

抓取器可以从节点中提到信息:

// 返回第一个节点的属性值$crawler->attr('class'); // 返回第一个节点的节点文本$crawler->text(); // Extracts an array of attributes for all nodes// (_text returns the node value)// returns an array for each element in crawler,// each with the value and href// 提取一个由所有节点的属性组成的数组// (_text返回节点值)// 返回一个由抓取器中的每一个元素所组成的数组// 每个元素有value和href$info = $crawler->extract(array('_text', 'href')); // Executes a lambda for each node and return an array of results// 对每个节点执行一次lambda函数(即匿名函数),最后返回结果数组$data = $crawler->each(function ($node, $i) {
    return $node->attr('href');});

链接 

为了选择链接,你可以使用上述遍历方法,或者使用更方便的 selectLink() 快捷方法:

1
$crawler->selectLink('Click here');

如此即可选择包含了给定文本的所有链接,或者是可以点击的图片而这些图片的 alt 属性中包含了给定文本。和其他的过滤方法类似,它也是返回一个 Crawler 对象。

一旦你选中了一个链接(link),你就可以使用一个特殊的链接对象( link  object),它是一个专门用来处理链接的方法(类似 getMethod()getUri() 这种,都属于帮助方法/helpful method)。要点击链接,使用Client的 click() 方法,并把链接对象传进去:

$link = $crawler->selectLink('Click here')->link(); $client->click($link);

表单 

表单可以通过它们的按钮被选择,而按钮可以用selectButton()方法来选择,正如选择链接一样:

1
$buttonCrawlerNode = $crawler->selectButton('submit');

注意你选择的是表单的按钮而不是表单本身,因为表单可以有多个按钮。如果你使用遍历API,记住必须要找到一个按钮。

selectButton() 方法可以选中 button (按钮)标签,以及submit(提交)所在的标签(即 input 标签)。它使用按钮的几个部分来找到它们:

  • value 属性值(The value attribute value)

  • 图片按钮的 id 属性值或 alt 属性值(The id or alt attribute value for images)

  • button 标签的 id 属性值或 name 属性值(The id or name attribute value for button tags)

当你有了一个含有button的crawler之后,调用 form() 方法即可得到拥有这个button节点的form实例。

1
$form = $buttonCrawlerNode->form();

当调用 form() 方法时,你还可以把一个装有表单字段值的数组传进来,以替代默认的:

$form = $buttonCrawlerNode->form(array(
    'name'              => 'Fabien',
    'my_form[subject]'  => 'Symfony rocks!',));

当你要模拟一个特定的HTTP表单提交方式时,把它传到第二个参数中:

1
$form = $buttonCrawlerNode->form(array(), 'DELETE');

Client负责提交表单实例:

1
$client->submit($form);

字段的值可以被传到submit()方法的第二个参数中:

$client->submit($form, array(
    'name'              => 'Fabien',
    'my_form[subject]'  => 'Symfony rocks!',));

对于更复杂的情况,可以把表单实例作为数组,来分别设置每一个字段对应的值:

// 改变某个字段的值/Change the value of a field$form['name'] = 'Fabien';$form['my_form[subject]'] = 'Symfony rocks!';

还有一个很好用的API,可根据字段类型来管理它们的值:

// 选取一个option单选或radio单选$form['country']->select('France'); // 选取一个checkbox$form['like_symfony']->tick(); // 上传一个文件$form['photo']->upload('/path/to/lucas.jpg');

如果你有目的地想要选择“无效的”select/radio值,参考 选择无效的Choice Values

你可以得到即将被提交的表单字段值,调用Form对象的 getValues() 方法即可。已上传的文件也可以从一个分离出来的数组得到,这个数组由 getFiles() 方法返回。 getPhpValues() 以及 getPhpFiles() 方法同样可以返回已经提交的字段,只不过是PHP格式的(它把带有方括号的键 – 比如 my_form[subject]  – 给转换成PHP数组)。

添加或删除表单到Collection 

如果你使用了 Collection of Forms(表单集合),你不能对既有表单添加一个 $form['task[tags][0][name]'] = 'foo 字段。这会导致一个 Unreachable field "…" 错误。因为 $form 只能被用于设置现有的字段。为了添加一个新字段,你不得不把值添加到一个原始数组中:

// Get the form. 取得表单$form = $crawler->filter('button')->form(); // Get the raw values. 取得原始数值$values = $form->getPhpValues(); // Add fields to the raw values. 给原始数组添加新字段$values['task']['tag'][0]['name'] = 'foo';$values['task']['tag'][1]['name'] = 'bar'; // Submit the form with the existing and new values.// 提交表单,包括既有值,以及新值$crawler = $this->client->request($form->getMethod(), $form->getUri(), $values,
    $form->getPhpFiles()); // The 2 tags have been added to the collection.// 2个新标签被添加到collection中$this->assertEquals(2, $crawler->filter('ul.tags > li')->count());

这里的 task[tags][0][name] 就是由JavaScript创建的字段的名字。

你可以移除一个既有字段,比如一个标签(tag):

// Get the values of the form. 取得表单数据$values = $form->getPhpValues(); // Remove the first tag. 移除第一个标签unset($values['task']['tags'][0]); // Submit the data. 提交数据$crawler = $client->request($form->getMethod(), $form->getUri(),
    $values, $form->getPhpFiles()); // The tag has been removed. 标签已被移除$this->assertEquals(0, $crawler->filter('ul.tags > li')->count());

配置测试 

功能测试所用到的Client创建了一个Kernel,用于在一个特殊的 test 测试环境中运行。由于Symfony在测试时加载的是 app/config/config_test.yml ,你可以调整任何一项用于测试的“程序级”设置。

例如,默认的Swift Mailer被设置为不要在测试环境发送邮件。配置文件中的相关选项是这样设置的:

PHP:// app/config/config_test.php // ...$container->loadFromExtension('swiftmailer', array(
    'disable_delivery' => true,));
XML:<!-- app/config/config_test.xml --><?xml version="1.0" encoding="UTF-8" ?><container xmlns="http://symfony.com/schema/dic/services"    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"    xmlns:swiftmailer="http://symfony.com/schema/dic/swiftmailer"    xsi:schemaLocation="http://symfony.com/schema/dic/services        http://symfony.com/schema/dic/services/services-1.0.xsd        http://symfony.com/schema/dic/swiftmailer        http://symfony.com/schema/dic/swiftmailer/swiftmailer-1.0.xsd">     <!-- ... -->
    <swiftmailer:config disable-delivery="true" /></container>
YAML:# app/config/config_test.yml # ...swiftmailer:
    disable_delivery: true

你也可以使用一个完全不同的环境,或者覆写掉默认的debug模式( true ),通过把相关选项传给 createClient() 方法即可:

$client = static::createClient(array(
    'environment' => 'my_test_env',
    'debug'       => false,));

如果你的程序需要通过一些HTTP头才能运转,把它们传给createClient()方法的第二个参数:

$client = static::createClient(array(), array(
    'HTTP_HOST'       => 'en.example.com',
    'HTTP_USER_AGENT' => 'MySuperBrowser/1.0',));

你也可以在每一个request基本过程中覆写HTTP header:

$client->request('GET', '/', array(), array(), array(
    'HTTP_HOST'       => 'en.example.com',
    'HTTP_USER_AGENT' => 'MySuperBrowser/1.0',));

client在 test 测试环境下是容器中的可用服务(或者无论什么环境,只要 framework.test选项被开启)。这意味着只要需要,你可以覆写整个client服务。


PHPUnit配置 

每一个程序,有它自己的PHPUnit配置,就存在 app/phpunit.xml.dist 文件中。你可以编辑这个文件,改变默认值,或创建一个仅供你本地机器设定测试选项的 app/phpunit.xml 文件。

app/phpunit.xml.dist 文件存在你的代码宝库中并且忽略 app/phpunit.xml 文件。

默认是只有存放在标准目录(src//Bundle/Tests, src//Bundle/Bundle/Tests, src/*Bundle/Tests)下面你的自定义bundle中的测试才能够被phpunit命令执行。这在app/phpunit.xml.dist文件中已经配置好了:

<!-- app/phpunit.xml.dist --><phpunit>
    <!-- ... -->
    <testsuites>
        <testsuite name="Project Test Suite">
            <directory>../src/*/*Bundle/Tests</directory>
            <directory>../src/*/Bundle/*Bundle/Tests</directory>
            <directory>../src/*Bundle/Tests</directory>
        </testsuite>
    </testsuites>
    <!-- ... --></phpunit>

但是你也可以很容易地添加更多目录。例如,下列配置添加了自定义目录lib/tests中的测试:

<!-- app/phpunit.xml.dist --><phpunit>
    <!-- ... -->
    <testsuites>
        <testsuite name="Project Test Suite">
            <!-- ... - - ->            <directory>../lib/tests</directory>        </testsuite>    </testsuites>    <!-- ... - - -></phpunit>

为了在代码覆盖中包容其他目录,还需添加码段:

<!-- app/phpunit.xml.dist --><phpunit>
    <!-- ... -->
    <filter>
        <whitelist>
            <!-- ... -->
            <directory>../lib</directory>
            <exclude>
                <!-- ... -->
                <directory>../lib/tests</directory>
            </exclude>
        </whitelist>
    </filter>
    <!-- ... - - -></phpunit>