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

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) :
- citer la méthode qui soumet un formulaire dans un test, à partir du texte de son bouton
- citer la méthode qui authentifie directement un utilisateur dans un test, sans passer par le formulaire de connexion
- citer la méthode automatiquement exécutée après chaque test, utile pour supprimer les données créées
📌 Réponse attendue
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.
Naviguer dans une page comme un vrai visiteur
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();
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');
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 %}
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');
}
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)],
];
}
}
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);
}
$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'));
}
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
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
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
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.