📜 ⬆️ ⬇️

A lot of text about the practice of working with PHPUnit / DbUnit

Good day, friends!
I want to share my experience in dealing with PHPUnit / DbUnit in conjunction with MySQL. Next, a small background.

Brief background


In the process of writing a single web application, it became necessary to test the code in PHP, intensively interacting with the MySQL database. The project used the xUnit port - PHPUnit as the unit testing framework. As a result, it was decided to write tests for modules that directly interact with the database, picking up the PHPUnit / DbUnit plugin. Then I will talk about the difficulties that have arisen in writing tests and how I have overcome them. In response, I would like to receive comments from knowledgeable people regarding the correctness of my decisions.

How DbUnit works


The sub-item is intended for those who are not familiar with the testing methodology using PHPUnit and / or DbUnit. Who is not interested, you can safely move on to the next .
')
Further in the text:
Since the sub-item is for beginners, first we will consider the procedure of unit testing of ordinary PHP classes, and then the differences are described when testing the code that interacts with the database.

Testing common PHP classes

To test a class written in PHP using the PHPUnit framework, you need to create a test class that extends the base class PHPUnit_Framework_TestCase. Then create public methods in this class that begin with the word test (if you create a method that will be called differently, it will not be automatically called when the tests are run), and place in them code that performs actions on objects of the class under test and verifies the result. At this point you can finish and feed the received class phpunit, which, in turn, will consistently call all test methods and kindly provide a report on their work. However, in most cases, in each of the test methods there will be a repeating code that prepares the system for working with the object under test. In order to avoid duplication of code, protected setUp and tearDown methods that have an empty implementation are created in the PHPUnit_Framework_TestCase class. These methods are called before and after the launch of the next test method, respectively, and are used to prepare the system for performing test actions and clean it after each test is completed. In a test class that extends PHPUnit_Framework_TestCase, you can override these methods and put code that repeats earlier in each test method. As a result, the sequence of method calls during the test run will be as follows:
  1.   setUp () {/ * Set the system to the desired state * /} 
  2.   testMethod1 () {/ * tested class method 1 * /} 
  3.   tearDown () {/ * Cleaned the system * /} 
  1.   setUp () {/ * Set the system to the desired state * /} 
  2.   testMethod2 () {/ * tested class 2 method * /} 
  3.   tearDown () {/ * Cleaned the system * /} 
...
  1.   setUp () {/ * Set the system to the desired state * /} 
  2.   testMethodN () {/ * tested class N method * /} 
  3.   tearDown () {/ * Cleaned the system * /} 

Testing PHP code interacting with the database

The process of writing tests for code interacting with the database is practically the same as testing the usual PHP classes. First you need to create a test class that inherits PHPUnit_Extensions_Database_TestCase (class PHPUnit_Extensions_Database_TestCase itself inherits PHPUnit_Framework_TestCase), which will contain tests for the methods of the class under test. Then create test methods starting with the test prefix, and then feed this phpunit code with the name of the test class. The only difference is that in the test class it is necessary to implement two public methods - getConnection () and getDataSet (). The first method is necessary in order to teach DbUnit to work with the database ( PDO will have to be used), and the second one is to tell the framework what state to translate the database before performing the next test. By DataSet in the terminology DbUnit is understood as a set of one or more tables.

As mentioned above, before executing the next test (represented by the method in the test class), PHPUnit calls the special setUp () method to emulate the runtime environment for the object of the class under test. In the case of DbUnit, the default implementation of the setUp () method is no longer empty. Speaking in general terms, a set of databaseTester objects will be created inside the setUp () method, which, using the getConnection () method defined by us, will transfer the base to the state represented by a set of tables (DataSet) obtained by calling the getDataSet () method. If you were careful, the implementation of the getDataSet () method should also be provided by the test class, i.e. us. As a result, we get a similar sequence of calls.
  1.   setUp () {/ * Set the database in accordance with the data received from
                       getDataSet () * /} method 
  2.   testMethod1 () {/ * tested class method 1 * /} 
  3.   tearDown () {/ * Cleaned the system * /} 
  1.   setUp () {/ * Set the database in accordance with the data received from
                       getDataSet () * /} method 
  2.   testMethod2 () {/ * tested class 2 method * /} 
  3.   tearDown () {/ * Cleaned the system * /} 
