数据库测试

这将是本章迄今为止争议最大的一节。说到数据库测试,众说纷纭。我们是否应该使用数据库?是使用开发数据库还是内存数据库?要解释如何模拟数据库或为每次测试准备一个新的数据库,已经超出了本书的范围,但我们将尝试在这里总结一些技术:

  • 我们将模拟数据库连接,并为模型和数据库之间的所有交互写入预期。在我们的例子中,这意味着我们将注入一个 PDO 对象的 mock。由于我们将手动编写查询,因此有可能引入错误的查询。模拟连接并不能帮助我们发现这个错误。如果我们使用 ORM 而不是手动编写查询,这个解决方案就会很好,但我们不会在本书中讨论这个主题。

  • 对于每个测试,我们都将创建一个全新的数据库,并在其中添加特定测试所需的数据。这种方法可能会耗费大量时间,但它能确保你将针对真实数据库进行测试,并且没有可能导致测试失败的意外数据;也就是说,测试是完全隔离的。在大多数情况下,这是一种更可取的方法,尽管这种方法的运行速度可能并不快。为了解决这种不便,我们将创建内存数据库。

  • 测试是针对现有数据库进行的。通常,在测试开始时,我们会启动一个事务,在测试结束时回滚,使数据库不发生任何变化。这种方法模拟了真实场景,我们可以在其中找到各种数据,而且我们的代码应始终按照预期运行。不过,使用共享数据库总会有一些副作用;例如,如果我们想对数据库模式进行更改,就必须在运行测试前将更改应用到数据库中,但使用数据库的其他应用程序或开发人员尚未准备好接受这些更改。

为了保持小规模,我们将尝试实施第二种和第三种方案的混合。我们将使用现有的数据库,但在启动每个测试的事务后,我们将清理与测试相关的所有表。看起来我们需要一个 ModelTestCase 来处理这个问题。在 tests/ModelTestCase.php 中添加以下内容:

namespace Bookstore\Tests;

use Bookstore\Core\Config;
use PDO;

abstract class ModelTestCase extends AbstractTestCase
{
    protected $db;
    protected $tables = [];

    public function setUp()
    {
        $config = new Config();
        $dbConfig = $config->get('db');
        $this->db = new PDO(
            'mysql:host=127.0.0.1;dbname=bookstore',
            $dbConfig['user'],
            $dbConfig['password']
        );
        $this->db->beginTransaction();
        $this->cleanAllTables();
    }

    public function tearDown()
    {
        $this->db->rollBack();
    }

    protected function cleanAllTables()
    {
        foreach ($this->tables as $table) {
            $this->db->exec("delete from $table");
        }
    }
}

setUp 方法使用 config/app.yml 文件中的相同凭据创建数据库连接。然后,我们将启动一个事务并调用 cleanAllTables 方法,该方法会遍历 $tables 属性中的表并删除其中的所有内容。tearDown 方法会回滚事务。

扩展 ModelTestCase

如果您编写的测试从该类扩展而来,需要实现 setUptearDown 方法,请务必记住调用父类中的方法。

让我们为 BookModel 类的 borrow 方法编写测试。该方法使用书籍和客户,因此我们希望清理包含它们的表。创建 test 类并将其保存在 tests/Models/BookModelTest.php 中:

<?php

namespace Bookstore\Tests\Models;

use Bookstore\Models\BookModel;
use Bookstore\Tests\ModelTestCase;

class BookModelTest extends ModelTestCase {
    protected $tables = [
        'borrowed_books',
        'customer',
        'book'
    ];
    protected $model;

    public function setUp() {
        parent::setUp();

        $this->model = new BookModel($this->db);
    }
}

请注意,我们还重载了 setUp 方法,调用了父对象中的方法并创建了所有测试都将使用的模型实例,这样做很安全,因为我们不会在此对象上保留任何上下文。在添加测试之前,让我们为 ModelTestCase 添加一些帮助程序:一个用于在给定参数数组的情况下创建书籍对象,另两个用于在数据库中保存书籍和客户。运行以下代码:

protected function buildBook(array $properties): Book {
    $book = new Book();
    $reflectionClass = new ReflectionClass(Book::class);

    foreach ($properties as $key => $value) {
        $property = $reflectionClass->getProperty($key);
        $property->setAccessible(true);
        $property->setValue($book, $value);
    }

    return $book;
}

protected function addBook(array $params) {
    $default = [
        'id' => null,
        'isbn' => 'isbn',
        'title' => 'title',
        'author' => 'author',
        'stock' => 1,
        'price' => 10.0,
    ];
    $params = array_merge($default, $params);

    $query = <<<SQL
insert into book (id, isbn, title, author, stock, price)
values(:id, :isbn, :title, :author, :stock, :price)
SQL;
    $this->db->prepare($query)->execute($params);
}

protected function addCustomer(array $params) {
    $default = [
        'id' => null,
        'firstname' => 'firstname',
        'surname' => 'surname',
        'email' => 'email',
        'type' => 'basic'
    ];
    $params = array_merge($default, $params);

    $query = <<<SQL
insert into customer (id, firstname, surname, email, type)
values(:id, :firstname, :surname, :email, :type)
SQL;
    $this->db->prepare($query)->execute($params);
}

正如你所注意到的,我们为所有字段添加了默认值,这样我们就不会在每次保存时都被迫定义整个书籍/客户。相反,我们只需发送相关字段并将其合并为默认值即可。

另外,请注意 buildBook 方法使用了一个新概念—​反射,来访问实例的私有属性。这已经超出了本书的范围,但如果你有兴趣,可以访问 http://php.net/manual/en/book.reflection.php 阅读更多内容。

现在我们可以开始编写测试了。有了所有这些助手,添加测试将变得非常简单和干净。borrow 方法有不同的用例:尝试借阅数据库中没有的图书、尝试使用未注册的客户以及成功借阅图书。让我们按如下方式添加它们:

/**
 * @expectedException \Bookstore\Exceptions\DbException
 */
public function testBorrowBookNotFound() {
    $book = $this->buildBook(['id' => 123]);
    $this->model->borrow($book, 123);
}

/**
 * @expectedException \Bookstore\Exceptions\DbException
 */
public function testBorrowCustomerNotFound() {
    $book = $this->buildBook(['id' => 123]);
    $this->addBook(['id' => 123]);

    $this->model->borrow($book, 123);
}

public function testBorrow() {
    $book = $this->buildBook(['id' => 123, 'stock' => 12]);
    $this->addBook(['id' => 123, 'stock' => 12]);
    $this->addCustomer(['id' => 123]);

    $this->model->borrow($book, 123);
}

有印象吗?与控制器测试相比,这些测试要简单得多,这主要是因为它们的代码只执行一个操作,但这也要归功于添加到 ModelTestCase 中的所有方法。一旦您需要处理其他对象(如销售),您可以将 addSalebuildSale 添加到这个类中,这样就能让事情变得更简洁。