单元测试
您可能已经注意到我们在上一章构建的框架中存在一些细微但重要的错误。在创建框架时,您必须确保它的行为符合广告宣传。如果不是,所有基于它的应用程序都会表现出相同的错误。好消息是,每当您修复一个错误时,您也在修复一大堆应用程序。
今天的任务是使用 PHPUnit 为我们创建的框架编写单元测试。首先,安装 PHPUnit 作为开发依赖项
1
$ composer require --dev phpunit/phpunit:^9.6
然后,在 example.com/phpunit.xml.dist
中创建一个 PHPUnit 配置文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="vendor/autoload.php"
>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./src</directory>
</include>
</coverage>
<testsuites>
<testsuite name="Test Suite">
<directory>./tests</directory>
</testsuite>
</testsuites>
</phpunit>
此配置为大多数 PHPUnit 设置定义了合理的默认值;更有趣的是,自动加载器用于引导测试,并且测试将存储在 example.com/tests/
目录下。
现在,让我们为“未找到”资源编写一个测试。为了避免在编写测试时创建所有依赖项,并真正只对我们想要的内容进行单元测试,我们将使用 测试替身。当我们依赖接口而不是具体类时,测试替身更容易创建。幸运的是,Symfony 为核心对象(如 URL 匹配器和控制器解析器)提供了此类接口。修改框架以使用它们
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
// example.com/src/Simplex/Framework.php
namespace Simplex;
// ...
use Calendar\Controller\LeapYearController;
use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\Routing\Matcher\UrlMatcherInterface;
class Framework
{
public function __construct(
private UrlMatcherInterface $matcher,
private ControllerResolverInterface $resolver,
private ArgumentResolverInterface $argumentResolver,
) {
}
// ...
}
我们现在准备编写我们的第一个测试
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
// example.com/tests/Simplex/Tests/FrameworkTest.php
namespace Simplex\Tests;
use PHPUnit\Framework\TestCase;
use Simplex\Framework;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\Routing;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
class FrameworkTest extends TestCase
{
public function testNotFoundHandling(): void
{
$framework = $this->getFrameworkForException(new ResourceNotFoundException());
$response = $framework->handle(new Request());
$this->assertEquals(404, $response->getStatusCode());
}
private function getFrameworkForException($exception): Framework
{
$matcher = $this->createMock(Routing\Matcher\UrlMatcherInterface::class);
$matcher
->expects($this->once())
->method('match')
->will($this->throwException($exception))
;
$matcher
->expects($this->once())
->method('getContext')
->will($this->returnValue($this->createMock(Routing\RequestContext::class)))
;
$controllerResolver = $this->createMock(ControllerResolverInterface::class);
$argumentResolver = $this->createMock(ArgumentResolverInterface::class);
return new Framework($matcher, $controllerResolver, $argumentResolver);
}
}
此测试模拟一个与任何路由都不匹配的请求。因此,match()
方法返回一个 ResourceNotFoundException
异常,我们正在测试我们的框架是否将此异常转换为 404 响应。
通过在 example.com
目录中运行 phpunit
来执行此测试
1
$ ./vendor/bin/phpunit
注意
如果您不理解代码中发生了什么,请阅读 PHPUnit 文档中的 测试替身。
测试运行后,您应该看到一个绿色条。如果不是,那么您的测试或框架代码中存在错误!
为控制器中抛出的任何异常添加单元测试
1 2 3 4 5 6 7 8
public function testErrorHandling(): void
{
$framework = $this->getFrameworkForException(new \RuntimeException());
$response = $framework->handle(new Request());
$this->assertEquals(500, $response->getStatusCode());
}
最后但并非最不重要的一点,让我们为我们实际拥有正确 Response 的情况编写一个测试
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
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver;
use Symfony\Component\HttpKernel\Controller\ControllerResolver;
// ...
public function testControllerResponse(): void
{
$matcher = $this->createMock(Routing\Matcher\UrlMatcherInterface::class);
$matcher
->expects($this->once())
->method('match')
->will($this->returnValue([
'_route' => 'is_leap_year/{year}',
'year' => '2000',
'_controller' => [new LeapYearController(), 'index'],
]))
;
$matcher
->expects($this->once())
->method('getContext')
->will($this->returnValue($this->createMock(Routing\RequestContext::class)))
;
$controllerResolver = new ControllerResolver();
$argumentResolver = new ArgumentResolver();
$framework = new Framework($matcher, $controllerResolver, $argumentResolver);
$response = $framework->handle(new Request());
$this->assertEquals(200, $response->getStatusCode());
$this->assertStringContainsString('Yep, this is a leap year!', $response->getContent());
}
在此测试中,我们模拟一个匹配路由并返回一个简单控制器的场景。我们检查响应状态是否为 200,以及其内容是否与我们在控制器中设置的内容一致。
要检查我们是否涵盖了所有可能的用例,请运行 PHPUnit 测试覆盖率功能(您需要先启用 XDebug)
1
$ ./vendor/bin/phpunit --coverage-html=cov/
在浏览器中打开 example.com/cov/src/Simplex/Framework.php.html
并检查 Framework 类的所有行是否为绿色(这意味着在执行测试时已访问过它们)。
或者,您可以将结果直接输出到控制台
1
$ ./vendor/bin/phpunit --coverage-text
由于我们到目前为止编写的干净的面向对象代码,我们已经能够编写单元测试来涵盖我们框架的所有可能用例;测试替身确保我们实际上是在测试我们的代码,而不是 Symfony 代码。
现在我们(再次)对我们编写的代码充满信心,我们可以安全地考虑我们想要添加到框架中的下一批功能。