使用 doubles 测试
到目前为止,我们测试的都是比较孤立的类,即它们与其他类没有太多交互。然而,我们有一些类会使用多个类,例如控制器。我们该如何处理这些交互呢?单元测试的目的是测试特定的方法,而不是整个代码库,对吗?
PHPUnit 允许你模拟这些依赖关系;也就是说,你可以提供与被测试类所需的依赖关系类似的假对象,但它们并不使用这些类的代码。这样做的目的是提供一个假实例,让类可以使用并调用其方法,而不会对这些调用产生副作用。以模型为例:如果控制器使用的是真实的模型,那么在调用其中的方法时,模型每次都会访问数据库,从而使测试变得非常不可预测。
如果我们使用一个模拟模型,控制器就可以根据需要多次调用其方法,而不会产生任何副作用。更妙的是,我们可以对 mock
接收到的参数进行断言,或强制它返回特定值。让我们来看看如何使用它们。
使用 DI 注入模型
我们首先需要了解的是,如果在控制器内使用 new
创建对象,我们将无法模拟它们。这意味着我们需要注入所有依赖项—例如,使用依赖项注入器。除模型外,我们将对所有依赖项进行注入。在本节中,我们将测试 BookController
类的 borrow
方法,因此我们将展示该方法所需的更改。当然,如果你想测试其余代码,也应该对其余控制器进行同样的修改。
首先要做的是在 index.php
文件的依赖注入器中添加 BookModel
实例。由于该类还依赖于 PDO
,因此使用相同的依赖注入器来获取其实例,如下所示:
$di->set('BookModel', new BookModel($di->get('PDO')));
现在,在 BookController
类的 borrow
方法中,我们将模型的新实例更改为以下内容:
public function borrow(int $bookId): string {
$bookModel = $this->di->get('BookModel');
try {
//...
定制 TestCase
在编写单元测试套件时,通常会有一个自定义的 TestCase
类,所有测试都从该类扩展而来。该类总是从 PHPUnit_Framework_TestCase
扩展而来,因此我们仍然可以得到所有断言和其他方法。由于所有测试都必须导入这个类,所以我们要修改自动加载器,使其能够识别测试目录中的命名空间。之后,运行 composer update
,如下所示:
"autoload": {
"psr-4": {
"Bookstore\\Tests\\": "tests",
"Bookstore\\": "src"
}
}
通过这一更改,我们将告诉 Composer,所有以 Bookstore\Tests
开头的命名空间都将位于测试目录下,其余的命名空间将遵循之前的规则。
现在,让我们添加自定义的 TestCase
类。我们现在唯一需要的辅助方法就是创建模拟。其实这并不是必须的,但它能让事情变得更简洁。在 tests/AbstractTestClase.php
中添加以下类:
namespace Bookstore\Tests;
use PHPUnit_Framework_TestCase;
use InvalidArgumentException;
abstract class AbstractTestCase extends PHPUnit_Framework_TestCase {
protected function mock(string $className) {
if (strpos($className, '\\') !== 0) {
$className = '\\' . $className;
}
if (!class_exists($className)) {
$className = '\Bookstore\\' . trim($className, '\\');
if (!class_exists($className)) {
throw new InvalidArgumentException(
"Class $className not found."
);
}
}
return $this->getMockBuilder($className)
->disableOriginalConstructor()
->getMock();
}
}
该方法获取一个类的名称,并尝试确定该类是否属于 Bookstore
命名空间。这在模拟我们自己代码库中的对象时非常方便,因为我们不必每次都写 Bookstore
。在确定类的真实全名后,它会使用 PHPUnit 的 mock 生成器创建一个类,然后返回。
更多助手!这一次,它们是为控制器准备的。每个控制器都需要相同的依赖项:日志记录器、数据库连接、模板引擎和配置读取器。有鉴于此,让我们创建一个 ControllerTestCase
类,所有涉及控制器的测试都将从该类扩展。该类将包含一个 setUp
方法,用于创建所有常用模拟并将其设置到依赖注入器中。将其添加到你的 tests/ControllerTestCase.php
文件中,如下所示:
namespace Bookstore\Tests;
use Bookstore\Utils\DependencyInjector;
use Bookstore\Core\Config;
use Monolog\Logger;
use Twig_Environment;
use PDO;
abstract class ControllerTestCase extends AbstractTestCase
{
protected $di;
public function setUp()
{
$this->di = new DependencyInjector();
$this->di->set('PDO', $this->mock(PDO::class));
$this->di->set('Utils\Config', $this->mock(Config::class));
$this->di->set(
'Twig_Environment',
$this->mock(Twig_Environment::class)
);
$this->di->set('Logger', $this->mock(Logger::class));
}
}
使用 mocks
好了,我们已经看够了助手,现在开始测试吧。这里的难点在于如何使用 mock
。创建模拟时,可以添加一些期望值和返回值。方法如下
-
expects:指定调用
mock
方法的次数。你可以发送$this→never()
、$this→once()
或$this→any()
作为参数,指定 0、1 或任意调用次数。 -
method:用于指定我们正在讨论的方法。它所期望的参数只是方法的名称。
-
with:这是一种方法,用于设置
mock
在调用时将接收的预期参数。例如,如果被模拟方法的第一个参数是basic
,第二个参数是123
,那么with
方法的调用方式就是with("basic", 123)
。该方法是可选的,但如果我们设置了它,PHPUnit 就会在模拟方法没有得到预期参数的情况下抛出错误,因此它起着断言的作用。 -
will:用来定义
mock
将返回什么。最常见的两种用法是$this→returnValue($value)
或$this- >throwException($exception)
。该方法也是可选的,如果不调用,mock
将始终返回空值。
让我们添加第一个测试来看看它是如何工作的。 将以下代码添加到 tests/Controllers/BookControllerTest.php
文件中:
namespace Bookstore\Tests\Controllers;
use Bookstore\Controllers\BookController;
use Bookstore\Core\Request;
use Bookstore\Exceptions\NotFoundException;
use Bookstore\Models\BookModel;
use Bookstore\Tests\ControllerTestCase;
use Twig_Template;
class BookControllerTest extends ControllerTestCase
{
private function getController(
Request $request = null
): BookController
{
if ($request === null) {
$request = $this->mock('Core\Request');
}
return new BookController($this->di, $request);
}
public function testBookNotFound()
{
$bookModel = $this->mock(BookModel::class);
$bookModel
->expects($this->once())
->method('get')
->with(123)
->will(
$this->throwException(
new NotFoundException()
)
);
$this->di->set('BookModel', $bookModel);
$response = "Rendered template";
$template = $this->mock(Twig_Template::class);
$template
->expects($this->once())
->method('render')
->with(['errorMessage' => 'Book not found.'])
->will($this->returnValue($response));
$this->di->get('Twig_Environment')
->expects($this->once())
->method('loadTemplate')
->with('error.twig')
->will($this->returnValue($template));
$result = $this->getController()->borrow(123);
$this->assertSame(
$result,
$response,
'Response object is not the expected one.'
);
}
}
测试的第一件事是创建 BookModel
类的模拟。然后,它添加了一个期望值,内容如下:调用一次带有一个参数 123
的 get
方法,并抛出 NotFoundException
。这是有道理的,因为测试试图模拟我们无法在数据库中找到图书的情况。
测试的第二部分包括添加模板引擎的预期。这部分比较复杂,因为涉及到两个模拟。Twig_Environment
的 loadTemplate
方法预计会被调用一次,并将 error.twig
参数作为模板名称。该模拟应返回 Twig_Template
,这是另一个模拟。第二个 mock 的 render
方法预计会被调用一次,并带有正确的错误信息,返回的响应是一个硬编码字符串。定义好所有依赖关系后,我们只需调用控制器的 borrow 方法,并期待得到响应。
请记住,该测试并非只有一个断言,而是四个:assertSame
方法和三个模拟期望。如果其中任何一个没有完成,测试就会失败,因此我们可以说这个方法是相当健壮的。
通过第一次测试,我们验证了找不到图书的情况是有效的。还有两种情况也会失败:一是没有足够的图书可供借阅,二是保存借阅图书时数据库出错。不过,您现在可以看到,所有这些情况都共享一段模拟模板的代码。让我们将这段代码提取到一个受保护的方法中,该方法会在给定模板名称、向模板发送参数并收到预期响应时生成模拟。运行以下代码:
protected function mockTemplate(
string $templateName,
array $params,
$response
) {
$template = $this->mock(Twig_Template::class);
$template
->expects($this->once())
->method('render')
->with($params)
->will($this->returnValue($response));
$this->di->get('Twig_Environment')
->expects($this->once())
->method('loadTemplate')
->with($templateName)
->will($this->returnValue($template));
}
public function testNotEnoughCopies() {
$bookModel = $this->mock(BookModel::class);
$bookModel
->expects($this->once())
->method('get')
->with(123)
->will($this->returnValue(new Book()));
$bookModel
->expects($this->never())
->method('borrow');
$this->di->set('BookModel', $bookModel);
$response = "Rendered template";
$this->mockTemplate(
'error.twig',
['errorMessage' => 'There are no copies left.'],
$response
);
$result = $this->getController()->borrow(123);
$this->assertSame(
$result,
$response,
'Response object is not the expected one.'
);
}
public function testErrorSaving() {
$controller = $this->getController();
$controller->setCustomerId(9);
$book = new Book();
$book->addCopy();
$bookModel = $this->mock(BookModel::class);
$bookModel
->expects($this->once())
->method('get')
->with(123)
->will($this->returnValue($book));
$bookModel
->expects($this->once())
->method('borrow')
->with(new Book(), 9)
->will($this->throwException(new DbException()));
$this->di->set('BookModel', $bookModel);
$response = "Rendered template";
$this->mockTemplate(
'error.twig',
['errorMessage' => 'Error borrowing book.'],
$response
);
$result = $controller->borrow(123);
$this->assertSame(
$result,
$response,
'Response object is not the expected one.'
);
}
这里唯一的新颖之处在于,当我们预期 borrow
方法永远不会被调用时。既然我们不希望它被调用,就没有理由使用 with
或 will
方法。如果代码真的调用了该方法,PHPUnit 将把测试标记为失败。
我们已经进行了测试,发现所有可能失败的情况都已失败。现在让我们添加一个用户能成功借书的测试,这意味着我们将从数据库返回有效的书籍和客户,save
方法将被正确调用,模板将获得所有正确的参数。测试内容如下
public function testBorrowingBook() {
$controller = $this->getController();
$controller->setCustomerId(9);
$book = new Book();
$book->addCopy();
$bookModel = $this->mock(BookModel::class);
$bookModel
->expects($this->once())
->method('get')
->with(123)
->will($this->returnValue($book));
$bookModel
->expects($this->once())
->method('borrow')
->with(new Book(), 9);
$bookModel
->expects($this->once())
->method('getByUser')
->with(9)
->will($this->returnValue(['book1', 'book2']));
$this->di->set('BookModel', $bookModel);
$response = "Rendered template";
$this->mockTemplate(
'books.twig',
[
'books' => ['book1', 'book2'],
'currentPage' => 1,
'lastPage' => true
],
$response
);
$result = $controller->borrow(123);
$this->assertSame(
$result,
$response,
'Response object is not the expected one.'
);
}
就是这样。你已经编写了本书中最复杂的测试之一。你觉得怎么样?由于你没有太多的测试经验,你可能对结果很满意,但让我们试着进一步分析一下。