| name | moodle-phpunit-testing |
| description | Use when writing, running, or debugging PHPUnit tests for Moodle plugins or core. Covers advanced_testcase, resetAfterTest, data generators, mocking $DB, testing events/tasks/external functions, and CLI invocation. |
Moodle PHPUnit Testing
Overview
Moodle ships its own PHPUnit harness with test bootstrap, transactional resets, and data generators. Tests live in <plugin>/tests/<thing>_test.php and extend advanced_testcase. Never call parent::setUp() for DB cleanup — use $this->resetAfterTest().
When to Use
- Writing unit/integration tests for any Moodle plugin or core API
- Debugging test failures (
Database was modified errors, isolation issues)
- Adding a data generator (
tests/generator/lib.php)
- Testing events, scheduled tasks, ad-hoc tasks, external functions
- Setting up CI for Moodle test suites
Skip when: writing Behat acceptance tests (use moodle-behat-testing).
First-time setup
php admin/tool/phpunit/cli/init.php
vendor/bin/phpunit --testsuite local_example_testsuite
phpunit.xml is regenerated by init.php — never hand-edit. Re-run after installing a new plugin.
Test class skeleton
<?php
namespace local_example;
defined('MOODLE_INTERNAL') || die();
final class manager_test extends \advanced_testcase {
public function test_create_item(): void {
$this->resetAfterTest();
$generator = self::getDataGenerator();
$course = $generator->create_course();
$user = $generator->create_user();
$manager = new manager();
$id = $manager->create_item($course->id, $user->id, 'hello');
global $DB;
$row = $DB->get_record('local_example_items', ['id' => $id], '*', MUST_EXIST);
$this->assertSame('hello', $row->name);
}
}
Key rules:
- File name:
<thing>_test.php, class: <thing>_test
final class (Moodle policy since 4.2)
@covers annotation required by Moodle CS
@group <component> enables --group filtering
void return type on test methods, : void on setUp
self:: (not $this->) for static methods like getDataGenerator()
Data generators
Plugin generator at tests/generator/lib.php:
<?php
defined('MOODLE_INTERNAL') || die();
class local_example_generator extends component_generator_base {
public function create_item(array $record = []): \stdClass {
global $DB, $USER;
$defaults = [
'courseid' => 0,
'userid' => $USER->id,
'name' => 'Item ' . random_string(8),
'timecreated'=> time(),
];
$record = (object)array_merge($defaults, $record);
$record->id = $DB->insert_record('local_example_items', $record);
return $record;
}
}
Use:
$gen = self::getDataGenerator()->get_plugin_generator('local_example');
$item = $gen->create_item(['name' => 'test']);
Activity module generator extends testing_module_generator and implements create_instance().
Common patterns
Test an event
$sink = $this->redirectEvents();
$manager->do_thing();
$events = $sink->get_events();
$sink->close();
$this->assertCount(1, $events);
$this->assertInstanceOf(\local_example\event\thing_done::class, $events[0]);
Test an email
$sink = $this->redirectEmails();
$manager->notify($user);
$messages = $sink->get_messages();
$this->assertSame($user->email, $messages[0]->to);
Test a scheduled task
$task = new \local_example\task\cleanup();
$task->execute();
Test an external (web service) function
$this->setUser($user);
$result = \local_example\external\get_items::execute($courseid);
$result = \core_external\external_api::clean_returnvalue(
\local_example\external\get_items::execute_returns(),
$result
);
$this->assertCount(2, $result);
clean_returnvalue is mandatory — catches schema mismatches.
Test an ad-hoc task
\core\task\manager::queue_adhoc_task(new \local_example\task\send_report());
$this->runAdhocTasks(\local_example\task\send_report::class);
Login as a user
$user = $this->getDataGenerator()->create_user();
$this->setUser($user);
$this->setAdminUser();
$this->setGuestUser();
Time-travel
$this->mock_clock_with_frozen(1700000000);
Running tests
vendor/bin/phpunit --testsuite local_example_testsuite
vendor/bin/phpunit local/example/tests/manager_test.php
vendor/bin/phpunit --filter test_create_item local/example/tests/manager_test.php
vendor/bin/phpunit --group local_example
vendor/bin/phpunit --coverage-html coverage/ local/example/tests
Test database
- Separate DB defined in
config.php: $CFG->phpunit_prefix = 'phpu_';
- Reset between tests via transactions —
$this->resetAfterTest() enables it
- Schema drift error: re-run
php admin/tool/phpunit/cli/init.php
- "Database was modified" failure means a test mutated DB without
resetAfterTest()
Mocking
Moodle prefers integration tests with the real test DB over mocking $DB. When you must mock:
$mockDB = $this->createMock(\moodle_database::class);
$mockDB->method('get_record')->willReturn((object)['id' => 1]);
Avoid replacing the global $DB — breaks isolation.
Common Mistakes
| Mistake | Fix |
|---|
Forgetting $this->resetAfterTest() | Add at start of every DB-touching test |
Class not final | Add final (Moodle 4.2+ policy) |
Missing @covers | Add @covers \Fully\Qualified\Class |
Hand-editing phpunit.xml | Re-run admin/tool/phpunit/cli/init.php |
Using parent::setUp() to reset DB | Use resetAfterTest() instead |
Skipping clean_returnvalue on external fn | Always wrap external returns to catch schema bugs |
$this->getDataGenerator() (instance) | Moodle prefers self::getDataGenerator() (static) |
Asserting time with time() | Use mock_clock_with_frozen or compare with tolerance |
CI snippet (GitHub Actions)
- name: PHPUnit
run: |
php admin/tool/phpunit/cli/init.php
vendor/bin/phpunit --testsuite ${{ matrix.suite }}
References