Mettre en place une base de données de test avec Symfony

Tanguy Dechiron

Il est une bonne pratique d'écrire des tests unitaires pour ses applications (malheureusement on ne le fait pas toujours dans le milieu professionnel).

La documentation de Symfony classe les tests en 3 catégories :

  • les tests unitaires
  • les tests d'intégration
  • les tests d'application (ou plutôt tests fonctionnels)

Nous allons nous intéresser plus particulièrement aux tests d'intégration, dans lesquels on va faire des tests sur plusieurs classes / services, et qui seront plus pertinents en utilisant des données en base de données.

J'utilise phpunit qui est le framework par défaut pour les tests avec Symfony, mais cette solution devrait fonctionner avec n'importe quel framework.

La configuration

Par défaut le fichier config/packages/test/doctrine.yaml est défini comme suit :

doctrine:
    dbal:
        # "TEST_TOKEN" is typically set by ParaTest
        dbname_suffix: '_test%env(default::TEST_TOKEN)%'

Cela signifie qu'en environnement de test, doctrine va aller chercher les données dans la base de données avec le préfixe _test. Par exemple mybase_test au lieu de mybase.

Vous pouvez créer une base de données pour vos tests, la mettre à jour manuellement mais ce n'est pas optimal.

Une meilleure solution consiste à:

  • Créer la base de données automatiquement au lancement de la suite de test
  • Ajouter / Modifier / Supprimer des données dans la base pour chaque test, en isolant les données entre chaque test
  • Supprimer la base de tests à la fin des tests

bootstrap.php

Le fichier tests/bootstrap.php est executé à chaque lancement d'une suite de tests.

On peut le modifier très simplement afin de pouvoir créer la base de données, la mettre à jour avec le schéma actuel du projet, puis la supprimer à la fin des tests:

<?php

use Symfony\Component\Dotenv\Dotenv;
use Psr\Log\LoggerInterface;

require dirname(__DIR__).'/vendor/autoload.php';