...
  1.   setUp () {/ * Set the database in accordance with the data received from
                       getDataSet () * /} method 
  2.   testMethodN () {/ * tested class N method * /} 
  3.   tearDown () {/ * Cleaned the system * /} 

Little trouble


Operational situation: The database used in the project has several dozen tables, the MySQL InnoDB engine. The foreign key mechanism is actively used to maintain data consistency at the level of the database itself.

1. Initialization of the base

The first trouble that started to darken the testing process for me is the initialization of the database by the sets of tables created by me.

DbUnit allows you to create DataSet `s, receiving data from various sources:

Each of the above methods for creating sets of tables is implemented by a separate method of the PHPUnit_Extensions_Database_TestCase class.

I chose mysqldump as my assistant and rushed into the attack: I formed the necessary state of the database, unloaded it in xml and in the implementation of getDataSet () wrote something like:
public function getDataSet() { return $this->createMySQLXMLDataSet('db_init.xml'); // ,  mysqldump. } 


... and decided to get rid of the first test. However, he immediately received an exception in which it was unambiguously stated that the database could not be brought into a given state due to the presence of restrictions on foreign keys.

A few minutes of digging in the DbUnit source code showed that in the PHPUnit_Extensions_Database_TestCase :: setUp () method the base is set to the state according to the DataSet I specified, using the PHPUnit_Extensions_Database_Operation_Factory :: CLEAN_INSERT operation. The CLEAN_INSERT operation in turn is a factory-generated macro that includes two operations: PHPUnit_Extensions_Database_Operation_Factory :: TRUNCATE and PHPUnit_Extensions_Database_Operation_Factory :: INSERT. It is obvious that everything fell into place - it is not possible to make a TRUNCATE for the base, which has active restrictions on foreign keys FOREIGN KEY.

Need to solve. There are two ways - either temporarily disable FOREIGN KEY during testing (the dark path), or use the new PHPUnit_Extensions_Database_Operation_Factory :: DELETE_ALL command, detected while smoking the DbUnit source code (a light, but longer path). A minute later, the dark side in me overpowered, and I decided to go a simpler way - to disable integrity constraints on foreign keys during connection creation. Fortunately, the creation code was still written by me in the implementation of the getConnection () method.

A typical implementation of getConnection () looks like this:
 public function getConnection() { if (is_null($this->m_oConn)) { $oPdo = new PDO('mysql:dbname=db1;host=localhost', 'root', 'qwerty'); $this->m_oConn = $this->createDefaultDBConnection($oPdo, 'db1'); } return $this->m_oConn; } 

$ m_oConn is a test class member variable, which is some kind of wrapper around a PDO. To be precise, this is an instance of the class PHPUnit_Extensions_Database_DB_DefaultDatabaseConnection. Having added the line $ oPdo-> exec ('SET foreign_key_checks = 0') right after creating the PDO object, I solved the initialization problem for a while.

Actually, as one would expect, after a while I ran into a rake with inconsistency of data in the database and had to return to the bright path, namely, to refuse to disable foreign keys and replace TRUNCATE with DELETE_ALL.

The next review of the source showed that you need to dig into the implementation of PHPUnit_Extensions_Database_TestCase :: setUp (). Here is its code:
 protected function setUp() { parent::setUp(); // PHPUnit_Framework_TestCase::setUp() -   $this->databaseTester = NULL; $this->getDatabaseTester()->setSetUpOperation($this->getSetUpOperation()); $this->getDatabaseTester()->setDataSet($this->getDataSet()); $this->getDatabaseTester()->onSetUp(); } 


and here is the getSetUpOperation () method:
 protected function getSetUpOperation() { return PHPUnit_Extensions_Database_Operation_Factory::CLEAN_INSERT(); } 


By overriding the getSetUpOperation () method in your test class to:
 protected function getSetUpOperation() { return PHPUnit_Extensions_Database_Operation_Factory::INSERT(); } 

I got rid of TRUNCATE, but added the need to implement database cleanup. Since our database contains several views, a thoughtless PHPUnit_Extensions_Database_Operation_Factory :: DELETE_ALL () call for the DataSet from all the database tables would not lead to anything good. In addition, I thought that the functionality of cleaning the database can be quite useful not only at the time of initialization of the test, so I decided to design it as an independent method:
 protected function clearDb() { $aTableNames = $this->getConnection()->createDataSet()->getTableNames(); foreach ($aTableNames as $i => $sTableName) { if (false === strpos($sTableName, 'view_')) continue; unset($aTableNames[$i]); } $aTableNames = array_values($aTableNames); $op = \PHPUnit_Extensions_Database_Operation_Factory::DELETE_ALL(); $op->execute($this->getConnection(), $this->getConnection()->createDataSet($aTableNames)); } 

The code makes the assumption that all views that exist in the database begin with the prefix view_.
It remains only to override the setUp () method so that it clears the database on its own before giving it to the databaseTester data.
 protected function setUp() { $this->clearDb(); parent::setUp(); } 


2. Comparing Table Sets

The following problem arose when trying to compare two DataSets, one obtained directly from the database (formed as a result of executing the code under test) and the other created by hands in advance and representing the desired result.

The current state of the database can be obtained in the following way:
 $oActualDataSet = $this->getConnection()->createDataSet(); 


When I saw the PHPUnit_Extensions_Database_TestCase :: assertDataSetsEqual method in manas, comparing two sets of tables, I was very happy. As it turned out a bit early. The results of the comparison were very unexpected. Two identical-looking tables when comparing caused the fall of assert.

The debugger, in turn, showed that the trouble is in the DataSet, derived from the database. Apparently for the purpose of optimization, when you call $ this-> getConnection () -> createDataSet () in the test class, only a partial load of the table set occurs, and to be exact, only the DataSet metadata (the name of the base and some other husk) .

The source code for the PHPUnit_Extensions_Database_TestCase :: assertDataSetsEqual is as follows:
 public static function assertDataSetsEqual(PHPUnit_Extensions_Database_DataSet_IDataSet $expected, PHPUnit_Extensions_Database_DataSet_IDataSet $actual, $message = '') { $constraint = new PHPUnit_Extensions_Database_Constraint_DataSetIsEqual($expected); self::assertThat($actual, $constraint, $message); } 


If you continue to unwind the call chain, then after several delegations, the comparison operation itself will come to PHPUnit_Extensions_Database_DataSet_AbstractTable :: matches (PHPUnit_Extensions_Database_DataSet_ITable $ other), in which two tables will be compared. In this method, when comparing tables, the data in them will necessarily be pulled from the base. But that is if it comes to this method. Because before comparing tables of two DataSet `s among themselves, comparisons of DataSet` s is made. As a result, assert in some place does not pass. This bug is in the issues PHPUnit / DbUnit on github, it is already several months old.

In anticipation of correcting this error, I quickly put on a method for comparing sets of tables. Not exactly in the spirit of DbUnit, where everything is done by a universal sequence of calls to evaluate -> matches of specific implementations of compared objects, but working:
 public function compareDataSets(PHPUnit_Extensions_Database_DataSet_IDataSet $expected, PHPUnit_Extensions_Database_DataSet_IDataSet $actual, $message = '') { $aExpectedNames = $expected->getTableNames(); $aActualNames = $actual->getTableNames(); sort($aActualNames); sort($aExpectedNames); $this->assertEquals($aExpectedNames, $aActualNames, $message); foreach ($aActualNames as $sTableName) { $atable = $actual->getTable($sTableName); $etable = $expected->getTable($sTableName); if (0 == $atable->getRowCount()) { $this->assertEquals(0, $etable->getRowCount(), $message); } else { $this->assertTablesEqual($etable, $atable, $message); } } } 


Conclusion


The behavior of DbUnit, described in the article, was obtained using DbUnit 1.1.2, PHPUnit 3.6.10 and MySQL 5.1. As a result of adding all the crutches described above, a base class was created that extends PHPUnit_Extensions_Database_TestCase and contains all these methods. The rest of the test classes of the project, working with the base, are inherited from this base class.

I paraphrase one good person - I don’t know how to fight , but I really love it. So I would like to hear comments about the methods presented in the article.

Source: https://habr.com/ru/post/138862/


All Articles