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.
How 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()`.
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 }}
npx claudepluginhub saadrahman01/claude-moodle-dev --plugin moodle-devWrites PHPUnit tests for PHP code: unit tests, mocking, data providers, test doubles, assertions, and TDD practices. Use for testing PHP apps including Magento.
Writes and runs Behat acceptance tests for Moodle plugins: feature files, custom step definitions, data generators, JS scenarios, and Selenium setup.
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.