使用 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 类的模拟。然后,它添加了一个期望值,内容如下:调用一次带有一个参数 123get 方法,并抛出 NotFoundException。这是有道理的,因为测试试图模拟我们无法在数据库中找到图书的情况。

测试的第二部分包括添加模板引擎的预期。这部分比较复杂,因为涉及到两个模拟。Twig_EnvironmentloadTemplate 方法预计会被调用一次,并将 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 方法永远不会被调用时。既然我们不希望它被调用,就没有理由使用 withwill 方法。如果代码真的调用了该方法,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.'
    );
}

就是这样。你已经编写了本书中最复杂的测试之一。你觉得怎么样?由于你没有太多的测试经验,你可能对结果很满意,但让我们试着进一步分析一下。