Introduction au TDD avec Symfony et PHPUnit

Retour

Tu as surement entendu parler de TDD, le Test Driven Development (Développement Piloté par les Tests) et si tu lis ceci, c’est que tu commences à t’y intéresser et tu as sous doute bien raison. Alors viens faire un tour avec moi dans cette brève introduction au TDD.

Pourquoi ?

Le TDD est là pour répondre à une simple problématique : coder avec le moins de déchet possible. De ce simple fait, l’idée derrière cette méthodologie est d’itérer selon le cycle Red-Green-Refactor. Cela signifie que tu vas écrire un test, qui va échouer (Red), puis le code qui va te permettre rapidement de faire passer ton test au vert (Green) et une fois que c’est OK, tu vas factoriser pour avoir un code élégant (Refactor). La subtilité est que tu vas devoir rédiger le minimum de code possible pour faire passer le test au vert, tu ne t’occupes de rien d’autres ! Il en résulte que ton code sera plus facile à maintenir et que cela préviendra des éventuels bugs (effet de bord suite à un quelconque changement par exemple) mais aussi que tu n’auras qu’une chose sur laquelle te concentrer pendant ton développement.

Comment ?

Avec le framework Symfony, tu peux utiliser PHPUnit. La CLI te permettra de créer les fichiers avec la commande :

php bin/console make:test

Une fois le fichier créé, tu pourras le retrouver dans le dossiers Tests.

Ça y est on est parti 🚀

Le test doit être concis et ne vérifier qu’une seule chose. Mais surtout, il doit échouer à sa première exécution. Je te l’ai dis au début, le développement est dirigé par le test, ce qui signifie que ma route (si c’est ça qu’on veut tester) n’existe pas encore.

class ProductTest extends WebTestCase
{
    public function testGetProduct(): void
    {
        $client = static::createClient();
        $client->request('GET', '/products');
        $response = $client->getResponse();
        $this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
    }
}

Et lorsqu’on lance le test, dans la console apparait le message suivant :

Testing
Product (App\\Tests\\Product)
✘ Something
┐
├ Failed asserting that 404 matches expected 200.
│
╵ C:\\Users\\yfran\\Sites\\sandbox\\tests\\ProductTest.php:15
┴

Si on veut se situer dans notre cycle, nous sommes au tout début, dans la partie Red. Maintenant il faut passer au vert, alors créons le controller et la route app_get_products :

#[Route('/products', name: 'app_get_products', methods: 'GET')]
public function getProducts(): JsonResponse
{
    return $this->json([
        'message' => 'Welcome to your new controller!',
        'path' => 'src/Controller/ProductController.php',
    ]);
}

Relançons le test et …

Testing
Product (App\\Tests\\Product)
 ✔ Product

Time: 00:03.458, Memory: 48.00 MB

OK (1 test, 1 assertion)

Top ! Bon maintenant j’aimerai que cette route me renvoie une liste de produits au format JSON. J’édite en premier mon test en ajoutant quelques assertions : une première pour vérifier qu’il s’agisse bien d’un JSON puis j’itère sur mon tableau pour vérifier que les éléments correspondent bien à la définition de ma Product classe.

💡 J’utilise la librairie Faker avec le bundle DoctrineFixturesBundle pour générer plusieurs produits en base de données, au moment ou je lance le test, des données sont déjà chargées en bdd.

public function testGetProduct(): void
{
    $client = static::createClient();
    $client->request('GET', '/products');
    $response = $client->getResponse();
    $content  = $response->getContent();
    $products = json_decode($content, true);

    $this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
    $this->assertJson($content);
    $this->assertIsArray($content, 'Response is not an array');

    foreach ($products as $product) {
        $this->assertArrayHasKey('id', $product, 'Property id does not exists');
        $this->assertArrayHasKey('name', $product, 'Property name does not exists');
        $this->assertArrayHasKey('description', $product, 'Property description does not exists');
        $this->assertArrayHasKey('price', $product, 'Property price does not exists');
    }
}

Ma route /products devrait donc désormais me renvoyer un tableau d'objet Product au format JSON. Mais comme je ne l'ai pas encore modifier...

Testing
Product (App\\Tests\\Product)
✘ Product route
┐
├ PHPUnit\\Framework\\InvalidArgumentException: Argument #2 of PHPUnit\\Framework\\Assert::assertArrayHasKey() must be an array or ArrayAccess
│
╵ C:\\Users\\yfran\\Sites\\sandbox\\tests\\ProductTest.php:23
┴
Time: 00:00.300, Memory: 28.00 MB

ERRORS!
Tests: 1, Assertions: 3, Errors: 1.

On est reparti pour un tour, passons notre test au vert sans plus attendre :

#[Route('/product', name: 'app_get_products', methods: 'GET')]
public function getProducts(ProductRepository $productRepository): JsonResponse
{
    $serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
    $products = $productRepository->findAll();

    return new JsonResponse(
        data: $serializer->serialize($products, 'json'),
        status: Response::HTTP_OK,
        json: true
    );
}

