数据库测试
这将是本章迄今为止争议最大的一节。说到数据库测试,众说纷纭。我们是否应该使用数据库?是使用开发数据库还是内存数据库?要解释如何模拟数据库或为每次测试准备一个新的数据库,已经超出了本书的范围,但我们将尝试在这里总结一些技术:
-
我们将模拟数据库连接,并为模型和数据库之间的所有交互写入预期。在我们的例子中,这意味着我们将注入一个
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
如果您编写的测试从该类扩展而来,需要实现 |
让我们为 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
中的所有方法。一旦您需要处理其他对象(如销售),您可以将 addSale
或 buildSale
添加到这个类中,这样就能让事情变得更简洁。