Tuto Symfony, 15 minutes pour mettre en place des tests automatiques qui vont vérifier toutes les routes de votre projet

Frederic Leaux
6 min readMay 12, 2023

--

Tester c’est douter, oui mais ne pas douter c’est de l’excès de confiance et une économie très dangereuse que vous allez payer tôt ou tard. On connait tous l’histoire et c’est rarement une priorité pour les patrons d’entreprise et ça va dans la case “on verra plus tard”.

Et plus le projet grossi plus cela semble compliqué de mettre en place des tests.

Je vous propose un tuto qui devrait vous permettre d’avoir un premier niveau de tests en validant que l’ensemble des routes de votre projet répondent correctement (et c’est déjà un très bon début).

Cela fonctionne pour des routes classiques mais aussi pour du Rest ou GraphQL.

Pour commencer, voici un repo Git avec un projet Symfony fonctionnel qui accompagne le tuto :

Les dépendances

Pour faire tourner les tests vous aurez besoin d’ajouter phpunit à votre projet :

composer require phpunit/phpunit --dev

Le code

On a trois fichiers à créer et j’explique après comme tout cela fontionne.

Dans config/packages/test/ on va créer un yaml routes_params.yaml qui va contenir les informations sur les routes à tester :

parameters:
role: 'ROLE_CLIENT'
route_params:
app_content:
params: {id: 1}
admin:
response-status-code: 307
logout:
response-status-code: 302
api_get:
params: {id: 1}

Dans le répertoire tests qui a été créé à l’installation de phpunit on va créer un trait LoginTrait.php qui servira à faker un utilisateur si nécessaire :

<?php

namespace App\Tests;

use Exception;
use Lexik\Bundle\JWTAuthenticationBundle\Encoder\JWTEncoderInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTEncodeFailureException;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;

trait LoginTrait
{
/** @var KernelBrowser */
private KernelBrowser $client;

private JWTEncoderInterface $jwtEncoder;

protected function setUp(): void
{
$this->client = static::createClient();
$encoder = $this->client->getContainer()->get('lexik_jwt_authentication.encoder');
if (!($encoder instanceof JWTEncoderInterface)) {
throw new Exception('bad encoder');
}
$this->jwtEncoder = $encoder;
}

protected function setClient(KernelBrowser $client): void
{
$this->client = $client;
}

/**
* Création d'un utilisateur fictif et retourne son token.
*
* @param string $role role de l'utilisateur ('ROLE_ADMIN', 'ROLE_USER')
* @throws JWTEncodeFailureException
*/
private function createUser(string $role, string $uid = 'XXXXXX'): string
{
$data = [
'sub' => $uid,
'email' => 'test.test@test.test',
'resource_access' => [
'profile' => [
'roles' => [
$role,
],
],
],
];

if ('User::ROLE_CLIENT' == $role) {
$data['clientId'] = 'service-connect';
}

if (array_key_exists('clientId', $data) && 'User::ROLE_USER' == $role) {
unset($data['clientId']);
}

if ('IS_OWN_THREAD' == $role) {
$data['resource_access']['profile'] = [
'role' => [$role],
];
}

return $this->jwtEncoder->encode($data);
}
}

Toujours dans le répertoire tests on va créer une class CheckControllerTest.php et voici le code à mettre dedans :

<?php

namespace App\Tests;

use Doctrine\ORM\EntityManagerInterface;
use Hautelook\AliceBundle\PhpUnit\RefreshDatabaseTrait;
use Symfony\Bundle\FrameworkBundle\Routing\Router;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Yaml\Yaml;
use TypeError;

class CheckControllerTest extends WebTestCase
{
use RefreshDatabaseTrait;
use LoginTrait;

private const ROUTES_NOT_TESTED = [
];

public function testAllRoutes(): void
{
/** @psalm-var RouteParamYaml */
$routeYaml = Yaml::parseFile(__DIR__ . '/../config/packages/test/routes_params.yaml');

if (!isset($routeYaml['parameters'])) {
throw new TypeError('parameters key not defined on routes_params.yaml');
}
$yaml = $routeYaml['parameters'];

if (!isset($yaml['role'])) {
throw new TypeError('parameters.role key not defined on routes_params.yaml');
}
$token = $this->createUser('User::' . $yaml['role']);

if (!isset($yaml['route_params'])) {
throw new TypeError('parameters.route_params key not defined on routes_params.yaml');
}
$routeParams = $yaml['route_params'];

self::bootKernel();

/** @var Router */
$router = static::$kernel->getContainer()
->get('router');

$routes = $router->getRouteCollection();

foreach ($routes as $routeName => $route) {
if (in_array($routeName, self::ROUTES_NOT_TESTED)) {
continue;
}

// get params from yaml.
$params = $routeParams[$routeName]['params'] ?? [];
if (isset($routeParams[$routeName]['role'])) {
$token = $this->createUser('User::' . $routeParams[$routeName]['role'], $routeParams[$routeName]['userId'] ?? '');
}
if (isset($routeParams[$routeName]['getUIDFromEM'])) {
/** @var EntityManagerInterface */
$em = static::$kernel->getContainer()
->get('doctrine.orm.entity_manager');

/** @var class-string */
$class = 'App\\Entity\\' . $routeParams[$routeName]['getUIDFromEM']['entity'];
$entityUseForTest = $em
->getRepository($class)
->findOneBy($routeParams[$routeName]['getUIDFromEM']['query']);
if (!$entityUseForTest || !method_exists($entityUseForTest, 'getId')) {
throw new TypeError('Bad entity use for test id');
}
$params['id'] = $entityUseForTest->getId()->__toString();
}

$url = $router->generate($routeName, $params);
$contentType = $routeParams[$routeName]['content-type'] ?? null;
$body = $routeParams[$routeName]['body'] ?? [];
$responseStatusCode = $routeParams[$routeName]['response-status-code'] ?? 200;

if ($route->getMethods() && in_array(Request::METHOD_PUT, $route->getMethods())) {
$this->client->request('PUT', $url, $body, [], [
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
]);
}

if ($route->getMethods() && in_array(Request::METHOD_POST, $route->getMethods())) {
$this->client->request('POST', $url, $body, [], [
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
]);
}

if ($route->getMethods() && in_array(Request::METHOD_PATCH, $route->getMethods())) {
$this->client->request('PATCH', $url, $body, [], [
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
]);
}

if ($route->getMethods() && in_array(Request::METHOD_DELETE, $route->getMethods())) {
$this->client->request('DELETE', $url, [], [], [
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
]);
}

if (!$route->getMethods() || in_array(Request::METHOD_GET, $route->getMethods())) {
$this->client->request('GET', $url, [], [], [
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
]);
}

$this->assertResponseStatusCodeSame($responseStatusCode, 'url: ' . $url . ' methods: [' . join(', ', $route->getMethods()) . ']');

if ($contentType) {
$this->assertResponseHeaderSame('content-type', $contentType);
}
}
}
}