Et relançons le test :

Testing
Product (App\\Tests\\Product)
✔ Product route

Time: 00:00.333, Memory: 30.00 MB
OK (1 test, 47 assertions)

Notre route est désormais terminée. On pourrait ajoute d’autre vérification, comme par exemple vérifier que notre tableau contient au moins un produit ou encore si notre utilisateur est autorisé à visualiser les produits. Auquel cas on écrirait du code un peu sale pour rapidement passer le test au vert et c’est justement dans la troisième étapes de notre itération qu’il faudrait factoriser pour terminer sur un code élégant. Ici mon exemple étant très simpliste il n’y a pas eu besoin de factoriser quoi que ce soit (à mon sens) mais c’est une étape importante à ne pas négliger ☝️

❕Voyons maintenant le cas avec un peu de refactorisation en créant un test pour créer un produit
public function testPostProduct(): void
{
    $client = static::createClient();
    $client->request(
        method: 'POST',
        uri: '/product',
        content: ''
    );

    $response = $client->getResponse();
    $this->assertEquals(Response::HTTP_CREATED, $response->getStatusCode());
}

Comme dans l’exemple avec la route GET, si je lance le test dans l’état actuel des choses on va avoir une erreur :

Summary of non-successful tests:

Product (App\\Tests\\Product)
✘ Post product
┐
├ Failed asserting that 404 matches expected 201.
│
╵ C:\\Users\\yfran\\Sites\\sandbox\\tests\\ProductTest.php:59
┴

Encore une fois, on va à l’essentiel dans notre controller :

#[Route('/product', name: 'app_post_product', methods: 'POST')]
public function createProduct(): JsonResponse
{
    return new JsonResponse(status: Response::HTTP_CREATED);
}

Puis je retourne dans mon test pour ajouter un peu plus de fonctionnalité : je vais d’abord créer un nouveau produit que je vais passer à ma requête. Par la suite je vérifie si le code HTTP est bien 201 (created). Je récupère l’entity manager grâce au container de service et je récupère dans la base de données un produit qui serait strictement identique à celui que j’ai passé à ma requête POST. Si le produit retourné par Doctrine est une instance de Product, alors on est tout bon.

public function testPostProduct(): void
{
    $serializer = new Serializer(
        [new ObjectNormalizer()],
        [new JsonEncoder()]
    );

    $name = 'Foo';
    $description = 'Bar';
    $price = 42.99;

    $product = new Product();
    $product
        ->setName($name)
        ->setDescription($description)
        ->setPrice($price);

    $client = static::createClient();
    $client->request(
        method: 'POST',
        uri: '/product',
        content: $serializer->serialize($product, 'json')
    );

    $response = $client->getResponse();
    $this->assertEquals(Response::HTTP_CREATED, $response->getStatusCode());

    $container = $this->getContainer();
    $entityManager = $container->get('doctrine.orm.entity_manager');
    $productRepository = $entityManager->getRepository(Product::class);

    $productFromDb = $productRepository->findOneBy([
        'name'        => $name,
        'description' => $description,
        'price'       => $price
    ]);

    $this->assertInstanceOf(Product::class, $productFromDb);
}

Tu connais la chanson maintenant, le test va échouer et on va donc s’empresser d’aller éditer notre méthode pour passer rapidement au vert.

#[Route('/product', name: 'app_post_product', methods: 'POST')]
public function createProduct(
    Request $request,
    ProductRepository $productRepository
): JsonResponse
{
    $json = json_decode($request->getContent(), true);

    $product = new Product();
    $product
        ->setName($json['name'])
        ->setDescription($json['description'])
        ->setPrice($json['price']);

    $productRepository->save($product, true);

    return new JsonResponse(status: Response::HTTP_CREATED);
}

Notre test est désormais vert, cette fois on peut refactoriser notre méthode 👇

#[Route('/product', name: 'app_post_product', methods: 'POST')]
public function createProduct(
    Request $request,
    ProductRepository $productRepository
): JsonResponse
{
    $serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
    $product = $serializer->deserialize($request->getContent(), Product::class, 'json');

    $productRepository->save($product, true);

    return new JsonResponse(status: Response::HTTP_CREATED);
}

Conclusion

Tu auras fait avec moi une courte introduction à la méthodologie TDD. Je n’en suis qu’au début mais c’est très intéréssant comme manière de travailler et je l’utiliserais surement dans mes prochains projets. Cela permet vraiment de se concentrer sur l’essentiel. Si le test n’est pas vert, c’est qu’on a mal développer notre fonctionnalité. Si c’est vert au premier lancement du test, alors la fonctionnalité fait déjà ce qu’on lui demande (ça serait plutôt étrange, non ? 😁). Mais dès que le test réussi et qu’on a factoriser, il ne faut plus rien ajouter à moins d’écrire de nouveaux tests.