Aller au contenu principal

Tests fonctionnels

Aller plus loin dans les tests fonctionnels : naviguer dans une page comme un vrai visiteur, vérifier une redirection précise, un message flash, une erreur de formulaire ou une API JSON

Schéma

Petite vérification​

Avant de commencer, vérifiez si vous savez déjà répondre à ces 3 questions très simples (si c'est le cas, la séance précédente est bien acquise) :

  1. citer la méthode qui soumet un formulaire dans un test, à partir du texte de son bouton
  2. citer la méthode qui authentifie directement un utilisateur dans un test, sans passer par le formulaire de connexion
  3. citer la méthode automatiquement exécutée après chaque test, utile pour supprimer les données créées
📌 Réponse attendue
astuce

Si ces 3 réponses vous semblent évidentes, vous êtes prêt à passer à la suite : cette séance ne fait qu'ajouter des vérifications plus précises aux tests que vous savez déjà écrire.

Notions théoriques​

Ce que les tests de la séance précédente ne vérifient pas encore​

Les tests d'application de la séance précédente vérifient qu'une action a un effet réel (un enregistrement en base, un accès autorisé ou refusé). Mais un vrai visiteur ne se contente pas d'envoyer une seule requête isolée : il clique sur des liens, il suit des redirections, il lit les messages d'erreur ou de confirmation qui s'affichent. Cette séance approfondit le test fonctionnel pour reproduire ce parcours complet, puis élargit la méthode à des cas que vous rencontrerez souvent : les routes qui renvoient du JSON, et les tests qui doivent partir d'un état de base connu à l'avance.

Le Crawler retourné par $client->request() représente la page HTML reçue. Sa méthode filter() sélectionne des éléments avec un sélecteur CSS, exactement comme en JavaScript ou en CSS :

$crawler = $client->request('GET', '/game/players');

// Compte le nombre de joueurs affichés sur la page
$this->assertCount(3, $crawler->filter('.player-item'));

Pour simuler un clic sur un lien, le client propose clickLink(), qui retrouve le lien par son texte visible puis simule la requête que ce clic provoquerait :

$crawler = $client->request('GET', '/game/players');

$client->clickLink('Voir ' . $player->getName());

$this->assertResponseIsSuccessful();
info

clickLink('Voir Alice') est un raccourci pour $client->click($crawler->selectLink('Voir Alice')->link()). Le texte doit correspondre exactement au texte du lien tel qu'il apparaît dans le HTML généré.

Pour que ce test fonctionne, la page /game/players doit lister les joueurs avec un lien vers chacun d'eux :

#[Route('/game/players', name: 'game_player_list')]
public function listPlayers(PlayerRepository $playerRepository): Response
{
return $this->render('game/player_list.html.twig', [
'players' => $playerRepository->findAll(),
]);
}

#[Route('/game/players/{id}', name: 'game_player_show')]
public function showPlayer(Player $player): Response
{
return $this->render('game/player_show.html.twig', ['player' => $player]);
}
{# templates/game/player_list.html.twig #}
<ul>
{% for player in players %}
<li class="player-item">
{{ player.name }}
<a href="{{ path('game_player_show', { id: player.id }) }}">Voir {{ player.name }}</a>
</li>
{% endfor %}
</ul>

Vérifier précisément vers où une redirection pointe​

assertResponseRedirects() accepte un premier argument facultatif : l'URL exacte attendue. Sans cet argument, le test vérifie seulement qu'une redirection a eu lieu, vers n'importe où.

Reprenons l'action createPlayer de la séance précédente, en la faisant rediriger vers la liste des joueurs plutôt que de laisser le choix implicite :

#[Route('/game/create', name: 'game_create_player')]
public function createPlayer(Request $request): Response
{
$player = new Player();
$form = $this->createForm(PlayerType::class, $player);

$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($player);
$this->entityManager->flush();

$this->addFlash('success', 'Le joueur ' . $player->getName() . ' a été créé.');

return $this->redirectToRoute('game_player_list');
}

return $this->render('game/create_player.html.twig', ['form' => $form->createView()]);
}
$client->submitForm('Create', ['player[name]' => 'JoueurFormulaireTest']);

$this->assertResponseRedirects('/game/players');
attention

Sans précision d'URL, assertResponseRedirects() laisserait passer une redirection vers n'importe quelle page, même une page d'erreur. Préciser l'URL attendue détecte une régression où le contrôleur redirigerait, par erreur, vers la mauvaise route.

Suivre la redirection pour vérifier la page suivante​

followRedirect() simule la requête que le navigateur enverrait automatiquement après une redirection, ce qui permet de vérifier ce qui s'affiche ensuite :

$client->submitForm('Create', ['player[name]' => 'JoueurFormulaireTest']);

$this->assertResponseRedirects('/game/players');

$client->followRedirect();

$this->assertSelectorTextContains('.flash-success', 'JoueurFormulaireTest a été créé');

Pour que ce test passe, le template de la liste des joueurs doit afficher le message flash ajouté par addFlash('success', ...) :

{% for message in app.flashes('success') %}
<div class="flash-success">{{ message }}</div>
{% endfor %}
info

Un message flash n'est disponible qu'une seule fois, sur la toute première page affichée après avoir été ajouté. C'est pour cela qu'il faut appeler followRedirect() avant de le chercher : il n'existe pas encore sur la réponse de redirection elle-même (code 302, sans contenu HTML).

Tester l'affichage d'une erreur de validation​

La séance sur la validation des données a ajouté #[Assert\NotBlank] et #[Assert\Length(max: 100)] sur la propriété name de Player. Soumettre un nom invalide ne redirige jamais : Symfony réaffiche la même page, avec le code 200 et les erreurs visibles dans le HTML.

public function testCreatePlayerWithEmptyNameShowsError(): void
{
$client = static::createClient();
$client->request('GET', '/game/create');

$client->submitForm('Create', ['player[name]' => '']);

$this->assertResponseIsSuccessful();
$this->assertSelectorExists('.form-error-message');
}
info

Le thème de formulaire par défaut de Symfony place chaque erreur de validation dans un <ul> de classe form-error-message. C'est ce sélecteur CSS qu'un test peut vérifier, sans dépendre du texte exact du message (qui peut changer selon la langue configurée).

Rejouer le même test avec plusieurs jeux de données​

Deux règles de validation existent sur name (NotBlank et Length(max: 100)) : il faudrait un test pour chacune. Plutôt que de dupliquer le test, un data provider PHPUnit fournit plusieurs jeux de données à une seule méthode de test :

use PHPUnit\Framework\Attributes\DataProvider;

class GameControllerTest extends WebTestCase
{
#[DataProvider('provideInvalidPlayerNames')]
public function testCreatePlayerWithInvalidNameShowsError(string $invalidName): void
{
$client = static::createClient();
$client->request('GET', '/game/create');

$client->submitForm('Create', ['player[name]' => $invalidName]);

$this->assertResponseIsSuccessful();
$this->assertSelectorExists('.form-error-message');
}

public static function provideInvalidPlayerNames(): array
{
return [
'nom vide' => [''],
'nom trop long' => [str_repeat('a', 101)],
];
}
}
info

La méthode fournissant les données (provideInvalidPlayerNames) doit être static. PHPUnit l'exécute avant de créer la classe de test, une fois par jeu de données déclaré, puis appelle testCreatePlayerWithInvalidNameShowsError() une fois pour chaque entrée du tableau retourné.

Tester une route qui renvoie du JSON​

Une application Symfony n'affiche pas toujours du HTML : une route peut exposer une petite API. Le test reste un WebTestCase, mais il décode la réponse au lieu de chercher des sélecteurs CSS :

#[Route('/api/players', name: 'api_players', methods: ['GET'])]
public function apiPlayers(PlayerRepository $playerRepository): JsonResponse
{
$players = $playerRepository->findAll();
$data = array_map(
fn (Player $player) => ['id' => $player->getId(), 'name' => $player->getName()],
$players
);

return $this->json($data);
}
public function testApiPlayersReturnsJsonArray(): void
{
$client = static::createClient();
$client->request('GET', '/api/players');

$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/json');

$data = json_decode($client->getResponse()->getContent(), true);

$this->assertIsArray($data);
}
info

$this->json($data) (hérité du contrôleur Symfony) encode automatiquement le tableau PHP en JSON et définit l'en-tête Content-Type: application/json. Un test de ce type ne cherche jamais de sélecteur CSS : il décode le contenu brut avec json_decode(), puis vérifie sa structure avec les assertions PHPUnit habituelles (assertIsArray, assertCount, assertSame...).

Partir d'un état de base connu grâce aux fixtures​

La séance sur les fixtures a présenté doctrine:fixtures:load pour peupler la base avec des données de démonstration. Pour des tests fonctionnels, cette même commande permet de partir d'un état connu et reproductible, plutôt que de créer chaque donnée à la main dans chaque test :

php bin/console doctrine:fixtures:load --env=test --no-interaction
public function testPlayerListShowsAllFixtures(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/game/players');

$this->assertResponseIsSuccessful();
$this->assertCount(5, $crawler->filter('.player-item'));
}
attention

doctrine:fixtures:load purge toute la base avant de recharger les fixtures. C'est pour cela qu'on la lance une seule fois avant la suite de tests (manuellement, ou dans un script de CI), jamais à l'intérieur de chaque méthode de test : la relancer à chaque test serait très lent, et effacerait aussi les données que d'autres tests auraient pu créer entre-temps.

Test de mémorisation/compréhension​


Que fait la méthode clickLink('Voir ce joueur') sur un client de test ?


À quoi sert la méthode filter() d'un Crawler, utilisée avec un sélecteur CSS ?


Quel est l'intérêt de préciser une URL dans assertResponseRedirects('/game/players') plutôt que d'appeler assertResponseRedirects() sans argument ?


Que fait followRedirect() sur un client de test ?


Pourquoi un test qui soumet un formulaire avec un nom vide doit-il vérifier assertResponseIsSuccessful() plutôt que assertResponseRedirects() ?


Dans le thème de formulaire par défaut de Symfony, quelle classe CSS permet de repérer que des erreurs de validation sont affichées ?


Dans un test, à quoi sert un message flash ajouté avec $this->addFlash('success', ...) côté contrôleur ?


Que permet un data provider PHPUnit (attribut #[DataProvider('...')]) sur une méthode de test ?


Pour tester une route qui renvoie du JSON, que faut-il faire du contenu de $client->getResponse()->getContent() ?


Pourquoi charger les fixtures avec --env=test avant de lancer la suite de tests, plutôt que dans chaque méthode de test ?


TP pour réfléchir et résoudre des problèmes​

Votre défi pour aujourd'hui consiste à compléter les tests fonctionnels de votre jeu

Étape 1 — Vérifier la redirection précise après la création d'un joueur​

Étape 2 — Suivre la redirection et vérifier le message flash​


Bonne pratique - Tester le message, pas seulement la redirection

Une redirection réussie ne prouve pas que l'utilisateur comprendra ce qui vient de se passer. Vérifier le message flash affiché garantit que le retour visuel promis au vrai visiteur existe réellement, pas seulement que le serveur a changé de page.

Étape 3 — Couvrir les deux règles de validation avec un data provider​


Bonne pratique - Nommer explicitement chaque jeu de données

Les clés 'nom vide' et 'nom trop long' du tableau ne sont pas obligatoires, mais elles apparaissent dans le nom du test lors de son exécution. En cas d'échec, vous identifiez immédiatement quel cas a échoué, sans relire le contenu du data provider.

Étape 4 — Tester l'API JSON des joueurs​


Bonne pratique - Ne jamais parser du HTML avec des assertions JSON, ni l'inverse

Une route HTML se vérifie avec des sélecteurs CSS (assertSelectorTextContains, assertSelectorExists). Une route JSON se vérifie en décodant son contenu (json_decode) puis en comparant des valeurs PHP. Mélanger les deux approches sur la même route est le signe qu'elle devrait sans doute être scindée en deux routes distinctes.

📌 Une solution