Symfony : Tester et Maîtriser le Nombre de Requêtes SQL avec PHPUnit

S

Lors du développement d’applications Symfony, il est essentiel de surveiller le nombre de requêtes SQL générées pour éviter des problèmes de performance. Dans cet article, nous allons mettre en place un test PHPUnit permettant de vérifier qu’une page donnée n’exécute pas un nombre excessif de requêtes, tout en détectant le problème bien connu du N+1.

Pourquoi tester le nombre de requêtes SQL ?

Une mauvaise gestion des requêtes SQL peut entraîner des ralentissements importants, notamment lorsque le Lazy-Loading (chargement à la demande) est mal utilisé. Ce test permet de :

  • Identifier les pages où trop de requêtes sont exécutées.
  • Prévenir le problème du N+1 : Doctrine exécute une requête supplémentaire pour chaque entité liée au lieu d’utiliser une jointure SQL optimisée.

Prérequis

Avant de commencer, vous devez créer un projet Symfony avec une base de données fonctionnelle.

Création du projet :

symfony new BlogTestsProfiler --webapp

Configuration de la base de données :

DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"

Mise en place des entités

Voici les entités Author et Post :

#[ORM\Entity(repositoryClass: AuthorRepository::class)]
class Author
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $username = null;

    #[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'author')]
    private Collection $posts;
}
#[ORM\Entity(repositoryClass: PostRepository::class)]
class Post
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $content = null;

    #[ORM\ManyToOne(inversedBy: 'posts')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Author $author = null;
}

Génération de données avec Faker

Mise en place des fixtures :

composer require orm-fixtures --dev
bin/console make:fixtures

Installez Faker pour générer des données fictives :

composer req fakerphp/faker --dev

Exemple de fixture :

class AppFixtures extends Fixture
{
    public function load(ObjectManager $manager): void
    {
        $faker = \Faker\Factory::create();

        for ($i = 0; $i < 100; $i++) {
            $author = new Author();
            $author->setUsername($faker->userName);
            $manager->persist($author);

            for ($j = 0; $j < rand(0, 100); $j++) {
                $post = new Post();
                $post->setTitle($faker->sentence);
                $post->setContent($faker->paragraph);
                $post->setAuthor($author);
                $manager->persist($post);
            }
        }

        $manager->flush();
    }
}

Création, migration et chargement des données :

bin/console doctrine:database:create
bin/console make:migration
bin/console doctrine:migration:migrate
bin/console doctrine:fixtures:load

Création du contrôleur et de la vue

#[Route('/', name: 'app_author')]
public function index(EntityManagerInterface $entityManager): Response
{
    $authors = $entityManager->getRepository(Author::class)->findAll();

    return $this->render('author/index.html.twig', [
        'authors' => $authors,
    ]);
}
{% extends 'base.html.twig' %}

{% block title %}Authors{% endblock %}

{% block body %}
<table>
    <thead>
        <tr>
            <th>Id</th>
            <th>Name</th>
            <th>Nb Posts</th>
        </tr>
    </thead>
    <tbody>
        {% for author in authors %}
            <tr>
                <td>{{ author.id }}</td>
                <td>{{ author.username }}</td>
                <td>{{ author.posts|length }}</td>
            </tr>
        {% endfor %}
    </tbody>
</table>
{% endblock %}

En allant sur la route du controller on peut déjà constater que l’on a un nombre trop élevé de requête :

Activer le profiler en environnement de test

Par défaut, le profiler Symfony n’est pas activé dans l’environnement de test. Pour permettre au test PHPUnit de collecter les informations sur les requêtes SQL, vous devez modifier le fichier config/packages/framework.yaml comme suit :

when@test:
    framework:
        test: true
        profiler:
            enabled: true
            collect: false

Cette configuration active le profiler tout en désactivant la collecte automatique des données (ce qui peut être gourmand en ressources). Le test activera alors manuellement le profiler pour chaque requête simulée.

Mise en place du test PHPUnit

class RequestCountTest extends WebTestCase
{
    public function testRequestCount(): void
    {
        $client = static::createClient();
        $client->enableProfiler();

        $crawler = $client->request('GET', '/');
        $this->assertResponseIsSuccessful();

        $this->assertLessThan(10, $client->getProfile()->getCollector('db')->getQueryCount());
    }
}

Exécution et analyse des résultats

Lancez le test avec la commande :

bin/phpunit

Le test devrait échouer, ce qui indique que le nombre de requêtes SQL exécutées dépasse la limite définie :

Failed asserting that 101 is less than 10.

Correction du problème

  • Configurer fetch = EAGER
#[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'author', fetch: 'EAGER')]
private Collection $posts;
  • Utiliser une requête DQL avec jointure
$authors = $entityManager->createQueryBuilder()
    ->select('a', 'p')
    ->from(Author::class, 'a')
    ->leftJoin('a.posts', 'p')
    ->getQuery()
    ->getResult();

J’ai une préférence pour la deuxième solution, car elle offre davantage de maîtrise sur les requêtes. L’utilisation de fetch = EAGER peut être déconseillée dans le cas de gros volumes de données, car cela pourrait consommer une quantité importante de mémoire.

En relançant le test vous pourrez constater qu’il est maintenant vert, et on peut aussi le constater dans le profiler :

Conclusion

En testant le nombre de requêtes SQL et en corrigeant les problèmes de N+1, vous garantissez des performances optimales pour votre application Symfony.

BONUS !!!

Petit bonus pour ceux qui sont restés jusqu’au bout, on peut aussi tester le temps de réponse :

$this->assertLessThan(
500,
$profile->getCollector('time')->getDuration()
);

A propos de l'auteur

Ajouter un commentaire

Guillaume

Get in touch

Je suis un développeur web passionné par les technologies Symfony et ChatGPT. J'aime partager mes connaissances et mon expérience à travers des articles que j'écris sur ces sujets. Avec une solide expertise en développement web, je m'efforce de fournir des contenus utiles et instructifs pour aider les développeurs à créer des applications web de qualité supérieure.