if (file_exists(dirname(__DIR__).'/config/bootstrap.php')) {
    require dirname(__DIR__).'/config/bootstrap.php';
} elseif (method_exists(Dotenv::class, 'bootEnv')) {
    (new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}

// create up-to-date TEST database before all tests start
passthru('php bin/console doctrine:database:drop --env=test --force --if-exists');
passthru('php bin/console doctrine:database:create --env=test');
passthru('php bin/console doctrine:migrations:migrate --env=test -n');

// remove TEST database after all tests end
register_shutdown_function(function() {
    passthru('php bin/console doctrine:database:drop --env=test --force --if-exists');
});

Isoler les données entre chaque test

Pour chaque test on souhaite que les données du test précédent soit effacées, afin d'avoir un environnement de test propre.

Il existe un bundle permettant de faire cela: doctrine-test-bundle

Il suffit de l'installer avec composer, la configuration se fera automatiquement avec Symfony Flex.

composer require --dev dama/doctrine-test-bundle

Lancer les tests

Vous pouvez désormais tester vos services avec des données sur une base de test.

Je vous recommande de créer une entrée dans les scripts du composer.json:

    "scripts": {
        ...
        "test": [
            "php ./vendor/bin/phpunit --debug"
        ]
    },

Puis pour lancer vos tests:

composer run test

Exemple de test

En bonus, le test d'une commande permettant de créer des utilisateurs:

CreateUserCommand.php
<?php

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use App\Entity\User;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validator\ValidatorInterface;

class CreateUserCommand extends Command
{
    protected static $defaultName = 'app:create-user';
    protected static $defaultDescription = 'Create a user with the role: ROLE_USER';

    private $entityManager;
    private $userRepository;
    private $passwordEncoder;
    private $validator;

    public function __construct(EntityManagerInterface $em, UserPasswordHasherInterface $passwordHasher, ValidatorInterface $validator)
    {
        parent::__construct();

        $this->entityManager = $em;
        $this->userRepository = $em->getRepository(User::class);
        $this->passwordHasher = $passwordHasher;
        $this->validator = $validator;
    }

    protected function configure(): void
    {
        $this
            ->addArgument('username', InputArgument::REQUIRED, 'must be unique')
            ->addArgument('password', InputArgument::REQUIRED, 'will be hashed')
            ->addArgument('email', InputArgument::REQUIRED, 'must be unique')
            ->addOption('admin',  null, InputOption::VALUE_NONE, 'if set, the user is created as an administrator')
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $username = $input->getArgument('username');
        $password = $input->getArgument('password');
        $email = $input->getArgument('email');
        $isAdmin = $input->getOption('admin');

        // validate user data
        $this->validateUser($username, $password, $email);

        // create the user and hash its password
        $user = new User();
        $user->setUsername($username);
        $user->setEmail($email);
        $user->setRoles([$isAdmin ? 'ROLE_ADMIN' : 'ROLE_USER']);
        $user->setIsVerified(true);
        $user->setActive(true);

        $hashedPassword = $this->passwordHasher->hashPassword($user, $password);
        $user->setPassword($hashedPassword);

        $this->entityManager->persist($user);
        $this->entityManager->flush();

        $io->success(sprintf('User has been created with id: %s.', $user->getId()));

        return Command::SUCCESS;
    }

    private function validateUser($username, $password, $email)
    {
        // first check if a user with the same username already exists.
        $existingUser = $this->userRepository->findOneBy(['username' => $username]);

        if (null !== $existingUser) {
            throw new RuntimeException(sprintf('There is already a user registered with the "%s" username.', $username));
        }

        // validate password length
        if ( strlen($password) < 8 )
        {
            throw new RuntimeException('Password must have at least 8 characters.');
        }

        // check if a user with the same email already exists.
        $existingEmail = $this->userRepository->findOneBy(['email' => $email]);

        if (null !== $existingEmail) {
            throw new RuntimeException(sprintf('There is already a user registered with the "%s" email.', $email));
        }

        // check email is valid
        $emailConstraint = new Assert\Email();
        $errors = $this->validator->validate(
            $email,
            $emailConstraint
        );

        if(count($errors) > 0) {
            throw new RuntimeException(sprintf('"%s" is not a valid email.', $email));
        }
    }
}
CreateUserCommandTest.php
<?php

namespace App\Tests\Command;

use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;

class CreateUserCommandTest extends KernelTestCase
{
    private $commandTester;
    private $entityManager;
    private $userRepository;

    protected function setUp(): void
    {
        $kernel = static::createKernel();
        $application = new Application($kernel);
        $command = $application->find('app:create-user');
        $this->commandTester = new CommandTester($command);

        $this->entityManager = $kernel->getContainer()
            ->get('doctrine')
            ->getManager();
        $this->userRepository = $this->entityManager
            ->getRepository(User::class);
    }

    public function testUserCreation(): void
    {
        $this->commandTester->execute([
            'username' => 'testusername',
            'password' => 'testpassword',
            'email' => 'testemail@test.com',
        ]);

        $userCreated = $this->userRepository->findOneBy(['username' => 'testusername']);
        $this->assertNotNull($userCreated);
        $this->assertContains('ROLE_USER', $userCreated->getRoles());
    }

    public function testAdminCreation(): void
    {
        $this->commandTester->execute([
            'username' => 'testusername',
            'password' => 'testpassword',
            'email' => 'testemail@test.com',
            '--admin' => true,
        ]);

        $userCreated = $this->userRepository->findOneBy(['username' => 'testusername']);
        $this->assertNotNull($userCreated);
        $this->assertContains('ROLE_ADMIN', $userCreated->getRoles());
    }

    public function testExistingUsername(): void
    {
        $this->createDefaultUser(
            'testusername', 'testemail2@test.com', ['ROLE_USER']
        );

        $this->expectExceptionMessage('There is already a user registered with the "testusername" username');

        $this->commandTester->execute([
            'username' => 'testusername',
            'password' => 'testpassword',
            'email' => 'testemail@test.com',
        ]);
    }

    public function testExistingEmail(): void
    {
        $this->createDefaultUser(
            'testusername2', 'testemail@test.com', ['ROLE_USER']
        );

        $this->expectExceptionMessage('There is already a user registered with the "testemail@test.com" email');

        $this->commandTester->execute([
            'username' => 'testusername',
            'password' => 'testpassword',
            'email' => 'testemail@test.com',
        ]);
    }

    public function testTooShortPassword(): void
    {
        $this->expectExceptionMessage('Password must have at least 8 characters');

        $this->commandTester->execute([
            'username' => 'testusername',
            'password' => 'test',
            'email' => 'testemail@test.com',
        ]);
    }

    public function testWrongEmailFormat(): void
    {
        $this->expectExceptionMessage('"testemail" is not a valid email');

        $this->commandTester->execute([
            'username' => 'testusername',
            'password' => 'testpassword',
            'email' => 'testemail',
        ]);
    }

    public function testMissingParameters(): void
    {
        $this->expectExceptionMessage('Not enough arguments');

        // no email
        $this->commandTester->execute([
            'username' => 'testusername',
            'password' => 'testpassword',
        ]);
    }

    private function createDefaultUser($username, $email, $roles=[]): void
    {
        $user = new User();
        $user->setUsername($username);
        $user->setEmail($email);
        $user->setRoles($roles);
        $user->setIsVerified(true);
        $user->setActive(true);
        $user->setPassword('*******');

        $this->entityManager->persist($user);
        $this->entityManager->flush();
    }
}


A propos de l'auteur

Tanguy Dechiron

Tanguy Dechiron

Développeur web fullstack (Symfony++).
Passionné de littérature fantasy, jeux de société.
Cycliste du dimanche.


Blog Comments powered by Disqus.