unit tests: MDL-17646 Helper functions for loading and cleaning up test data.
[moodle.git] / lib / simpletestlib.php
CommitLineData
56768525 1<?php // $Id$
3ef8c936 2/**
3 * Utility functions to make unit testing easier.
b9c639d6 4 *
3ef8c936 5 * These functions, particularly the the database ones, are quick and
b9c639d6 6 * dirty methods for getting things done in test cases. None of these
3ef8c936 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 */
15
16require_once(dirname(__FILE__) . '/../config.php');
17require_once($CFG->libdir . '/simpletestlib/simpletest.php');
18require_once($CFG->libdir . '/simpletestlib/unit_tester.php');
19require_once($CFG->libdir . '/simpletestlib/expectation.php');
4309bd17 20require_once($CFG->libdir . '/simpletestlib/reporter.php');
a205dcdc 21require_once($CFG->libdir . '/simpletestlib/web_tester.php');
76144765 22require_once($CFG->libdir . '/simpletestlib/mock_objects.php');
3ef8c936 23
24/**
25 * Recursively visit all the files in the source tree. Calls the callback
b9c639d6 26 * function with the pathname of each file found.
27 *
28 * @param $path the folder to start searching from.
3ef8c936 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).
b9c639d6 31 * @param $exclude If true, pathnames that match the regexp will be ingored. If false,
3ef8c936 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).
b9c639d6 34 */
3ef8c936 35function recurseFolders($path, $callback, $fileregexp = '/.*/', $exclude = false, $ignorefolders = array()) {
36 $files = scandir($path);
37
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}
51
52/**
53 * An expectation for comparing strings ignoring whitespace.
54 */
55class IgnoreWhitespaceExpectation extends SimpleExpectation {
56 var $expect;
57
58 function IgnoreWhitespaceExpectation($content, $message = '%s') {
59 $this->SimpleExpectation($message);
60 $this->expect=$this->normalise($content);
61 }
62
63 function test($ip) {
64 return $this->normalise($ip)==$this->expect;
65 }
66
67 function normalise($text) {
68 return preg_replace('/\s+/m',' ',trim($text));
69 }
70
71 function testMessage($ip) {
72 return "Input string [$ip] doesn't match the required value.";
73 }
74}
75
76/**
77 * An Expectation that two arrays contain the same list of values.
78 */
79class ArraysHaveSameValuesExpectation extends SimpleExpectation {
80 var $expect;
81
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 }
90
91 function test($actual) {
92 return $this->normalise($actual) == $this->expect;
93 }
94
95 function normalise($array) {
96 sort($array);
97 return $array;
98 }
99
100 function testMessage($actual) {
101 return 'Array [' . implode(', ', $actual) .
102 '] does not contain the expected list of values [' . implode(', ', $this->expect) . '].';
103 }
104}
105
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 */
111class CheckSpecifiedFieldsExpectation extends SimpleExpectation {
112 var $expect;
113
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 }
122
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 }
135
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 }
150}
151
b9c639d6 152class MoodleUnitTestCase extends UnitTestCase {
356e0010 153 public $tables = array();
90997d6d 154 public $pkfile;
155 public $cfg;
274e2947 156 public $DB;
356e0010 157
90997d6d 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 */
b9c639d6 165 public function __construct($label = false) {
166 parent::UnitTestCase($label);
90997d6d 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;
274e2947 171
172 UnitTestDB::instantiate();
90997d6d 173
174 $tables = $DB->get_tables();
175
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));
179
180 } else { // Create the file
181 $tabledata = '';
182
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)) {
5d1381c2 192 $a = new stdClass();
193 $a->filename = $this->pkfile;
194 throw new moodle_exception('testtablescsvfileunwritable', 'simpletest', '', $a);
90997d6d 195 }
196 }
197 }
198
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;
205
206 $tables = $DB->get_tables();
207
208 foreach ($tables as $table) {
209 if ($table != 'sessions2' && isset($tabledata[$table])) {
5d1381c2 210 // $DB->delete_records_select($table, "id > ?", array($tabledata[$table]));
90997d6d 211 }
212 }
356e0010 213 }
214
90997d6d 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();
226
227 while (($data = fgetcsv($handle, 1000, ",")) !== false) {
228 $tabledata[$data[0]] = $data[1];
229 }
230 return $tabledata;
231 } else {
5d1381c2 232 $a = new stdClass();
233 $a->filename = $this->pkfile;
234 debug_print_backtrace();
235 throw new moodle_exception('testtablescsvfilemissing', 'simpletest', '', $a);
90997d6d 236 return false;
237 }
238 }
239
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 */
356e0010 245 public function setUp() {
356e0010 246 parent::setUp();
5d1381c2 247 UnitTestDB::instantiate();
274e2947 248 global $DB;
249 $this->DB =& $DB;
915d745f 250 ob_start();
5d1381c2 251 }
252
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;
274e2947 258 if (empty($DB)) {
259 $DB = $this->DB;
260 }
5d1381c2 261 $DB->cleanup();
262 parent::tearDown();
747f4a4c 263
264 // Output buffering
915d745f 265 if (ob_get_length() > 0) {
266 ob_end_flush();
267 }
5d1381c2 268 }
269
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;
276
277 $CFG = $this->cfg;
278 $this->tearDown();
279 UnitTestDB::restore();
280 fulldelete($this->pkfile);
281 }
821e4ecf 282
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 }
311
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 }
5d1381c2 329}
330
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 */
338class UnitTestDB {
339 public static $DB;
340 private static $real_db;
356e0010 341
5d1381c2 342 public $table_data = array();
343
5d1381c2 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);
b9c639d6 351
90997d6d 352 if (empty($CFG->unittestprefix)) {
b9c639d6 353 print_error("prefixnotset", 'simpletest');
354 }
355
274e2947 356 if (empty(UnitTestDB::$DB)) {
357 UnitTestDB::$DB = moodle_database::get_driver_instance($CFG->dbtype, $CFG->dblibrary);
beaa43db 358 UnitTestDB::$DB->connect($CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->unittestprefix);
274e2947 359 }
360
5d1381c2 361 $manager = UnitTestDB::$DB->get_manager();
356e0010 362
363 if (!$manager->table_exists('user')) {
b9c639d6 364 print_error('tablesnotsetup', 'simpletest');
365 }
5d1381c2 366
367 $DB = new UnitTestDB();
368 }
369
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 }
378
379 public function __get($variable) {
380 return UnitTestDB::$DB->$variable;
381 }
382
383 public function __set($variable, $value) {
384 UnitTestDB::$DB->$variable = $value;
385 }
386
387 public function __isset($variable) {
388 return isset(UnitTestDB::$DB->$variable);
389 }
390
391 public function __unset($variable) {
392 unset(UnitTestDB::$DB->$variable);
b9c639d6 393 }
394
90997d6d 395 /**
5d1381c2 396 * Overriding insert_record to keep track of the ids inserted during unit tests, so that they can be deleted afterwards
90997d6d 397 */
5d1381c2 398 public function insert_record($table, $dataobject, $returnid=true, $bulk=false) {
356e0010 399 global $DB;
5d1381c2 400 $id = UnitTestDB::$DB->insert_record($table, $dataobject, $returnid, $bulk);
401 $this->table_data[$table][] = $id;
402 return $id;
403 }
356e0010 404
5d1381c2 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;
747f4a4c 412 if ((empty($this->table_data[$table]) || !in_array($dataobject->id, $this->table_data[$table])) && !($table == 'course_categories' && $dataobject->id == 1)) {
274e2947 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);
5d1381c2 419 } else {
420 return UnitTestDB::$DB->update_record($table, $dataobject, $bulk);
421 }
b9c639d6 422 }
423
424 /**
5d1381c2 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.
b9c639d6 428 */
274e2947 429 public function delete_records($table, array $conditions=array()) {
5d1381c2 430 global $DB;
747f4a4c 431 $tables_to_ignore = array('context_temp');
432
5d1381c2 433 $a = new stdClass();
434 $a->table = $table;
435
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 }
440
441 $proceed_with_delete = true;
442
443 if (!is_array($ids_to_delete)) {
444 $ids_to_delete = array($ids_to_delete);
445 }
446
447 foreach ($ids_to_delete as $id) {
747f4a4c 448 if (!in_array($table, $tables_to_ignore) && (empty($this->table_data[$table]) || !in_array($id, $this->table_data[$table]))) {
5d1381c2 449 $proceed_with_delete = false;
450 $a->id = $id;
451 break;
452 }
453 }
454
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 }
b9c639d6 461 }
b9c639d6 462
5d1381c2 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;
472
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 }
477
478 $proceed_with_delete = true;
479
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 }
487
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 }
495
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 }
507
508 /**
509 * Restores the global $DB object.
510 */
511 public static function restore() {
512 global $DB;
513 $DB = UnitTestDB::$real_db;
514 }
515
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 }
522}
bb48a537 523?>