This will be the most controversial of the sections of this chapter by far. When it comes to database testing, there are different schools of thought. Should we use the database or not? Should we use our development database or one in memory? It is quite out of the scope of the book to explain how to mock the database or prepare a fresh one for each test, but we will try to summarize some of the techniques here:
PDO
object. As we will write the queries manually, chances are that we might introduce a wrong query. Mocking the connection would not help us detect this error. This solution would be good if we used ORM instead of writing the queries manually, but we will leave this topic out of the book.In order to keep things small, we will try to implement a mixture of the second and third options. We will use our existing database, but after starting the transaction of each test, we will clean all the tables involved with the test. This looks as though we need a ModelTestCase
to handle this. Add the following into tests/ModelTestCase.php
:
<?php namespace BookstoreTests; use BookstoreCoreConfig; 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"); } } }
The setUp
method creates a database connection with the same credentials found in the config/app.yml
file. Then, we will start a transaction and invoke the cleanAllTables
method, which iterates the tables in the $tables
property and deletes all the content from them. The tearDown
method rolls back the transaction.
Let's write tests for the borrow
method of the BookModel
class. This method uses books and customers, so we would like to clean the tables that contain them. Create the test
class and save it in tests/Models/BookModelTest.php
:
<?php namespace BookstoreTestsModels; use BookstoreModelsBookModel; use BookstoreTestsModelTestCase; class BookModelTest extends ModelTestCase { protected $tables = [ 'borrowed_books', 'customer', 'book' ]; protected $model; public function setUp() { parent::setUp(); $this->model = new BookModel($this->db); } }
Note how we also overrode the setUp
method, invoking the one in the parent and creating the model instance that all tests will use, which is safe to do as we will not keep any context on this object. Before adding the tests though, let's add some more helpers to ModelTestCase
: one to create book objects given an array of parameters and two to save books and customers in the database. Run the following code:
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); }
As you can note, we added default values for all the fields, so we are not forced to define the whole book/customer each time we want to save one. Instead, we just sent the relevant fields and merged them to the default ones.
Also, note that the buildBook
method used a new concept, reflection, to access the private properties of an instance. This is way beyond the scope of the book, but if you are interested, you can read more at http://php.net/manual/en/book.reflection.php.
We are now ready to start writing tests. With all these helpers, adding tests will be very easy and clean. The borrow
method has different use cases: trying to borrow a book that is not in the database, trying to use a customer not registered, and borrowing a book successfully. Let's add them as follows:
/** * @expectedException BookstoreExceptionsDbException */ public function testBorrowBookNotFound() { $book = $this->buildBook(['id' => 123]); $this->model->borrow($book, 123); } /** * @expectedException BookstoreExceptionsDbException */ 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); }
Impressed? Compared to the controller tests, these tests are way simpler, mainly because their code performs only one action, but also thanks to all the methods added to ModelTestCase
. Once you need to work with other objects, such as sales
, you can add addSale
or buildSale
to this same class to make things cleaner.