agentsclimarketplace

Moodle phpunit testing

Skill SaadRahman01/claude-moodle-dev/skills/moodle-phpunit-testing

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.From its SKILL.md

Install
npx -y skills add SaadRahman01/claude-moodle-dev --skill moodle-phpunit-testing

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

SKILL.md

6.7 KB, as published. Nobody here has run it

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       # 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.

Test class skeleton

<?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:

  • 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();
// assert side effects

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);             // sets $USER global
$this->setAdminUser();             // shortcut
$this->setGuestUser();

Time-travel

$this->mock_clock_with_frozen(1700000000);    // Moodle 4.4+
// or in older versions, manually set timecreated/timemodified

Running tests

# 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

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]);
// inject via DI, never replace global

Avoid replacing the global $DB — breaks isolation.

Common Mistakes

MistakeFix
Forgetting $this->resetAfterTest()Add at start of every DB-touching test
Class not finalAdd final (Moodle 4.2+ policy)
Missing @coversAdd @covers \Fully\Qualified\Class
Hand-editing phpunit.xmlRe-run admin/tool/phpunit/cli/init.php
Using parent::setUp() to reset DBUse resetAfterTest() instead
Skipping clean_returnvalue on external fnAlways 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

Keep looking

Skills are one crate of 628,971. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.