2 // This file is part of Moodle - http://moodle.org/
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
22 * @copyright 2012 Petr Skoda {@link http://skodak.org}
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
28 * Advanced PHPUnit test case customised for Moodle.
32 * @copyright 2012 Petr Skoda {@link http://skodak.org}
33 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35 abstract class advanced_testcase extends base_testcase {
36 /** @var bool automatically reset everything? null means log changes */
37 private $resetAfterTest;
39 /** @var moodle_transaction */
40 private $testdbtransaction;
42 /** @var int timestamp used for current time asserts */
43 private $currenttimestart;
46 * Constructs a test case with the given name.
48 * Note: use setUp() or setUpBeforeClass() in your test cases.
52 * @param string $dataName
54 final public function __construct($name = null, array $data = array(), $dataName = '') {
55 parent::__construct($name, $data, $dataName);
57 $this->setBackupGlobals(false);
58 $this->setBackupStaticAttributes(false);
59 $this->setPreserveGlobalState(false);
63 * Runs the bare test sequence.
66 final public function runBare(): void {
69 if (phpunit_util::$lastdbwrites != $DB->perf_get_writes()) {
70 // this happens when previous test does not reset, we can not use transactions
71 $this->testdbtransaction = null;
73 } else if ($DB->get_dbfamily() === 'postgres' or $DB->get_dbfamily() === 'mssql') {
74 // database must allow rollback of DDL, so no mysql here
75 $this->testdbtransaction = $DB->start_delegated_transaction();
79 $this->setCurrentTimeStart();
81 // set DB reference in case somebody mocked it in test
82 $DB = phpunit_util::get_global_backup('DB');
84 // Deal with any debugging messages.
85 $debugerror = phpunit_util::display_debugging_messages(true);
86 $this->resetDebugging();
87 if (!empty($debugerror)) {
88 trigger_error('Unexpected debugging() call detected.'."\n".$debugerror, E_USER_NOTICE);
91 } catch (Exception $ex) {
93 } catch (Throwable $ex) {
94 // Engine errors in PHP7 throw exceptions of type Throwable (this "catch" will be ignored in PHP5).
99 // cleanup after failed expectation
100 self::resetAllData();
104 if (!$this->testdbtransaction or $this->testdbtransaction->is_disposed()) {
105 $this->testdbtransaction = null;
108 if ($this->resetAfterTest === true) {
109 if ($this->testdbtransaction) {
110 $DB->force_transaction_rollback();
111 phpunit_util::reset_all_database_sequences();
112 phpunit_util::$lastdbwrites = $DB->perf_get_writes(); // no db reset necessary
114 self::resetAllData(null);
116 } else if ($this->resetAfterTest === false) {
117 if ($this->testdbtransaction) {
118 $this->testdbtransaction->allow_commit();
120 // keep all data untouched for other tests
123 // reset but log what changed
124 if ($this->testdbtransaction) {
126 $this->testdbtransaction->allow_commit();
127 } catch (dml_transaction_exception $e) {
128 self::resetAllData();
129 throw new coding_exception('Invalid transaction state detected in test '.$this->getName());
132 self::resetAllData(true);
135 // Reset context cache.
136 context_helper::reset_caches();
138 // make sure test did not forget to close transaction
139 if ($DB->is_transaction_started()) {
140 self::resetAllData();
141 if ($this->getStatus() == PHPUnit\Runner\BaseTestRunner::STATUS_PASSED
142 or $this->getStatus() == PHPUnit\Runner\BaseTestRunner::STATUS_SKIPPED
143 or $this->getStatus() == PHPUnit\Runner\BaseTestRunner::STATUS_INCOMPLETE) {
144 throw new coding_exception('Test '.$this->getName().' did not close database transaction');
150 * Creates a new FlatXmlDataSet with the given $xmlFile. (absolute path.)
152 * @param string $xmlFile
153 * @return PHPUnit\DbUnit\DataSet\FlatXmlDataSet
155 protected function createFlatXMLDataSet($xmlFile) {
156 // TODO: MDL-67673 - removed
157 return new PHPUnit\DbUnit\DataSet\FlatXmlDataSet($xmlFile);
161 * Creates a new XMLDataSet with the given $xmlFile. (absolute path.)
163 * @param string $xmlFile
164 * @return PHPUnit\DbUnit\DataSet\XmlDataSet
166 protected function createXMLDataSet($xmlFile) {
167 // TODO: MDL-67673 - deprecate this (debugging...)
168 return $this->dataset_from_files([$xmlFile]);
172 * Creates a new CsvDataSet from the given array of csv files. (absolute paths.)
174 * @param array $files array tablename=>cvsfile
175 * @param string $delimiter unused
176 * @param string $enclosure unused
177 * @param string $escape unused
178 * @return phpunit_dataset
180 protected function createCsvDataSet($files, $delimiter = ',', $enclosure = '"', $escape = '"') {
181 // TODO: MDL-67673 - deprecate this (debugging...)
182 return $this->dataset_from_files($files);
186 * Creates new ArrayDataSet from given array
188 * @param array $data array of tables, first row in each table is columns
189 * @return phpunit_ArrayDataSet
191 protected function createArrayDataSet(array $data) {
192 // TODO: MDL-67673 - deprecate this (debugging...)
193 return $this->dataset_from_array($data);
197 * Load date into moodle database tables from standard PHPUnit data set.
199 * Note: it is usually better to use data generators
201 * @param phpunit_dataset $dataset
204 protected function loadDataSet(phpunit_dataset $dataset) {
205 // TODO: MDL-67673 - deprecate this (debugging...)
206 $dataset->to_database();
210 * Creates a new dataset from CVS/XML files.
212 * This method accepts an array of full paths to CSV or XML files to be loaded
213 * into the dataset. For CSV files, the name of the table which the file belongs
214 * to needs to be specified. Example:
217 * '/path/to/users.xml',
218 * 'course' => '/path/to/courses.csv',
221 * @param array $files full paths to CSV or XML files to load.
222 * @return phpunit_dataset
224 protected function dataset_from_files(array $files) {
225 // We ignore $delimiter, $enclosure and $escape, use the default ones in your fixtures.
226 $dataset = new phpunit_dataset();
227 $dataset->from_files($files);
232 * Creates a new dataset from string (CSV or XML).
234 * @param string $content contents (CSV or XML) to load.
235 * @param string $type format of the content to be loaded (csv or xml).
236 * @param string $table name of the table which the file belongs to (only for CSV files).
238 protected function dataset_from_string(string $content, string $type, ?string $table = null) {
239 $dataset = new phpunit_dataset();
240 $dataset->from_string($content, $type, $table);
245 * Creates a new dataset from PHP array.
247 * @param array $data array of tables, see {@see phpunit_dataset::from_array()} for supported formats.
248 * @return phpunit_dataset
250 protected function dataset_from_array(array $data) {
251 $dataset = new phpunit_dataset();
252 $dataset->from_array($data);
257 * Call this method from test if you want to make sure that
258 * the resetting of database is done the slow way without transaction
261 * This is useful especially when testing stuff that is not compatible with transactions.
265 public function preventResetByRollback() {
266 if ($this->testdbtransaction and !$this->testdbtransaction->is_disposed()) {
267 $this->testdbtransaction->allow_commit();
268 $this->testdbtransaction = null;
273 * Reset everything after current test.
274 * @param bool $reset true means reset state back, false means keep all data for the next test,
275 * null means reset state and show warnings if anything changed
278 public function resetAfterTest($reset = true) {
279 $this->resetAfterTest = $reset;
283 * Return debugging messages from the current test.
284 * @return array with instances having 'message', 'level' and 'stacktrace' property.
286 public function getDebuggingMessages() {
287 return phpunit_util::get_debugging_messages();
291 * Clear all previous debugging messages in current test
292 * and revert to default DEVELOPER_DEBUG level.
294 public function resetDebugging() {
295 phpunit_util::reset_debugging();
299 * Assert that exactly debugging was just called once.
301 * Discards the debugging message if successful.
303 * @param null|string $debugmessage null means any
304 * @param null|string $debuglevel null means any
305 * @param string $message
307 public function assertDebuggingCalled($debugmessage = null, $debuglevel = null, $message = '') {
308 $debugging = $this->getDebuggingMessages();
309 $debugdisplaymessage = "\n".phpunit_util::display_debugging_messages(true);
310 $this->resetDebugging();
312 $count = count($debugging);
315 if ($message === '') {
316 $message = 'Expectation failed, debugging() not triggered.';
318 $this->fail($message);
321 if ($message === '') {
322 $message = 'Expectation failed, debugging() triggered '.$count.' times.'.$debugdisplaymessage;
324 $this->fail($message);
326 $this->assertEquals(1, $count);
328 $message .= $debugdisplaymessage;
329 $debug = reset($debugging);
330 if ($debugmessage !== null) {
331 $this->assertSame($debugmessage, $debug->message, $message);
333 if ($debuglevel !== null) {
334 $this->assertSame($debuglevel, $debug->level, $message);
339 * Asserts how many times debugging has been called.
341 * @param int $expectedcount The expected number of times
342 * @param array $debugmessages Expected debugging messages, one for each expected message.
343 * @param array $debuglevels Expected debugging levels, one for each expected message.
344 * @param string $message
347 public function assertDebuggingCalledCount($expectedcount, $debugmessages = array(), $debuglevels = array(), $message = '') {
348 if (!is_int($expectedcount)) {
349 throw new coding_exception('assertDebuggingCalledCount $expectedcount argument should be an integer.');
352 $debugging = $this->getDebuggingMessages();
353 $message .= "\n".phpunit_util::display_debugging_messages(true);
354 $this->resetDebugging();
356 $this->assertEquals($expectedcount, count($debugging), $message);
358 if ($debugmessages) {
359 if (!is_array($debugmessages) || count($debugmessages) != $expectedcount) {
360 throw new coding_exception('assertDebuggingCalledCount $debugmessages should contain ' . $expectedcount . ' messages');
362 foreach ($debugmessages as $key => $debugmessage) {
363 $this->assertSame($debugmessage, $debugging[$key]->message, $message);
368 if (!is_array($debuglevels) || count($debuglevels) != $expectedcount) {
369 throw new coding_exception('assertDebuggingCalledCount $debuglevels should contain ' . $expectedcount . ' messages');
371 foreach ($debuglevels as $key => $debuglevel) {
372 $this->assertSame($debuglevel, $debugging[$key]->level, $message);
378 * Call when no debugging() messages expected.
379 * @param string $message
381 public function assertDebuggingNotCalled($message = '') {
382 $debugging = $this->getDebuggingMessages();
383 $count = count($debugging);
385 if ($message === '') {
386 $message = 'Expectation failed, debugging() was triggered.';
388 $message .= "\n".phpunit_util::display_debugging_messages(true);
389 $this->resetDebugging();
390 $this->assertEquals(0, $count, $message);
394 * Assert that an event legacy data is equal to the expected value.
396 * @param mixed $expected expected data.
397 * @param \core\event\base $event the event object.
398 * @param string $message
401 public function assertEventLegacyData($expected, \core\event\base $event, $message = '') {
402 $legacydata = phpunit_event_mock::testable_get_legacy_eventdata($event);
403 if ($message === '') {
404 $message = 'Event legacy data does not match expected value.';
406 $this->assertEquals($expected, $legacydata, $message);
410 * Assert that an event legacy log data is equal to the expected value.
412 * @param mixed $expected expected data.
413 * @param \core\event\base $event the event object.
414 * @param string $message
417 public function assertEventLegacyLogData($expected, \core\event\base $event, $message = '') {
418 $legacydata = phpunit_event_mock::testable_get_legacy_logdata($event);
419 if ($message === '') {
420 $message = 'Event legacy log data does not match expected value.';
422 $this->assertEquals($expected, $legacydata, $message);
426 * Assert that an event is not using event->contxet.
427 * While restoring context might not be valid and it should not be used by event url
428 * or description methods.
430 * @param \core\event\base $event the event object.
431 * @param string $message
434 public function assertEventContextNotUsed(\core\event\base $event, $message = '') {
435 // Save current event->context and set it to false.
436 $eventcontext = phpunit_event_mock::testable_get_event_context($event);
437 phpunit_event_mock::testable_set_event_context($event, false);
438 if ($message === '') {
439 $message = 'Event should not use context property of event in any method.';
442 // Test event methods should not use event->context.
444 $event->get_description();
445 $event->get_legacy_eventname();
446 phpunit_event_mock::testable_get_legacy_eventdata($event);
447 phpunit_event_mock::testable_get_legacy_logdata($event);
449 // Restore event->context.
450 phpunit_event_mock::testable_set_event_context($event, $eventcontext);
454 * Stores current time as the base for assertTimeCurrent().
456 * Note: this is called automatically before calling individual test methods.
457 * @return int current time
459 public function setCurrentTimeStart() {
460 $this->currenttimestart = time();
461 return $this->currenttimestart;
465 * Assert that: start < $time < time()
467 * @param string $message
470 public function assertTimeCurrent($time, $message = '') {
471 $msg = ($message === '') ? 'Time is lower that allowed start value' : $message;
472 $this->assertGreaterThanOrEqual($this->currenttimestart, $time, $msg);
473 $msg = ($message === '') ? 'Time is in the future' : $message;
474 $this->assertLessThanOrEqual(time(), $time, $msg);
478 * Starts message redirection.
480 * You can verify if messages were sent or not by inspecting the messages
481 * array in the returned messaging sink instance. The redirection
482 * can be stopped by calling $sink->close();
484 * @return phpunit_message_sink
486 public function redirectMessages() {
487 return phpunit_util::start_message_redirection();
491 * Starts email redirection.
493 * You can verify if email were sent or not by inspecting the email
494 * array in the returned phpmailer sink instance. The redirection
495 * can be stopped by calling $sink->close();
497 * @return phpunit_message_sink
499 public function redirectEmails() {
500 return phpunit_util::start_phpmailer_redirection();
504 * Starts event redirection.
506 * You can verify if events were triggered or not by inspecting the events
507 * array in the returned event sink instance. The redirection
508 * can be stopped by calling $sink->close();
510 * @return phpunit_event_sink
512 public function redirectEvents() {
513 return phpunit_util::start_event_redirection();
517 * Reset all database tables, restore global state and clear caches and optionally purge dataroot dir.
519 * @param bool $detectchanges
520 * true - changes in global state and database are reported as errors
521 * false - no errors reported
522 * null - only critical problems are reported as errors
525 public static function resetAllData($detectchanges = false) {
526 phpunit_util::reset_all_data($detectchanges);
530 * Set current $USER, reset access cache.
532 * @param null|int|stdClass $user user record, null or 0 means non-logged-in, positive integer means userid
535 public static function setUser($user = null) {
538 if (is_object($user)) {
539 $user = clone($user);
541 $user = new stdClass();
543 $user->mnethostid = $CFG->mnet_localhost_id;
545 $user = $DB->get_record('user', array('id'=>$user));
547 unset($user->description);
548 unset($user->access);
549 unset($user->preference);
551 // Enusre session is empty, as it may contain caches and user specific info.
552 \core\session\manager::init_empty_session();
554 \core\session\manager::set_user($user);
558 * Set current $USER to admin account, reset access cache.
562 public static function setAdminUser() {
567 * Set current $USER to guest account, reset access cache.
571 public static function setGuestUser() {
576 * Change server and default php timezones.
578 * @param string $servertimezone timezone to set in $CFG->timezone (not validated)
579 * @param string $defaultphptimezone timezone to fake default php timezone (must be valid)
581 public static function setTimezone($servertimezone = 'Australia/Perth', $defaultphptimezone = 'Australia/Perth') {
583 $CFG->timezone = $servertimezone;
584 core_date::phpunit_override_default_php_timezone($defaultphptimezone);
585 core_date::set_default_server_timezone();
591 * @return testing_data_generator
593 public static function getDataGenerator() {
594 return phpunit_util::get_data_generator();
598 * Returns UTL of the external test file.
600 * The result depends on the value of following constants:
601 * - TEST_EXTERNAL_FILES_HTTP_URL
602 * - TEST_EXTERNAL_FILES_HTTPS_URL
604 * They should point to standard external test files repository,
605 * it defaults to 'http://download.moodle.org/unittest'.
607 * False value means skip tests that require external files.
609 * @param string $path
610 * @param bool $https true if https required
613 public function getExternalTestFileUrl($path, $https = false) {
614 $path = ltrim($path, '/');
619 if (defined('TEST_EXTERNAL_FILES_HTTPS_URL')) {
620 if (!TEST_EXTERNAL_FILES_HTTPS_URL) {
621 $this->markTestSkipped('Tests using external https test files are disabled');
623 return TEST_EXTERNAL_FILES_HTTPS_URL.$path;
625 return 'https://download.moodle.org/unittest'.$path;
628 if (defined('TEST_EXTERNAL_FILES_HTTP_URL')) {
629 if (!TEST_EXTERNAL_FILES_HTTP_URL) {
630 $this->markTestSkipped('Tests using external http test files are disabled');
632 return TEST_EXTERNAL_FILES_HTTP_URL.$path;
634 return 'http://download.moodle.org/unittest'.$path;
638 * Recursively visit all the files in the source tree. Calls the callback
639 * function with the pathname of each file found.
641 * @param string $path the folder to start searching from.
642 * @param string $callback the method of this class to call with the name of each file found.
643 * @param string $fileregexp a regexp used to filter the search (optional).
644 * @param bool $exclude If true, pathnames that match the regexp will be ignored. If false,
645 * only files that match the regexp will be included. (default false).
646 * @param array $ignorefolders will not go into any of these folders (optional).
649 public function recurseFolders($path, $callback, $fileregexp = '/.*/', $exclude = false, $ignorefolders = array()) {
650 $files = scandir($path);
652 foreach ($files as $file) {
653 $filepath = $path .'/'. $file;
654 if (strpos($file, '.') === 0) {
655 /// Don't check hidden files.
657 } else if (is_dir($filepath)) {
658 if (!in_array($filepath, $ignorefolders)) {
659 $this->recurseFolders($filepath, $callback, $fileregexp, $exclude, $ignorefolders);
661 } else if ($exclude xor preg_match($fileregexp, $filepath)) {
662 $this->$callback($filepath);
668 * Wait for a second to roll over, ensures future calls to time() return a different result.
670 * This is implemented instead of sleep() as we do not need to wait a full second. In some cases
671 * due to calls we may wait more than sleep() would have, on average it will be less.
673 public function waitForSecond() {
675 while (time() == $starttime) {
681 * Run adhoc tasks, optionally matching the specified classname.
683 * @param string $matchclass The name of the class to match on.
684 * @param int $matchuserid The userid to match.
686 protected function runAdhocTasks($matchclass = '', $matchuserid = null) {
688 require_once($CFG->libdir.'/cronlib.php');
691 if (!empty($matchclass)) {
692 if (strpos($matchclass, '\\') !== 0) {
693 $matchclass = '\\' . $matchclass;
695 $params['classname'] = $matchclass;
698 if (!empty($matchuserid)) {
699 $params['userid'] = $matchuserid;
702 $lock = $this->createMock(\core\lock\lock::class);
703 $cronlock = $this->createMock(\core\lock\lock::class);
705 $tasks = $DB->get_recordset('task_adhoc', $params);
706 foreach ($tasks as $record) {
707 // Note: This is for cron only.
708 // We do not lock the tasks.
709 $task = \core\task\manager::adhoc_task_from_record($record);
712 if ($userid = $task->get_userid()) {
713 // This task has a userid specified.
714 $user = \core_user::get_user($userid);
716 // User found. Check that they are suitable.
717 \core_user::require_active_user($user, true, true);
720 $task->set_lock($lock);
721 if (!$task->is_blocking()) {
722 $cronlock->release();
724 $task->set_cron_lock($cronlock);
727 cron_prepare_core_renderer();
728 $this->setUser($user);
731 \core\task\manager::adhoc_task_complete($task);