Explications

L’idée du test c’est de récupérer chaque route du projet on le fait avec le Router de Symfony très simplement.

// CheckControllerTest.php
...
$routes = $router->getRouteCollection();
...

Puis on boucle sur le résultat :

foreach ($routes as $routeName => $route) {
...
}

On regarde si la route est dans le fichier d’exclusion (vous pouvez y mettre ce que vous voulez). Si oui on ignore et on passe à la suivante.

private const ROUTES_NOT_TESTED = [
'route_a_eclure',
'route_a_eclure_2',
];

...

foreach ($routes as $routeName => $route) {
if (in_array($routeName, self::ROUTES_NOT_TESTED)) {
continue;
}
...

Ensuite on va regarder si on trouve ses params dans le fichier yaml “routes_params.yaml” pour cette route.

...
// get params from yaml.
$params = $routeParams[$routeName]['params'] ?? [];
...

Par exemple la route suivante (/api/get) à besoin d’un id :

#[Route('/api', name: 'api_')]
class ApiController extends AbstractFOSRestController
{
#[Rest\Get(path: '/{id}', name: 'get')]
#[Rest\View(statusCode: Response::HTTP_OK, serializerGroups: [Content::GROUP_GET])]
#[OA\Tag(name: 'Content')]
#[ParamConverter('content', options: ['expr' => 'repository.find(id)'])]
#[OA\Response(
response: Response::HTTP_OK,
description: 'Returns Content',
content: new OA\JsonContent(ref: new Model(type: Content::class, groups: [Content::GROUP_GET]))
)]
public function getContent(Content $content): Response
{
$view = $this->view($content, 200);
return $this->handleView($view);
}
}

on va donc le définir dans “routes_params.yaml” comme cela :

parameters:
route_params:
api_get:
params: {id: 1}

On peut également définir un code de réponse attendu par une route autre que 200 par exemple sur /logout :

parameters:
route_params:
api_get:
params: {id: 1}
logout:
response-status-code: 302

Et puis en fonction des params et des methods on va exécuter les requests et les asserts.

Vous pouvez bien sur adapter à vos besoin.

On fait tourner et on regarde

Pour exécuter les tests on lance simplement :

/bin/phpunit

Dans l’exemple ci-dessus le test indique qu’il manque un params “id” pour la route “api_get”. On l’ajoute au fichier “routes_params.yaml” comme indiqué ci-dessus et …

Voila on est bon. Comme vous pouvez le remarquer c’est magique, dès qu’une route est ajoutée à votre projet elle est testée et s’il manque un params le test va échouer.

C’est pas un coverage 100% mais au moins vous êtes sur que vos routes répondent. Çà peut éviter des catastrophes ;-)

Tester GraphQl

Si vous utilisez GraphQl il faudra ajouter cela au fichier CheckControllerTest.php :

<?php

...

class CheckControllerTest extends WebTestCase
{
...

public function testAllRoutes(): void
{
...

if (!isset($yaml['graph_ql'])) {
throw new TypeError('parameters.graph_ql key not defined on routes_params.yaml');
}
// GraphQL tests.
foreach ($yaml['graph_ql'] as $graphQl) {
$locale = $graphQl['locale'] ?? 'fr';
$body = $graphQl['body'] ?? [];
$responseStatusCode = $graphQl['response-status-code'] ?? 200;

$url = $router->generate('overblog_graphql_endpoint', ['locale' => $locale]);
$this->client->request('POST', $url, $body, [], [
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
]);
$this->assertResponseStatusCodeSame($responseStatusCode);
$this->assertResponseHeaderSame('content-type', $graphQl['content-type'] ?? 'application/json');
}

foreach ($routes as $routeName => $route) {
...
}
}
}

Et renseigner routes_params.yaml comme ci-dessous :

parameters:
role: 'ROLE_CLIENT'
graph_ql:
- body: { query: "query content {content(id: 1) {id,title}}", variables: null, operationName: "content" }
route_params:
...

En conclusion

Voila, simple rapide et efficace. N’hésitez pas à cloner le repo Git d’exemple et à jouer avec.

--

--