支付宝扫一扫付款
微信扫一扫付款
(微信为保护隐私,不显示你的昵称)
每当你多写一行代码,你都在增加潜在的新bug。为了打造更好、更好可靠的程序,你需要对代码使用功能测试和单元测试。
Symfony整合了一个独立类库 – 名为PHPUnit – 带给你丰富的测试框架。本章不覆盖PHPUnit本身,不过它有自己的 极佳文档。
推荐使用最新的稳定版PHPUnit,安装的是PHAR。
每一个测试 – 不管是单元测试(unit test)还是功能测试(functional test) - 都是一个PHP类,存放在你的Bundle的 Tests/
子目录下。如果你遵守这一原则,那么运行所有的程序级测试时,只需以下命令即可:
1 | $ phpunit |
1 2 3 | # specify the configuration directory on the command line
# 在命令行中指定配置文件所在的目录
$ phpunit -c app/ |
-c选项告诉PHPUnit在app/目录下寻找配置文件。如果你很在意PHPUnit的选项,查看Symfony app/phpunit.xml.dist
文件。
代码覆盖(code coverage)可以通过 —coverage-*
选项来生成。要查看帮助信息,可使用 —help
来显示更多内容
单元测试是针对单一PHP类的测试,这个类也被称为“单元(unit)”。如果你需要测试你的应用程序层级的整体行为,参考功能测试(Functional Tests)。
编写Symfony单元测试和编写标准的PHPUnit单元测试并无不同。假设,你有一个极其简单的类,类名是 Calculator
,位于app bundle的 Util/
目录下:
1 2 3 4 5 6 7 8 9 10 | // src/AppBundle/Util/Calculator.php
namespace AppBundle\Util;
class Calculator
{
public function add($a, $b)
{
return $a + $b;
}
} |
为了测试它,创建一个 CalculatorTest
文件到你的bundle的 tests/AppBundle/Util
目录下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | // tests/AppBundle/Util/CalculatorTest.php
namespace 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文件中)
针对指定文件或目录的测试也很简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | # 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/ |
1 2 3 4 5 6 7 8 9 10 11 | # 运行程序中的所有测试
$ phpunit -c app
# 运行指定目录下的所有测试
$ phpunit -c app src/AppBundle/Tests/Util
# 仅运行Calculator类的测试
$ phpunit -c app src/AppBundle/Tests/Util/CalculatorTest.php
# 运行整个bundle的测试
$ phpunit -c app src/AppBundle/ |
功能测试(Functional tests)检查程序不同层面的整合情况(从路由到视图)。这些层面本身并不因PHPUnit的介入而发生改变,只不过它们有着独特的工作流:
制造一个请求(request);
测试响应(response);
点击一个链接,或提交一个表单;
测试响应;
清除然后重复。
功能测试是存放在 Test/AppBundle/Controller
目录下的普通PHP文件。如果要测试由你的 PostController
处理的页面,先创建一个新的PostControllerTest.php文件,并继承一个特殊的 WebTestCase
类。
作为例程,这个测试看起来可能像下面代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // tests/AppBundle/Controller/PostControllerTest.php
namespace 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目录:
1 2 3 4 5 6 7 | <?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()方法
(参考more about the request method)返回了一个 Crawler
对象,用于从响应内容中选择元素(select elements),完成点击链接或提交表单这样的动作。
响应信息必须是XML或HTML文档格式,抓取器(Crawler)才会工作。若需要不带文档格式的纯响应内容(raw content),请使用 $client->getResponse()->getContent()
。
需要点击链接时,先用抓取器选择它,可使用XPath表达式或CSS拾取器来完成选 择,然后再使用client行为来点击它:
1 2 3 4 5 6 7 | $link = $crawler
->filter('a:contains("Greet")') // 选择所有含有"Greet"文本之链接
->eq(1) // 结果列表中的第二个
->link() // 点击它
;
$crawler = $client->click($link); |
提交表单时非常相似:选取一个表单按钮,可选地覆写某些表单项的值,然后提交对应的表单:
1 2 3 4 5 6 7 8 | $form = $crawler->selectButton('submit')->form();
// 设置一些值
$form['name'] = 'Lucas';
$form['form_name[subject]'] = 'Hey there!';
// 提交表单
$crawler = $client->submit($form); |
表单也可以处理上传,它还包含了用于填写不同类型的表单字段的方法(例如 select()
和 tick()
)。有关细节请参考后面小节表单(Forms)。
现在你可以很容易地穿梭于程序中,使用断言来测试页面是否按你期待的那样来执行。使用抓取器(Crawler)可令断言作用于DOM之上。
1 2 3 | // Assert that the response matches a given CSS selector.
// 断言响应内容匹配到一个指定的CSS拾取器
$this->assertGreaterThan(0, $crawler->filter('h1')->count()); |
或者直接测试响应内容,如果你只是想对内容中是否包含某些文本来断言的话,又或只需知道响应内容并非XML/HTML格式而已:
1 2 3 4 | $this->assertContains(
'Hello World',
$client->getResponse()->getContent()
); |
测试客户端(test client)模拟了一个HTTP客户端,就像一个浏览器,可以产生针对你Symfony程序的请求。
1 | $crawler = $client->request('GET', '/post/hello-world'); |
request()
方法接受一个HTTP请求方式的参数和一个URL参数,返回一个Crawler实例。
对于功能测试来说,写死request链接是最佳实践。如果在测试中生成URL时使用Symfony路由,它将无法侦测到针对此URL页面的任何改变,这可能导致用户体验受到冲击。
使用抓取器来找到响应内容中的DOM元素。这些元素可以被用在点击链接或提交表单等场合:
此处的 click()
方法和 submit()
方法,都会返回一个 crawler
抓取器对象。这些方法是浏览你的程序页面的最佳方式,因为它们帮你做了很多事,像是从表单中侦测HTTP请求方式,或者给你提供一个很好的API用于文件上传。
在后面的Crawler小节中你将学习到更多关于链接Link和表单Form对象的知识。
request
方法也可以被用于直接模拟表单提交,或是执行更多复杂请求。一些有用的例子:
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 | // 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(); |
Client对象支持很多种真实浏览器中的操作:
1 2 3 4 5 6 7 | $client->back();
$client->forward();
$client->reload();
// Clears all cookies and the history
// 清除所有cookie和历史记录
$client->restart(); |
getInternalRequest() 和 getInternalResponse() 方法从Symfony 2.3起被引入。 如果你使用client来测试你的程序,你可能想要使用client内部的一些对象:
1 2 | $history = $client->getHistory();
$cookieJar = $client->getCookieJar(); |
你还可以得到与最近一次请求相关的对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | // 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
(内核):
1 2 | $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,按下例操作:
1 2 3 4 5 6 7 8 | // enable the profiler for the very next request
// 为接下来的请求开启profiler
$client->enableProfiler();
$crawler = $client->request('GET', '/profiler');
// get the profile 得到分析器
$profile = $client->getProfile(); |
在测试中使用分析器的某些细节,请参阅如何在功能测试中使用分析器cookbook章节。
当一个请求返回的是重定向响应时,client并不自动跟进。你可以检测该响应信息,然后通过 followRedirect()
方法来强迫跟进:
1 | $crawler = $client->followRedirect(); |
如果你需要client自动跟进所有重定向,可以强迫它实现,使用 followRedirects()
方法:
1 | $client->followRedirects(); |
将 false
传给 followRedirects()
方法,重定向将不再被跟进:
1 | $client->followRedirects(false); |
每当你通过client制造一个请求时都有一个抓取器(Crawler)实例被返回。抓取器允许你遍历HTML文档,拾取DOM节点,找到链接和表单。
就像JQuery,抓取器有各种方法来遍历HTML/XML文档的DOM。例如,下述方法能找到所有 input[type=submit]
元素,并选择页面中的最后一个,然后选择它的临近父元素:
1 2 3 4 5 | $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)
Nodes for which the callable does not return false. 匿名函数$lambda不返回false时的所有节点
由于上述每个方法都返回一个 Crawler
抓取器对象,你可以通过对各方法的链式调用来减少节点拾取过程的代码:
1 2 3 4 5 6 7 8 9 | $crawler
->filter('h1')
->reduce(function ($node, $i) {
if (!$node->getAttribute('class')) {
return false;
}
})
->first()
; |
可以使用 count()
函数来得到存储在一个Crawler中的节点数量: count($crawler)
抓取器可以从节点中提到信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | // 返回第一个节点的属性值
$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()
方法,并把链接对象传进去:
1 2 3 | $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()
方法时,你还可以把一个装有表单字段值的数组传进来,以替代默认的:
1 2 3 4 | $form = $buttonCrawlerNode->form(array(
'name' => 'Fabien',
'my_form[subject]' => 'Symfony rocks!',
)); |
当你要模拟一个特定的HTTP表单提交方式时,把它传到第二个参数中:
1 | $form = $buttonCrawlerNode->form(array(), 'DELETE'); |
Client负责提交表单实例:
1 | $client->submit($form); |
字段的值可以被传到submit()方法的第二个参数中:
1 2 3 4 | $client->submit($form, array(
'name' => 'Fabien',
'my_form[subject]' => 'Symfony rocks!',
)); |
对于更复杂的情况,可以把表单实例作为数组,来分别设置每一个字段对应的值:
1 2 3 | // 改变某个字段的值/Change the value of a field
$form['name'] = 'Fabien';
$form['my_form[subject]'] = 'Symfony rocks!'; |
还有一个很好用的API,可根据字段类型来管理它们的值:
1 2 3 4 5 6 7 8 | // 选取一个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 of Forms(表单集合),你不能对既有表单添加一个 $form['task[tags][0][name]'] = 'foo
字段。这会导致一个 Unreachable field "…"
错误。因为 $form
只能被用于设置现有的字段。为了添加一个新字段,你不得不把值添加到一个原始数组中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | // 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):
1 2 3 4 5 6 7 8 9 10 11 12 | // 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被设置为不要在测试环境发送邮件。配置文件中的相关选项是这样设置的:
1 2 3 4 5 | # app/config/config_test.yml
# ...
swiftmailer:
disable_delivery: true |
1 2 3 4 5 6 7 8 9 10 11 12 13 | <!-- 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> |
1 2 3 4 5 6 | // app/config/config_test.php
// ...
$container->loadFromExtension('swiftmailer', array(
'disable_delivery' => true,
)); |
你也可以使用一个完全不同的环境,或者覆写掉默认的debug模式( true
),通过把相关选项传给 createClient()
方法即可:
1 2 3 4 | $client = static::createClient(array(
'environment' => 'my_test_env',
'debug' => false,
)); |
如果你的程序需要通过一些HTTP头才能运转,把它们传给createClient()方法的第二个参数:
你也可以在每一个request基本过程中覆写HTTP header:
client在 test
测试环境下是容器中的可用服务(或者无论什么环境,只要framework.test选项被开启)。这意味着只要需要,你可以覆写整个client服务。
每一个程序,有它自己的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文件中已经配置好了:
1 2 3 4 5 6 7 8 9 10 11 12 | <!-- 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中的测试:
1 2 3 4 5 6 7 8 9 10 11 | <!-- app/phpunit.xml.dist -->
<phpunit>
<!-- ... -->
<testsuites>
<testsuite name="Project Test Suite">
<!-- ... - - ->
<directory>../lib/tests</directory>
</testsuite>
</testsuites>
<!-- ... - - ->
</phpunit> |
为了在代码覆盖中包容其他目录,还需添加
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | <!-- app/phpunit.xml.dist -->
<phpunit>
<!-- ... -->
<filter>
<whitelist>
<!-- ... -->
<directory>../lib</directory>
<exclude>
<!-- ... -->
<directory>../lib/tests</directory>
</exclude>
</whitelist>
</filter>
<!-- ... - - ->
</phpunit> |
本文,包括例程代码在内,采用的是 Creative Commons BY-SA 3.0 创作共用授权。