unit tests: MDL-17646 Helper functions for loading and cleaning up test data.
[moodle.git] / lib / simpletestlib.php
1 <?php // $Id$
2 /**
3  * Utility functions to make unit testing easier.
4  *
5  * These functions, particularly the the database ones, are quick and
6  * dirty methods for getting things done in test cases. None of these
7  * methods should be used outside test code.
8  *
9  * @copyright &copy; 2006 The Open University
10  * @author T.J.Hunt@open.ac.uk
11  * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
12  * @version $Id$
13  * @package SimpleTestEx
14  */
16 require_once(dirname(__FILE__) . '/../config.php');
17 require_once($CFG->libdir . '/simpletestlib/simpletest.php');
18 require_once($CFG->libdir . '/simpletestlib/unit_tester.php');
19 require_once($CFG->libdir . '/simpletestlib/expectation.php');
20 require_once($CFG->libdir . '/simpletestlib/reporter.php');
21 require_once($CFG->libdir . '/simpletestlib/web_tester.php');
22 require_once($CFG->libdir . '/simpletestlib/mock_objects.php');
24 /**
25  * Recursively visit all the files in the source tree. Calls the callback
26  * function with the pathname of each file found.
27  *
28  * @param $path the folder to start searching from.
29  * @param $callback the function to call with the name of each file found.
30  * @param $fileregexp a regexp used to filter the search (optional).
31  * @param $exclude If true, pathnames that match the regexp will be ingored. If false,
32  *     only files that match the regexp will be included. (default false).
33  * @param array $ignorefolders will not go into any of these folders (optional).
34  */
35 function recurseFolders($path, $callback, $fileregexp = '/.*/', $exclude = false, $ignorefolders = array()) {
36     $files = scandir($path);
38     foreach ($files as $file) {
39         $filepath = $path .'/'. $file;
40         if ($file == '.' || $file == '..') {
41             continue;
42         } else if (is_dir($filepath)) {
43             if (!in_array($filepath, $ignorefolders)) {
44                 recurseFolders($filepath, $callback, $fileregexp, $exclude, $ignorefolders);
45             }
46         } else if ($exclude xor preg_match($fileregexp, $filepath)) {
47             call_user_func($callback, $filepath);
48         }
49     }
50 }
52 /**
53  * An expectation for comparing strings ignoring whitespace.
54  */
55 class IgnoreWhitespaceExpectation extends SimpleExpectation {
56     var $expect;
58     function IgnoreWhitespaceExpectation($content, $message = '%s') {
59         $this->SimpleExpectation($message);
60         $this->expect=$this->normalise($content);
61     }
63     function test($ip) {
64         return $this->normalise($ip)==$this->expect;
65     }
67     function normalise($text) {
68         return preg_replace('/\s+/m',' ',trim($text));
69     }
71     function testMessage($ip) {
72         return "Input string [$ip] doesn't match the required value.";
73     }
74 }
76 /**
77  * An Expectation that two arrays contain the same list of values.
78  */
79 class ArraysHaveSameValuesExpectation extends SimpleExpectation {
80     var $expect;
82     function ArraysHaveSameValuesExpectation($expected, $message = '%s') {
83         $this->SimpleExpectation($message);
84         if (!is_array($expected)) {
85             trigger_error('Attempt to create an ArraysHaveSameValuesExpectation ' .
86                     'with an expected value that is not an array.');
87         }
88         $this->expect = $this->normalise($expected);
89     }
91     function test($actual) {
92         return $this->normalise($actual) == $this->expect;
93     }
95     function normalise($array) {
96         sort($array);
97         return $array;
98     }
100     function testMessage($actual) {
101         return 'Array [' . implode(', ', $actual) .
102                 '] does not contain the expected list of values [' . implode(', ', $this->expect) . '].';
103     }
106 /**
107  * An Expectation that compares to objects, and ensures that for every field in the
108  * expected object, there is a key of the same name in the actual object, with
109  * the same value. (The actual object may have other fields to, but we ignore them.)
110  */
111 class CheckSpecifiedFieldsExpectation extends SimpleExpectation {
112     var $expect;
114     function CheckSpecifiedFieldsExpectation($expected, $message = '%s') {
115         $this->SimpleExpectation($message);
116         if (!is_object($expected)) {
117             trigger_error('Attempt to create a CheckSpecifiedFieldsExpectation ' .
118                     'with an expected value that is not an object.');
119         }
120         $this->expect = $expected;
121     }
123     function test($actual) {
124         foreach ($this->expect as $key => $value) {
125             if (isset($value) && isset($actual->$key) && $actual->$key == $value) {
126                 // OK
127             } else if (is_null($value) && is_null($actual->$key)) {
128                 // OK
129             } else {
130                 return false;
131             }
132         }
133         return true;
134     }
136     function testMessage($actual) {
137         $mismatches = array();
138         foreach ($this->expect as $key => $value) {
139             if (isset($value) && isset($actual->$key) && $actual->$key == $value) {
140                 // OK
141             } else if (is_null($value) && is_null($actual->$key)) {
142                 // OK
143             } else {
144                 $mismatches[] = $key;
145             }
146         }
147         return 'Actual object does not have all the same fields with the same values as the expected object (' .
148                 implode(', ', $mismatches) . ').';
149     }
152 class MoodleUnitTestCase extends UnitTestCase {
153     public $tables = array();
154     public $pkfile;
155     public $cfg;
156     public $DB;
158     /**
159      * In the constructor, record the max(id) of each test table into a csv file.
160      * If this file already exists, it means that a previous run of unit tests
161      * did not complete, and has left data undeleted in the DB. This data is then
162      * deleted and the file is retained. Otherwise it is created.
163      * @throws moodle_exception if CSV file cannot be created
164      */
165     public function __construct($label = false) {
166         parent::UnitTestCase($label);
167         // MDL-16483 Get PKs and save data to text file
168         global $DB, $CFG;
169         $this->pkfile = $CFG->dataroot.'/testtablespks.csv';
170         $this->cfg = $CFG;
172         UnitTestDB::instantiate();
174         $tables = $DB->get_tables();
176         // The file exists, so use it to truncate tables (tests aborted before test data could be removed)
177         if (file_exists($this->pkfile)) {
178             $this->truncate_test_tables($this->get_table_data($this->pkfile));
180         } else { // Create the file
181             $tabledata = '';
183             foreach ($tables as $table) {
184                 if ($table != 'sessions2') {
185                     if (!$max_id = $DB->get_field_sql("SELECT MAX(id) FROM {$CFG->unittestprefix}{$table}")) {
186                         $max_id = 0;
187                     }
188                     $tabledata .= "$table, $max_id\n";
189                 }
190             }
191             if (!file_put_contents($this->pkfile, $tabledata)) {
192                 $a = new stdClass();
193                 $a->filename = $this->pkfile;
194                 throw new moodle_exception('testtablescsvfileunwritable', 'simpletest', '', $a);
195             }
196         }
197     }
199     /**
200      * Given an array of tables and their max id, truncates all test table records whose id is higher than the ones in the $tabledata array.
201      * @param array $tabledata
202      */
203     private function truncate_test_tables($tabledata) {
204         global $CFG, $DB;
206         $tables = $DB->get_tables();
208         foreach ($tables as $table) {
209             if ($table != 'sessions2' && isset($tabledata[$table])) {
210                 // $DB->delete_records_select($table, "id > ?", array($tabledata[$table]));
211             }
212         }
213     }
215     /**
216      * Given a filename, opens it and parses the csv contained therein. It expects two fields per line:
217      * 1. Table name
218      * 2. Max id
219      * @param string $filename
220      * @throws moodle_exception if file doesn't exist
221      */
222     public function get_table_data($filename) {
223         if (file_exists($this->pkfile)) {
224             $handle = fopen($this->pkfile, 'r');
225             $tabledata = array();
227             while (($data = fgetcsv($handle, 1000, ",")) !== false) {
228                 $tabledata[$data[0]] = $data[1];
229             }
230             return $tabledata;
231         } else {
232             $a = new stdClass();
233             $a->filename = $this->pkfile;
234             debug_print_backtrace();
235             throw new moodle_exception('testtablescsvfilemissing', 'simpletest', '', $a);
236             return false;
237         }
238     }
240     /**
241      * Method called before each test method. Replaces the real $DB with the one configured for unit tests (different prefix, $CFG->unittestprefix).
242      * Also detects if this config setting is properly set, and if the user table exists.
243      * TODO Improve detection of incorrectly built DB test tables (e.g. detect version discrepancy and offer to upgrade/rebuild)
244      */
245     public function setUp() {
246         parent::setUp();
247         UnitTestDB::instantiate();
248         global $DB;
249         $this->DB =& $DB;
250         ob_start();
251     }
253     /**
254      * Method called after each test method. Doesn't do anything extraordinary except restore the global $DB to the real one.
255      */
256     public function tearDown() {
257         global $DB;
258         if (empty($DB)) {
259             $DB = $this->DB;
260         }
261         $DB->cleanup();
262         parent::tearDown();
264         // Output buffering
265         if (ob_get_length() > 0) {
266             ob_end_flush();
267         }
268     }
270     /**
271      * This will execute once all the tests have been run. It should delete the text file holding info about database contents prior to the tests
272      * It should also detect if data is missing from the original tables.
273      */
274     public function __destruct() {
275         global $CFG, $DB;
277         $CFG = $this->cfg;
278         $this->tearDown();
279         UnitTestDB::restore();
280         fulldelete($this->pkfile);
281     }
283     /**
284      * Load a table with some rows of data. A typical call would look like:
285      *
286      * $config = $this->load_test_data('config_plugins',
287      *         array('plugin', 'name', 'value'), array(
288      *         array('frog', 'numlegs', 2),
289      *         array('frog', 'sound', 'croak'),
290      *         array('frog', 'action', 'jump'),
291      * ));
292      *
293      * @param string $table the table name.
294      * @param array $cols the columns to fill.
295      * @param array $data the data to load.
296      * @return array $objects corresponding to $data.
297      */
298     public function load_test_data($table, array $cols, array $data) {
299         global $DB;
300         $results = array();
301         foreach ($data as $rowid => $row) {
302             $obj = new stdClass;
303             foreach ($cols as $key => $colname) {
304                 $obj->$colname = $row[$key];
305             }
306             $obj->id = $DB->insert_record($table, $obj);
307             $results[$rowid] = $obj;
308         }
309         return $results;
310     }
312     /**
313      * Clean up data loaded with load_test_data. The call corresponding to the
314      * example load above would be:
315      *
316      * $this->delete_test_data('config_plugins', $config);
317      *
318      * @param string $table the table name.
319      * @param array $rows the rows to delete. Actually, only $rows[$key]->id is used.
320      */
321     public function delete_test_data($table, array $rows) {
322         global $DB;
323         $ids = array();
324         foreach ($rows as $row) {
325             $ids[] = $row->id;
326         }
327         $DB->delete_records_list($table, 'id', $ids);
328     }
331 /**
332  * This is a Database Engine proxy class: It replaces the global object $DB with itself through a call to the
333  * static instantiate() method, and restores the original global $DB through restore().
334  * Internally, it routes all calls to $DB to a real instance of the database engine (aggregated as a member variable),
335  * except those that are defined in this proxy class. This makes it possible to add extra code to the database engine
336  * without subclassing it.
337  */
338 class UnitTestDB {
339     public static $DB;
340     private static $real_db;
342     public $table_data = array();
344     /**
345      * Call this statically to connect to the DB using the unittest prefix, instantiate
346      * the unit test db, store it as a member variable, instantiate $this and use it as the new global $DB.
347      */
348     public static function instantiate() {
349         global $CFG, $DB;
350         UnitTestDB::$real_db = clone($DB);
352         if (empty($CFG->unittestprefix)) {
353             print_error("prefixnotset", 'simpletest');
354         }
356         if (empty(UnitTestDB::$DB)) {
357             UnitTestDB::$DB = moodle_database::get_driver_instance($CFG->dbtype, $CFG->dblibrary);
358             UnitTestDB::$DB->connect($CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->unittestprefix);
359         }
361         $manager = UnitTestDB::$DB->get_manager();
363         if (!$manager->table_exists('user')) {
364             print_error('tablesnotsetup', 'simpletest');
365         }
367         $DB = new UnitTestDB();
368     }
370     public function __call($method, $args) {
371         // Set args to null if they don't exist (up to 10 args should do)
372         if (!method_exists($this, $method)) {
373             return call_user_func_array(array(UnitTestDB::$DB, $method), $args);
374         } else {
375             call_user_func_array(array($this, $method), $args);
376         }
377     }
379     public function __get($variable) {
380         return UnitTestDB::$DB->$variable;
381     }
383     public function __set($variable, $value) {
384         UnitTestDB::$DB->$variable = $value;
385     }
387     public function __isset($variable) {
388         return isset(UnitTestDB::$DB->$variable);
389     }
391     public function __unset($variable) {
392         unset(UnitTestDB::$DB->$variable);
393     }
395     /**
396      * Overriding insert_record to keep track of the ids inserted during unit tests, so that they can be deleted afterwards
397      */
398     public function insert_record($table, $dataobject, $returnid=true, $bulk=false) {
399         global $DB;
400         $id = UnitTestDB::$DB->insert_record($table, $dataobject, $returnid, $bulk);
401         $this->table_data[$table][] = $id;
402         return $id;
403     }
405     /**
406      * Overriding update_record: If we are updating a record that was NOT inserted by unit tests,
407      * throw an exception and cancel update.
408      * @throws moodle_exception If trying to update a record not inserted by unit tests.
409      */
410     public function update_record($table, $dataobject, $bulk=false) {
411         global $DB;
412         if ((empty($this->table_data[$table]) || !in_array($dataobject->id, $this->table_data[$table])) && !($table == 'course_categories' && $dataobject->id == 1)) {
413             // return UnitTestDB::$DB->update_record($table, $dataobject, $bulk);
414             $a = new stdClass();
415             $a->id = $dataobject->id;
416             $a->table = $table;
417             debug_print_backtrace();
418             throw new moodle_exception('updatingnoninsertedrecord', 'simpletest', '', $a);
419         } else {
420             return UnitTestDB::$DB->update_record($table, $dataobject, $bulk);
421         }
422     }
424     /**
425      * Overriding delete_record: If we are deleting a record that was NOT inserted by unit tests,
426      * throw an exception and cancel delete.
427      * @throws moodle_exception If trying to delete a record not inserted by unit tests.
428      */
429     public function delete_records($table, array $conditions=array()) {
430         global $DB;
431         $tables_to_ignore = array('context_temp');
433         $a = new stdClass();
434         $a->table = $table;
436         // Get ids matching conditions
437         if (!$ids_to_delete = $DB->get_field($table, 'id', $conditions)) {
438             return UnitTestDB::$DB->delete_records($table, $conditions);
439         }
441         $proceed_with_delete = true;
443         if (!is_array($ids_to_delete)) {
444             $ids_to_delete = array($ids_to_delete);
445         }
447         foreach ($ids_to_delete as $id) {
448             if (!in_array($table, $tables_to_ignore) && (empty($this->table_data[$table]) || !in_array($id, $this->table_data[$table]))) {
449                 $proceed_with_delete = false;
450                 $a->id = $id;
451                 break;
452             }
453         }
455         if ($proceed_with_delete) {
456             return UnitTestDB::$DB->delete_records($table, $conditions);
457         } else {
458             debug_print_backtrace();
459             throw new moodle_exception('deletingnoninsertedrecord', 'simpletest', '', $a);
460         }
461     }
463     /**
464      * Overriding delete_records_select: If we are deleting a record that was NOT inserted by unit tests,
465      * throw an exception and cancel delete.
466      * @throws moodle_exception If trying to delete a record not inserted by unit tests.
467      */
468     public function delete_records_select($table, $select, array $params=null) {
469         global $DB;
470         $a = new stdClass();
471         $a->table = $table;
473         // Get ids matching conditions
474         if (!$ids_to_delete = $DB->get_field_select($table, 'id', $select, $params)) {
475             return UnitTestDB::$DB->delete_records_select($table, $select, $params);
476         }
478         $proceed_with_delete = true;
480         foreach ($ids_to_delete as $id) {
481             if (!in_array($id, $this->table_data[$table])) {
482                 $proceed_with_delete = false;
483                 $a->id = $id;
484                 break;
485             }
486         }
488         if ($proceed_with_delete) {
489             return UnitTestDB::$DB->delete_records_select($table, $select, $params);
490         } else {
491             debug_print_backtrace();
492             throw new moodle_exception('deletingnoninsertedrecord', 'simpletest', '', $a);
493         }
494     }
496     /**
497      * Removes from the test DB all the records that were inserted during unit tests,
498      */
499     public function cleanup() {
500         global $DB;
501         foreach ($this->table_data as $table => $ids) {
502             foreach ($ids as $id) {
503                 $DB->delete_records($table, array('id' => $id));
504             }
505         }
506     }
508     /**
509      * Restores the global $DB object.
510      */
511     public static function restore() {
512         global $DB;
513         $DB = UnitTestDB::$real_db;
514     }
516     public function get_field($table, $return, array $conditions) {
517         if (!is_array($conditions)) {
518             debug_print_backtrace();
519         }
520         return UnitTestDB::$DB->get_field($table, $return, $conditions);
521     }
523 ?>