Chapitre 11.3 — Tests unitaires avec PHPUnit
Partie 11 : Niveau expert — Chapitre 3/8 Public : dev ayant du code sûr (11.1) et performant (11.2) ; il lui faut un filet de sécurité automatisé. Version de référence : Moodle 5.2 (PHPUnit,
advanced_testcase, data generators).
⏱️ TL;DR
- Moodle a un harnais PHPUnit dédié : base de test séparée, initialisation via
admin/tool/phpunit/cli/init.php, config dansphpunit.xml.- On étend
advanced_testcaseet on appelleresetAfterTest(): la base est remise à zéro après chaque test → tests isolés.- Le data generator crée les fixtures :
create_user,create_course,create_module, inscriptions, groupes… via$this->getDataGenerator().setUser()simule un utilisateur connecté (pour tester les permissions).- Tester le code qui émet des events / tâches / messages via les sinks (
redirectEvents(),redirectMessages()).- dataProviders pour tester plusieurs cas ; couverture des external functions (Partie 9) et des privacy providers (11.5).
- Les tests vivent dans
tests/du plugin (*_test.php, namespacecomponent), lancés ciblés ou en masse, dans moodle-docker.
🎯 Objectifs
- Initialiser et lancer le harnais PHPUnit de Moodle.
- Écrire un test isolé avec
advanced_testcase+resetAfterTest(). - Créer des fixtures avec le data generator et simuler un utilisateur.
- Tester events, tâches et messages via les sinks.
- Couvrir une external function et un privacy provider.
1. Pourquoi tester, et le harnais spécifique
Rappel de la Partie 6 (cycle de vie) : Moodle sort deux versions par an, chacune pouvant casser votre plugin. Sans tests, chaque upgrade est une loterie que vous perdrez en production. PHPUnit est le filet qui transforme une régression en test rouge avant le déploiement.
Moodle ne se teste pas comme un projet PHP quelconque : il a un harnais qui prépare une base de données de test isolée (préfixe distinct, jamais votre base réelle), charge le framework, et fournit des utilitaires (data generator, sinks). On ne lance jamais les tests sur la base de production.
⚠️ Piège : PHPUnit utilise une base de test séparée déclarée dans
config.php($CFG->phpunit_prefix,$CFG->phpunit_dataroot). Ne jamais pointer sur la base réelle : le harnais la vide entre les runs. La configuration de test est distincte de la config de prod/dev.
2. Initialisation
# 1. Config : ajouter à config.php les paramètres phpunit
# $CFG->phpunit_prefix = 'phpu_';
# $CFG->phpunit_dataroot = '/var/moodledata_phpunit';
# 2. Installer Composer (dépendances de dev, dont PHPUnit)
composer install
# 3. Initialiser la base de test
php admin/tool/phpunit/cli/init.php
# 4. Lancer TOUS les tests (long)
vendor/bin/phpunit
# 5. Lancer les tests d'un plugin
vendor/bin/phpunit --testsuite local_todolist_testsuite
# ou par chemin :
vendor/bin/phpunit local/todolist/tests/manager_test.phpEn moodle-docker (Partie 4), tout ceci se fait dans le conteneur (bin/moodle-docker-compose exec webserver ...), avec la base de test provisionnée. init.php est à rejouer quand un plugin change son schéma (nouvelles tables à créer dans la base de test).
⚠️ Piège : après avoir ajouté/modifié le schéma d’un plugin (
install.xml), relancezinit.php— sinon la base de test n’a pas vos nouvelles tables et les tests échouent sur des erreurs SQL obscures.
3. Le squelette d’un test
Les tests vivent dans tests/ du plugin, fichiers *_test.php, namespace du component :
<?php
// public/local/todolist/tests/manager_test.php
namespace local_todolist;
defined('MOODLE_INTERNAL') || die();
/**
* Tests du manager de local_todolist.
*
* @covers \local_todolist\manager
*/
final class manager_test extends \advanced_testcase {
public function test_create_and_get(): void {
$this->resetAfterTest(); // remet la base à zéro après CE test
// Fixture : un utilisateur.
$user = $this->getDataGenerator()->create_user();
$this->setUser($user); // simule cet utilisateur connecté
// Action.
$id = manager::create($user->id, 'Lire le chapitre 11');
// Assertions.
$this->assertIsInt($id);
$tasks = manager::get_user_tasks($user->id);
$this->assertCount(1, $tasks);
$task = reset($tasks);
$this->assertEquals('Lire le chapitre 11', $task->name);
$this->assertEquals(0, $task->completed);
}
public function test_toggle(): void {
$this->resetAfterTest();
$user = $this->getDataGenerator()->create_user();
$id = manager::create($user->id, 'Tâche');
manager::toggle($id, $user->id);
$task = manager::get_own_task($id, $user->id);
$this->assertEquals(1, $task->completed);
}
public function test_cannot_access_others_task(): void {
$this->resetAfterTest();
$alice = $this->getDataGenerator()->create_user();
$bob = $this->getDataGenerator()->create_user();
$id = manager::create($alice->id, 'Tâche privée d\'Alice');
// Bob ne doit PAS pouvoir charger la tâche d'Alice (ch. 6.3, IDOR).
$this->expectException(\dml_missing_record_exception::class);
manager::get_own_task($id, $bob->id);
}
}Les fondamentaux :
extends \advanced_testcase: la classe de base Moodle (utilitaires + intégration au harnais).resetAfterTest(): appelé au début de chaque test qui touche la base → Moodle restaure l’état initial après. Sans lui, les tests fuient les uns sur les autres.@covers: documente ce que le test couvre (bonne pratique + rapport de couverture).final classet méthodestest_*: conventions Moodle/PHPUnit.
⚠️ Piège : oublier
resetAfterTest()dans un test qui écrit en base → l’état persiste et pollue les tests suivants (ordre-dépendants, faux positifs/négatifs). En 5.2, PHPUnit avertit/échoue si un test modifie la base sansresetAfterTest()— respectez l’avertissement.
💡 Pour un dev React :
advanced_testcase+resetAfterTest()≈ Jest avec une base réinitialisée entre chaque test (comme unbeforeEachqui truncate). Le data generator est votre factory (à lafaker/factory-bot) : il crée des entités valides (utilisateurs, cours, modules) sans que vous écriviez les insertions à la main.
4. Le data generator : créer des fixtures
$this->getDataGenerator() fabrique des entités Moodle valides et cohérentes :
$gen = $this->getDataGenerator();
$user = $gen->create_user(['email' => 'a@example.com']);
$course = $gen->create_course();
$cm = $gen->create_module('simplecheck', ['course' => $course->id, 'name' => 'Checklist']);
// Inscrire un utilisateur dans un cours avec un rôle.
$gen->enrol_user($user->id, $course->id, 'student');
// Groupes.
$group = $gen->create_group(['courseid' => $course->id]);
$gen->create_group_member(['groupid' => $group->id, 'userid' => $user->id]);Certains plugins fournissent un générateur spécifique (mod_simplecheck_generator dans tests/generator/) pour créer leurs entités (items, coches). C’est la bonne pratique pour un plugin non trivial : exposer un générateur que vos tests (et d’autres) réutilisent.
5. Simuler un utilisateur et tester les permissions
setUser() définit l’utilisateur « connecté » du test — indispensable pour tester require_capability, l’appartenance, les external functions :
public function test_capability_required(): void {
$this->resetAfterTest();
$user = $this->getDataGenerator()->create_user();
$this->setUser($user);
// Par défaut, un utilisateur authentifié a local/todolist:manageentries (archetype user).
$this->assertTrue(has_capability('local/todolist:manageentries', \context_system::instance()));
// Retirer la capability et vérifier le refus.
$roleid = create_role('norights', 'norights', '');
// … assignation et override CAP_PROHIBIT …
}setUser(null) simule un non-connecté (utile pour tester require_login). On peut aussi setAdminUser() pour les tests nécessitant les pleins droits.
6. Tester events, tâches et messages (les sinks)
Du code qui émet un event, envoie un message ou planifie une tâche ne doit pas réellement les propager pendant un test. Moodle fournit des sinks qui capturent ces émissions :
public function test_view_triggers_event(): void {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
$cm = $this->getDataGenerator()->create_module('simplecheck', ['course' => $course->id]);
$this->setAdminUser();
// Capturer les events.
$sink = $this->redirectEvents();
// Action qui déclenche l'event (ch. 6.5).
$context = \context_module::instance($cm->cmid);
$event = \mod_simplecheck\event\course_module_viewed::create([
'objectid' => $cm->instance, 'context' => $context,
]);
$event->trigger();
// Vérifier.
$events = $sink->get_events();
$this->assertCount(1, $events);
$this->assertInstanceOf(\mod_simplecheck\event\course_module_viewed::class, $events[0]);
$sink->close();
}redirectEvents()capture les events (au lieu de les propager aux observers).redirectMessages()capture les messages (au lieu de les envoyer).- Pour les tâches : on peut exécuter une
scheduled_task/adhoc_taskdirectement ($task->execute()) et vérifier ses effets, ou capturer les tâches ad hoc mises en file.
💡 Pour un dev React : les sinks sont des spies/mocks intégrés au framework : au lieu de partir vraiment (e-mail, event bus), l’émission est interceptée et inspectable.
$sink->get_events()est votreexpect(spy).toHaveBeenCalledWith(...).
7. dataProviders, external functions, privacy
dataProviders — tester plusieurs cas sans dupliquer :
/**
* @dataProvider grade_provider
*/
public function test_grade_calculation(int $done, int $total, float $max, float $expected): void {
$this->assertEqualsWithDelta($expected, ($done / $total) * $max, 0.001);
}
public static function grade_provider(): array {
return [
'half' => [1, 2, 100.0, 50.0],
'full' => [4, 4, 100.0, 100.0],
'zero' => [0, 5, 100.0, 0.0],
];
}External functions (Partie 9) — on les teste comme du code normal, en appelant execute() après setUser() :
public function test_ws_add_task(): void {
$this->resetAfterTest();
$user = $this->getDataGenerator()->create_user();
$this->setUser($user);
$result = \local_todolist\external\add_task::execute('Nouvelle tâche');
$this->assertEquals('Nouvelle tâche', $result['name']);
// Valider aussi la structure de retour :
$returned = \core_external\external_api::clean_returnvalue(
\local_todolist\external\add_task::execute_returns(), $result
);
$this->assertEquals($result, $returned);
}clean_returnvalue() valide que le retour respecte le schéma execute_returns — un test précieux qui attrape les dérives de structure.
Privacy providers (11.5) — la base provider_testcase teste export/delete. On y reviendra au chapitre 5.
8. Lancer, cibler, déboguer
# Un fichier
vendor/bin/phpunit local/todolist/tests/manager_test.php
# Une méthode
vendor/bin/phpunit --filter test_toggle local/todolist/tests/manager_test.php
# Avec couverture (nécessite Xdebug/pcov)
vendor/bin/phpunit --coverage-html /tmp/coverage local/todolist/tests/En dev, lancez ciblé (un fichier/une méthode) pour un feedback rapide ; la suite complète tourne en CI (chapitre 4). Pour déboguer, debugging() et var_dump apparaissent dans la sortie du test ; Xdebug se branche comme d’habitude.
9. Synthèse
PHPUnit dans Moodle : une base de test isolée, des classes advanced_testcase avec resetAfterTest() (isolation), un data generator pour les fixtures, setUser() pour les permissions, et des sinks pour events/messages. On teste le métier (le manager), les external functions (avec clean_returnvalue), et bientôt les privacy providers. C’est le filet qui rend durables la sécurité (11.1) et la performance (11.2) : sans lui, chaque upgrade de Moodle est un pari. Le chapitre suivant automatise ce filet en CI et ajoute les tests d’acceptation Behat.
✏️ Exercices
Exercice 1 — Tests qui se polluent. Deux tests passent seuls mais échouent quand on lance la suite. Le premier crée des tâches. Cause probable et correctif ?
✅ Solution
Le premier test n’appelle pas resetAfterTest() : ses écritures en base persistent et polluent le second (données inattendues, comptes faussés). Correctif : appeler $this->resetAfterTest() au début de chaque test qui touche la base, pour que Moodle restaure l’état initial après. En 5.2, PHPUnit avertit/échoue si un test modifie la base sans ce reset — c’est le signal à respecter.
Exercice 2 — Fixtures.
Écrivez le début d’un test qui crée un cours, une activité simplecheck et un étudiant inscrit. Quelles méthodes du data generator ?
✅ Solution
$this->resetAfterTest();
$gen = $this->getDataGenerator();
$course = $gen->create_course();
$cm = $gen->create_module('simplecheck', ['course' => $course->id]);
$student = $gen->create_user();
$gen->enrol_user($student->id, $course->id, 'student');create_course, create_module('simplecheck', [...]), create_user, enrol_user(..., 'student'). Le data generator crée des entités valides et cohérentes sans insertions manuelles.
Exercice 3 — Tester une permission.
Comment vérifier qu’un utilisateur non connecté ne peut pas exécuter une external function protégée par require_login ?
✅ Solution
Simuler l’absence d’utilisateur avec $this->setUser(null) (ou ne pas appeler setUser sur un contexte exigeant login) puis appeler execute() en attendant l’exception : $this->expectException(\require_login_exception::class); (ou l’exception appropriée) avant l’appel. setUser() contrôle « qui est connecté » pendant le test ; setUser(null) = non authentifié, setAdminUser() = admin.
Exercice 4 — Sink d’event.
Vous voulez vérifier qu’ouvrir une activité déclenche course_module_viewed. Quel mécanisme et quelles étapes ?
✅ Solution
Utiliser $sink = $this->redirectEvents(); pour capturer les events, déclencher l’action (créer et ->trigger() l’event, ou appeler le code qui l’émet), puis inspecter $sink->get_events() : vérifier le nombre et le type (assertInstanceOf(...course_module_viewed::class, ...)), et fermer avec $sink->close(). Le sink intercepte l’émission au lieu de la propager aux observers — un spy intégré.
Exercice 5 — Valider le retour d’une external function.
Pourquoi appeler clean_returnvalue(execute_returns(), $result) dans un test, au-delà d’asserter les valeurs ?
✅ Solution
clean_returnvalue valide que le résultat respecte le schéma déclaré par execute_returns() (types, clés, structures). Cela attrape une dérive de structure (champ manquant, mauvais type) qui casserait les consommateurs REST/JS avant la production — une classe de bugs qu’un simple assertEquals sur les valeurs ne détecte pas. C’est le test qui garantit que le contrat de l’API (Partie 9) reste respecté.
🧠 Quiz de révision
Q1. Pourquoi Moodle a-t-il un harnais PHPUnit dédié, et quelle précaution centrale ?
Réponse
admin/tool/phpunit/cli/init.php. Précaution : ne jamais pointer sur la base réelle (le harnais la vide entre runs). Relancer init.php quand un schéma de plugin change.Q2. À quoi sert resetAfterTest() et que se passe-t-il sans ?
Réponse
Q3. Qu’est-ce que le data generator et setUser() ?
Réponse
$this->getDataGenerator()) crée des fixtures valides (users, cours, modules, inscriptions, groupes) — une factory. setUser() définit l’utilisateur « connecté » du test (pour tester capabilities/appartenance) ; setUser(null) = non connecté, setAdminUser() = admin.Q4. Comment teste-t-on du code qui émet events ou messages ?
Réponse
redirectEvents() / redirectMessages() capturent les émissions au lieu de les propager ; on inspecte ensuite get_events()/get_messages(). Ce sont des spies intégrés au framework.Q5. Pourquoi valider le retour d’une external function avec clean_returnvalue ?
Réponse
execute_returns (types/clés/structure), attrapant les dérives de contrat d’API avant la prod — ce qu’un simple assert de valeurs ne détecte pas.Chapitre suivant : 04 — Behat et CI/CD — les tests d’acceptation Behat (Gherkin, steps core et custom, @javascript, Selenium dans moodle-docker), puis la CI complète avec moodle-plugin-ci sur GitHub Actions (matrice PHP × Moodle × base de données, phpcs, mustache lint, phpunit, behat) et les stratégies de déploiement.