678 mots
3 minutes
Tester le nombre de requêtes SQL dans Symfony avec PHPUnit

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êtes :

Nombre de requêtes élevé

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 :

Nombre de requêtes réduit

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()
);