Database Testing with PHPUnit
In this post, you will learn how to set up PHPUnit to test the database layer of your application. Unit tests are usually focused on testing object models, but they can also be used to test interactions with the database to prevent regressions in data persistence. If you have a complex application that involves a database, it is very important to validate that it is working properly so you can be more confident in your deploys.
The examples used in this post are based on a sample “library” application. You can find all source code for the app on Github. The sample has an accompanying Vagrantfile for creating an ubuntu VM with the sample database initialized, and uses Phinx for managing the database migration. Follow the README for steps to initialize.
Application Summary
The application is a basic example of a library system. There are two tables: authors
and books
.
CREATE TABLE `authors` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
CREATE TABLE `books` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`isbn` varchar(16) NOT NULL,
`author_id` int(11) NOT NULL,
`title` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
Each record in the books
table represents a book in the library. Each book has an ISBN number, a title and an author,
which is stored using an author_id
. The author_id
in the books
table is a foreign key to the id
in the authors
table.
In the sample database library
, there are four books:
1
2
3
4
A Short History of Nearly Everything - Bill Bryson - 9780767908184
The Life and Times of the Thunderbolt Kid - Bill Bryson - 9780767919371
The Road to Little Dribbling - Bill Bryson - 9780385539289
The Kite Runner - Khaled Hosseini - 9781594480003
Code Structure
The code structure of the application is shown below.
The source code is located in the src
directory, and the corresponding tests are located in the tests
directory.
In a fully tested application there should be a corresponding tests/Unit
folder at the same level as the tests/Persistence
folder,
but this sample application only covers the Persistence
(database) testing requirements so the unit tests have been left out.
The BookMapper
class is the database mapper class that queries the database table and maps the resulting records
into Book
objects. There are 5 functions in the class that will be tested:
fetchAll
- fetches all books in the
books
table, with author name from theauthors
table
- fetches all books in the
fetchByISBN
- fetches zero or one book in the
books
table, matching the provided ISBN number
- fetches zero or one book in the
insert
- creates a new record in the
books
table
- creates a new record in the
update
- updates a record in the
books
table
- updates a record in the
delete
- deletes a record in the
books
table
- deletes a record in the
Each of these functions will be tested using the BookMapperTest.php
class.
Setting Up The Tests
The first thing to do when setting up the tests is to install PHPUnit. The best way to do this is using Composer.
Ensure that both phpunit/phpunit
and phpunit/dbunit
are included in the require-dev
section of the composer.json
file.
{
..
"require-dev":{
"phpunit/phpunit": "~4.8",
"phpunit/dbunit": "~2"
},
..
}
Next create a file phpunit.xml.dist
at the root of the project to define the bootstrap for the project, the database
connection details for the tests, and any specific test suites that you require. In the case of this sample, there
is only a persistence
suite:
<phpunit bootstrap="./tests/bootstrap.php"
verbose="false"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true">
<php>
<var name="DB_HOST" value="10.0.0.20" />
<var name="DB_USER" value="root" />
<var name="DB_PASSWD" value="tutorial" />
<var name="DB_DBNAME" value="library" />
</php>
<testsuites>
<testsuite name="persistence">
<directory>./tests/Persistence</directory>
</testsuite>
</testsuites>
</phpunit>
Finally create a bootstrap.php
file at the root of the tests
directory to initialize the tests for phpunit, and create
an empty tests/Persistence
directory.
<?php
error_reporting( E_ALL | E_STRICT );
// Ensure that composer has installed all dependencies
if( !file_exists( dirname( __DIR__ ) . '/composer.lock' ) )
{
die( "Dependencies must be installed using composer:\n\nphp composer.phar install --dev\n\n"
. "See http://getcomposer.org for help with installing composer\n" );
}
// Include the composer autoloader
$autoloader = require dirname( __DIR__ ) . '/vendor/autoload.php';
At this point, assuming you have not yet created any test files, you should be able to run the phpunit suite with no results:
fraser@localhost database-testing $ vendor/bin/phpunit
PHPUnit 4.8.18 by Sebastian Bergmann and contributors.
Time: 94 ms, Memory: 4.50Mb
No tests executed!
Creating The Test Classes
First, create a file BookMapperTest.php
in the tests/Persistence
folder. The file should look something like this,
with a test function for each of the functions that exist in the BookMapper.php
file.
<?php
namespace DatabaseTesting\Tests\Persistence\Mappers;
use DatabaseTesting\Tests\Persistence\DatabaseTestCase;
class BookMapperTest extends DatabaseTestCase
{
/**
* Prepare data set for database tests
*
* @return \PHPUnit_Extensions_Database_DataSet_ArrayDataSet
*/
public function getDataSet()
{
return $this->createArrayDataSet( array() );
}
public function testFetchAll()
{
$this->markTestIncomplete( 'Not written yet.' );
}
public function testFetchByISBN()
{
$this->markTestIncomplete( 'Not written yet.' );
}
public function testInsert()
{
$this->markTestIncomplete( 'Not written yet.' );
}
public function testUpdate()
{
$this->markTestIncomplete( 'Not written yet.' );
}
public function testDelete()
{
$this->markTestIncomplete( 'Not written yet.' );
}
}
Note two things about this file:
- the
getDataSet
function returns an empty dataset (this will be filled in later) - the class extends
DatabaseTestCase
, which you may have also noticed in the code structure image above
Create the DatabaseTestCase.php
file next, in the tests/Persistence
folder:
<?php
namespace DatabaseTesting\Tests\Persistence;
use DatabaseTesting\Db\MysqlAdapter;
abstract class DatabaseTestCase extends \PHPUnit_Extensions_Database_TestCase
{
// only instantiate adapter once per test
static private $adapter = null;
// only instantiate pdo once for test clean-up/fixture load
static private $pdo = null;
// only instantiate PHPUnit_Extensions_Database_DB_IDatabaseConnection once per test
private $conn = null;
final public function getAdapter()
{
if (self::$adapter == null) {
self::$adapter = new MysqlAdapter( $GLOBALS['DB_DSN'], $GLOBALS['DB_USER'], $GLOBALS['DB_PASSWD'] );
}
return self::$adapter;
}
final public function getConnection()
{
if ($this->conn === null) {
if (self::$pdo == null) {
$dbAdapter = $this->getAdapter();
self::$pdo = $dbAdapter->getConnection();
}
$this->conn = $this->createDefaultDBConnection( self::$pdo );
}
return $this->conn;
}
}
The DatabaseTestCase
class extends the PHPUnit_Extensions_Database_TestCase
, which is the base phpunit/dbunit
test case class.
The trait used by PHPUnit_Extensions_Database_TestCase
contains the following abstract functions, requiring the test
class to implement:
getConnection
getDataset
By creating our own abstract version of the base test class (as recommended in the
PHPUnit Manual),
it allows you to define the database connection to be used in all of the tests. In the example we are using the main
database connection (as defined in phpunit.xml.dist
), which is the same connection that the application itself uses.
This can also be adapted to use a specific test
version of the database, depending on your application and it’s
environmental requirements.
Now if you run the tests, you should see 5 tests run, all incomplete:
fraser@localhost database-testing $ vendor/bin/phpunit
PHPUnit 4.8.18 by Sebastian Bergmann and contributors.
IIIII
Time: 107 ms, Memory: 5.25Mb
OK, but incomplete, skipped, or risky tests!
Tests: 5, Assertions: 0, Incomplete: 5.
Creating Test DataSets
There are four documented ways to create datasets for database tests run with PHPUnit: XML, YAML, CSV and Arrays. The first three methods are natively supported, and the fourth can be supported with some custom code written. Let’s go over each.
XML
XML datasets can be used to store the test data used in the suite, with each XML tag inside the root node <dataset>
representing a row in a database table. The tag name should be the name of the database table, and attributes should
be the column names in the table.
For example:
<?xml version="1.0" ?>
<dataset>
<authors id="100" name="Mordecai Richler"/>
<authors id="101" name="Farley Mowat"/>
<books id="200" isbn="978-0671028473" title="The Apprenticeship of Duddy Kravitz" author_id="100" />
<books id="201" isbn="978-0887769252" title="Jacob Two-Two Meets the Hooded Fang" author_id="100" />
<books id="202" isbn="978-1550139891" title="The Farfarers" author_id="101" />
</dataset>
The XML file data is retrieved by a test class using the createFlatXmlDataSet
function:
<?php
namespace DatabaseTesting\Tests\Persistence\Mappers;
use DatabaseTesting\Tests\Persistence\DatabaseTestCase;
class BookMapperTest extends DatabaseTestCase
{
/**
* Prepare data set for database tests
*
* @return \PHPUnit_Extensions_Database_DataSet_AbstractDataSet
*/
public function getDataSet()
{
return $this->createFlatXMLDataSet( __DIR__ . '/../../Fixtures/library-test.xml' );
}
YAML
YAML datasets can also be used to store the test data used in the suite. The structure of the YAML file is very straightforward, with headings for each table and blocks below for each row in the table:
authors:
-
id: 100
name: "Mordecai Richler"
-
id: 101
name: "Farley Mowat"
books:
-
id: 200
isbn: "978-0671028473"
title: "The Apprenticeship of Duddy Kravitz"
author_id: 100
-
id: 201
isbn: "978-0887769252"
title: "Jacob Two-Two Meets the Hooded Fang"
author_id: 100
-
id: 202
isbn: "978-1550139891"
title: "The Farfarers"
author_id: 101
The YAML file data is retrieved by a test class using the PHPUnit_Extensions_Database_DataSet_YamlDataSet
function:
<?php
namespace DatabaseTesting\Tests\Persistence\Mappers;
use DatabaseTesting\Tests\Persistence\DatabaseTestCase;
class BookMapperTest extends DatabaseTestCase
{
/**
* Prepare data set for database tests
*
* @return \PHPUnit_Extensions_Database_DataSet_AbstractDataSet
*/
public function getDataSet()
{
return new \PHPUnit_Extensions_Database_DataSet_YamlDataSet( __DIR__ . '/../../Fixtures/library-test.yml' );
}
CSV
CSV datasets are slightly different in structure than XML and YAML datasets, as you must load each table individually with the data for each table contained in a separate file. Maintaining the datasets this way, however, is much easier if you are extracting the test data from an existing database, as the data dumps can be configured per file to match this format.
library-test-authors.csv
id,name
100,"Mordecai Richler"
101,"Farley Mowat"
library-test-books.csv
id,isbn,title,author_id
200,"978-0671028473","The Apprenticeship of Duddy Kravitz",100
201,"978-0887769252","Jacob Two-Two Meets the Hooded Fang",100
202,"978-1550139891","The Farfarers",101
The CSV files are retrieved per table by a test class using the PHPUnit_Extensions_Database_DataSet_CsvDataSet
function:
<?php
namespace DatabaseTesting\Tests\Persistence\Mappers;
use DatabaseTesting\Tests\Persistence\DatabaseTestCase;
class BookMapperTest extends DatabaseTestCase
{
/**
* Prepare data set for database tests
*
* @return \PHPUnit_Extensions_Database_DataSet_AbstractDataSet
*/
public function getDataSet()
{
$dataSet = new \PHPUnit_Extensions_Database_DataSet_CsvDataSet();
$dataSet->addTable( 'authors', __DIR__ . '/../../Fixtures/library-test-authors.csv' );
$dataSet->addTable( 'books', __DIR__ . '/../../Fixtures/library-test-books.csv' );
return $dataSet;
}
Arrays
Array datasets can be included directly in the getDataSet
function, or can be loaded using included files similar to the
methods used above. However, you need to write your own data iterator which you can find a sample of in the
PHP Manual. In the case of this example, the
dataset is loaded using the custom ArrayDataSet
class:
<?php
namespace DatabaseTesting\Tests\Persistence\Mappers;
use DatabaseTesting\Tests\Persistence\ArrayDataSet;
use DatabaseTesting\Tests\Persistence\DatabaseTestCase;
class BookMapperTest extends DatabaseTestCase
{
/**
* Prepare data set for database tests
*
* @return \PHPUnit_Extensions_Database_DataSet_AbstractDataSet
*/
public function getDataSet()
{
return new ArrayDataSet( array(
'authors' => array(
array( 'id' => 100, 'name' => 'Mordecai Richler' ),
array( 'id' => 101, 'name' => 'Farley Mowat' )
),
'books' => array(
array( 'id' => 200, 'isbn' => '978-0671028473', 'title' => 'The Apprenticeship of Duddy Kravitz', 'author_id' => 100 ),
array( 'id' => 201, 'isbn' => '978-0887769252', 'title' => 'Jacob Two-Two Meets the Hooded Fang', 'author_id' => 100 ),
array( 'id' => 202, 'isbn' => '978-1550139891', 'title' => 'The Farfarers', 'author_id' => 101 )
),
) );
}
Writing The Tests
Finally, you can now write the tests for the BookMapper class. Start with the first incomplete test function and work your way down.
<?php
public function testFetchAll()
{
$bookMapper = new BookMapper( $this->getAdapter() );
$allBooks = $bookMapper->fetchAll();
//verify the number of books returned
$this->assertCount( 3, $allBooks );
//verify each item
/** @var \DatabaseTesting\Model\Book $book */
$book = $allBooks[0];
$this->assertEquals( 200, $book->getId() );
$this->assertEquals( '978-0671028473', $book->getIsbn() );
$this->assertEquals( 100, $book->getAuthorId() );
$this->assertEquals( 'Mordecai Richler', $book->getAuthorName() );
$this->assertEquals( 'The Apprenticeship of Duddy Kravitz', $book->getTitle() );
$book = $allBooks[1];
$this->assertEquals( 201, $book->getId() );
$this->assertEquals( '978-0887769252', $book->getIsbn() );
$this->assertEquals( 100, $book->getAuthorId() );
$this->assertEquals( 'Mordecai Richler', $book->getAuthorName() );
$this->assertEquals( 'Jacob Two-Two Meets the Hooded Fang', $book->getTitle() );
$book = $allBooks[2];
$this->assertEquals( 202, $book->getId() );
$this->assertEquals( '978-1550139891', $book->getIsbn() );
$this->assertEquals( 101, $book->getAuthorId() );
$this->assertEquals( 'Farley Mowat', $book->getAuthorName() );
$this->assertEquals( 'The Farfarers', $book->getTitle() );
}
In this test, you should create a $bookMapper
object, using the test adapter from the DatabaseTestCase
class. Call
the fetchAll()
function, and verify each of the expected properties in the resulting data against the values inserted in
the test datasets.
Now if you execute the tests, you should see that there are 4 incomplete tests and one passing test:
fraser@localhost database-testing $ vendor/bin/phpunit
PHPUnit 4.8.18 by Sebastian Bergmann and contributors.
.IIII
Time: 191 ms, Memory: 6.50Mb
OK, but incomplete, skipped, or risky tests!
Tests: 5, Assertions: 16, Incomplete: 4.
Continue filling in each of the remaining incomplete tests.
<?php
public function testFetchByISBN()
{
$bookMapper = new BookMapper( $this->getAdapter() );
/** @var \DatabaseTesting\Model\Book $book */
$book = $bookMapper->fetchByISBN( '978-0887769252' );
$this->assertEquals( 201, $book->getId() );
$this->assertEquals( '978-0887769252', $book->getIsbn() );
$this->assertEquals( 100, $book->getAuthorId() );
$this->assertEquals( 'Mordecai Richler', $book->getAuthorName() );
$this->assertEquals( 'Jacob Two-Two Meets the Hooded Fang', $book->getTitle() );
//also verify a not-found request
$book = $bookMapper->fetchByISBN( '978-9999988888' );
$this->assertNull( $book );
}
public function testInsert()
{
//create a new book object
$newBook = new Book();
$newBook->setIsbn( '0-316-88179-1' );
$newBook->setAuthorId( 101 );
$newBook->setTitle( 'Never Cry Wolf' );
//insert the book into the database
$bookMapper = new BookMapper( $this->getAdapter() );
$bookMapper->insert( $newBook );
//fetch the book back using fetchByISBN
$fetchedBook = $bookMapper->fetchByISBN( '0-316-88179-1' );
//verify that the book fetched matches the book object
$this->assertNotNull( $fetchedBook->getId() );
$this->assertEquals( '0-316-88179-1', $fetchedBook->getIsbn() );
$this->assertEquals( 101, $fetchedBook->getAuthorId() );
$this->assertEquals( 'Farley Mowat', $fetchedBook->getAuthorName() );
$this->assertEquals( 'Never Cry Wolf', $fetchedBook->getTitle() );
}
public function testUpdate()
{
$bookMapper = new BookMapper( $this->getAdapter() );
//fetch a book from the database
/** @var \DatabaseTesting\Model\Book $book */
$book = $bookMapper->fetchByISBN( '978-0887769252' );
$this->assertEquals( 201, $book->getId() );
$this->assertEquals( '978-0887769252', $book->getIsbn() );
$this->assertEquals( 100, $book->getAuthorId() );
$this->assertEquals( 'Mordecai Richler', $book->getAuthorName() );
$this->assertEquals( 'Jacob Two-Two Meets the Hooded Fang', $book->getTitle() );
//update the object
$book->setAuthorId( 101 );
$book->setIsbn( '978-0-316-58633' );
//persist the record back to the database
$bookMapper->update( $book );
//fetch it back again
$fetchedBook = $bookMapper->fetchByISBN( '978-0-316-58633' );
//verify the book fetch matches the updated book object
$this->assertEquals( 201, $fetchedBook->getId() );
$this->assertEquals( '978-0-316-58633', $fetchedBook->getIsbn() );
$this->assertEquals( 101, $fetchedBook->getAuthorId() );
$this->assertEquals( 'Farley Mowat', $fetchedBook->getAuthorName() );
$this->assertEquals( 'Jacob Two-Two Meets the Hooded Fang', $fetchedBook->getTitle() );
}
public function testDelete()
{
$book = new Book();
$book->setId( 201 );
//delete a book from the database
$bookMapper = new BookMapper( $this->getAdapter() );
$bookMapper->delete( $book );
//ensure it cannot be fetched back
$fetchedBook = $bookMapper->fetchByISBN( '978-0887769252' );
$this->assertNull( $fetchedBook );
}
Finally, run the full test suite to ensure all tests are passing.
fraser@localhost database-testing $ vendor/bin/phpunit
PHPUnit 4.8.18 by Sebastian Bergmann and contributors.
.....
Time: 163 ms, Memory: 6.50Mb
OK (5 tests, 38 assertions)