Help us improve
Share bugs, ideas, or general feedback.
From moodle-dev
Write, run, and debug PHPUnit tests for Moodle plugins or core. Covers advanced_testcase, resetAfterTest, data generators, mocking $DB, and testing events/tasks/external functions.
npx claudepluginhub saadrahman01/claude-moodle-dev --plugin moodle-devHow this skill is triggered — by the user, by Claude, or both
Slash command
/moodle-dev:moodle-phpunit-testingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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()`.
Writes PHPUnit tests for PHP code: unit tests, mocking, data providers, test doubles, assertions, and TDD practices. Use for testing PHP apps including Magento.
Guides Moodle plugin development: version.php, DB install/upgrade, capabilities, web services, PSR-4 autoloading, hooks, settings, privacy provider, and coding standards.
PHPUnit testing framework conventions and practices. Invoke whenever task involves any interaction with PHPUnit — writing tests, configuring PHPUnit, data providers, mocking, assertions, debugging test failures, or coverage.
Share bugs, ideas, or general feedback.
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().
Database was modified errors, isolation issues)tests/generator/lib.php)Skip when: writing Behat acceptance tests (use moodle-behat-testing).
php admin/tool/phpunit/cli/init.php # writes phpunit.xml + initializes test DB
vendor/bin/phpunit --testsuite local_example_testsuite
phpunit.xml is regenerated by init.php — never hand-edit. Re-run after installing a new plugin.
<?php
namespace local_example;
defined('MOODLE_INTERNAL') || die();
/**
* @group local_example
* @covers \local_example\manager
*/
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:
<thing>_test.php, class: <thing>_testfinal class (Moodle policy since 4.2)@covers annotation required by Moodle CS@group <component> enables --group filteringvoid return type on test methods, : void on setUpself:: (not $this->) for static methods like getDataGenerator()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().
$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]);
$sink = $this->redirectEmails();
$manager->notify($user);
$messages = $sink->get_messages();
$this->assertSame($user->email, $messages[0]->to);
$task = new \local_example\task\cleanup();
$task->execute();
// assert side effects
$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.
\core\task\manager::queue_adhoc_task(new \local_example\task\send_report());
$this->runAdhocTasks(\local_example\task\send_report::class);
$user = $this->getDataGenerator()->create_user();
$this->setUser($user); // sets $USER global
$this->setAdminUser(); // shortcut
$this->setGuestUser();
$this->mock_clock_with_frozen(1700000000); // Moodle 4.4+
// or in older versions, manually set timecreated/timemodified
# Single suite
vendor/bin/phpunit --testsuite local_example_testsuite
# Single file
vendor/bin/phpunit local/example/tests/manager_test.php
# Single method
vendor/bin/phpunit --filter test_create_item local/example/tests/manager_test.php
# By group
vendor/bin/phpunit --group local_example
# Coverage (requires xdebug or pcov)
vendor/bin/phpunit --coverage-html coverage/ local/example/tests
config.php: $CFG->phpunit_prefix = 'phpu_';$this->resetAfterTest() enables itphp admin/tool/phpunit/cli/init.phpresetAfterTest()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]);
// inject via DI, never replace global
Avoid replacing the global $DB — breaks isolation.
| 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 |
- name: PHPUnit
run: |
php admin/tool/phpunit/cli/init.php
vendor/bin/phpunit --testsuite ${{ matrix.suite }}