Comment démarrer en TDD en PHP ?

Test Driven Development c'est bien mais comment commencer à en faire ?

  1. Pourquoi faire du Test Driven Development
  2. Outillage
  3. Notre exemple
  4. Le service à tester
  5. Conclusion

Test Driven Development ou le développement guidé par les tests, n'est clairement pas une méthode triviale. N'ayez pas honte de ne pas savoir par où commencer. Beaucoup de personnes parlent de « déclic » lorsqu'il s'agit d'appréhender le TDD. L'ambition de cet article est d'essayer de créer ce déclic.

Pourquoi faire du Test Driven Development

On trouve pléthore de littérature à ce sujet et voici selon nous les avantages à faire du TDD en vrac :

  • tester les différents cas de figure,
  • le plus souvent les tests s'appuient sur les spécifications et cela permet donc de conserver la documentation dans le code (live documentation),
  • permet de mieux découper le code (notion de clean code),
  • poser une première implémentation puis optimiser ou/et refactoriser et à chaque fois la valider grâce aux tests,
  • gain de temps en validant l'implémentation via la console sans passer par les tests dans le navigateur.

Outillage

Installons le framework de test PHPUnit :

$ composer require --dev phpunit/phpunit ^7

En considérant que notre namespace est MyCompany\App on devrait avoir ceci dans notre composer.json :

{
  "autoload": {
    "psr-4": {
      "MyCompany\\App\\": "src"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "MyCompany\\App\\Tests\\": "tests"
    }
  },
  "require-dev": {
    "phpunit/phpunit": "^7.0"
  }
}

Vérifions que tout est en place en lançant :

./vendor/bin/phpunit

Nous devrions avoir quelque chose comme ça :

> No tests executed!

Notre exemple

Imaginons que nous intervenons sur un blog. Les utilisateurs de ce blog ont besoin de voir la liste des articles. Pour chaque article, on voit un titre et le nombre de commentaires laissés. Si un article a été publié il y a moins d'une semaine, le titre de l'article contient "Nouveau !" en préfixe.

Hypothèses

Les articles sont stockés quelque part, en base de données, fichiers, peu importe... Il me faut donc un service qui récupère les données puis les prépare pour être affichées.

On va considérer que :

  • le ou les services pour récupérer les données existent déjà,
  • l'affichage sera géré par autre chose, par le controller et le templating de notre application.

On va uniquement se focaliser pour cet exemple sur un service qui va préparer les données souhaitées.

Imaginer quelle est la plus petite entité logique à tester et faire cet effort de réflexion est finalement la premère étape en TDD.

Entité

Nous allons uniquement utiliser l'entité suivante Post représentant un article du blog :

<?php

namespace MyCompany\App\Post;

class Post
{
    /** @var string */
    public $title;

    /** @var \DateTime */
    public $publishedAt;

    public function __construct(string $title, \DateTime $publishedAt)
    {
        $this->title = $title;
        $this->publishedAt = $publishedAt;
    }
}

Dépendances

Considérons que nous avons les deux interfaces suivantes permettant d'accéder aux articles et au nombre de commentaires par articles.

PostRepository:

<?php

namespace MyCompany\App\Repository;

use MyCompany\App\Post\Post;

interface PostRepository
{
    /**
     * @return Post[]
     */
    public function getAll(): array;
}

CommentRepository:

<?php

namespace MyCompany\App\Repository;

use MyCompany\App\Post\Post;

interface CommentRepository
{
    public function countByPost(Post $post): int;
}

La plomberie

Créons une classe PostView qu'on appelle communément un DTO (data transfer object), un objet de transfert de données qui ne contient aucun comportement, que des valeurs :

<?php

namespace MyCompany\App\Post;

final class PostView
{
    /** @var string */
    public $title;

    /** @var int */
    public $commentsNumber;

    /** @var bool */
    public $isNew;

    public function __construct(string $title, int $commentsNumber, bool $isNew)
    {
        $this->title = $title;
        $this->commentsNumber = $commentsNumber;
        $this->isNew = $isNew;
    }
}

Le service à tester

Créons notre service GetPostsList qui ne fait en réalité qu'une seule chose, une seule méthode publique, _invoke :

<?php

namespace MyCompany\App\Post;

final class GetPostsList
{
    public function __invoke(): array
    {
        return [];
    }
}

Les enfants et les tests d'abord !

Créons maintenant un test de GetPostsList qui est censé nous envoyer deux articles, l'un marqué nouveau, l'autre non. Il est important de tester tous les cas. Cela tombe bien, notre besoin est simple, on a que deux cas.

Dans : tests/Post/GetPostsListTest.php :

<?php

namespace MyCompany\App\Tests\Post;

use MyCompany\App\Post\GetPostsList;
use MyCompany\App\Post\PostView;
use PHPUnit\Framework\TestCase;

class GetPostsListTest extends TestCase
{
    public function test_get_new_and_old_posts()
    {
        $getPostsList = new GetPostsList();

        $this->assertEquals(
            [
                new PostView('TDD c\'est bien', 42, true), // is new
                new PostView('Tester ce n\'est pas douter', 1, false), // is not new
            ],
            $getPostsList()
        );
    }
}

Lançons les tests :

$ ./vendor/bin/phpunit

Bien sûr, cela échoue :

There was 1 failure:

    1) MyCompany\App\Tests\Post\GetPostsListTest::test_get_new_and_old_posts
    Failed asserting that two arrays are equal.
    --- Expected
    +++ Actual
    @@ @@
     Array (
    -    0 => MyCompany\App\Post\PostView Object (...)
    -    1 => MyCompany\App\Post\PostView Object (...)
     )

Mais pas de panique. Au contraire, faire échouer les tests est la 1ère étape en TDD !

Complétons le test en faisant un mock de chaque dépendance. Le mock nous permet de s'affranchir de l'implémentation de la dépendance et de décider ce que celle-ci renvoie comme données.

<?php

namespace MyCompany\App\Tests\Post;

use MyCompany\App\Post\GetPostsList;
use MyCompany\App\Post\Post;
use MyCompany\App\Post\PostView;
use MyCompany\App\Repository\CommentRepository;
use MyCompany\App\Repository\PostRepository;
use PHPUnit\Framework\TestCase;

class GetPostsListTest extends TestCase
{
    public function test_get_new_and_old_posts()
    {
        $post1 = new Post('TDD c\'est bien', new \DateTime('2018-10-29'));
        $post2 = new Post('Tester ce n\'est pas douter', new \DateTime('2018-10-01'));

        // mock of PostRepository interface
        $postRepository = $this->prophesize(PostRepository::class);
        $postRepository->getAll()->shouldBeCalled()->willReturn([$post1, $post2]);

        // mock of CommentRepository interface
        $commentRepository = $this->prophesize(CommentRepository::class);
        $commentRepository->countByPost($post1)->shouldBeCalled()->willReturn(42);
        $commentRepository->countByPost($post2)->shouldBeCalled()->willReturn(1);

        $getPostsList = new GetPostsList($postRepository->reveal(), $commentRepository->reveal());

        $this->assertEquals(
            [
                new PostView('TDD c\'est bien', 42, true),
                new PostView('Tester ce n\'est pas douter', 1, false),
            ],
            $getPostsList()
        );
    }
}

Enfin, l'implémentation

Nous avons tout ce qu'il faut pour implémenter notre code :

<?php

namespace MyCompany\App\Post;

use MyCompany\App\Repository\CommentRepository;
use MyCompany\App\Repository\PostRepository;

final class GetPostsList
{
    /** @var PostRepository */
    private $postRepository;

    /** @var CommentRepository */
    private $commentRepository;

    public function __construct(PostRepository $postRepository, CommentRepository $commentRepository)
    {
        $this->postRepository = $postRepository;
        $this->commentRepository = $commentRepository;
    }

    public function __invoke(): array
    {
        $postViews = [];

        foreach ($this->postRepository->getAll() as $post) {
            $isLessThanAWeekOld = (clone $post->publishedAt)->add(new \DateInterval('P1W')) > new \DateTime();

            $postViews[] = new PostView(
                $post->title,
                $this->commentRepository->countByPost($post),
                $isLessThanAWeekOld
            );
        }

        return $postViews;
    }
}

Enfin, lançons les tests :

$ ./vendor/bin/phpunit
> OK (1 test, 4 assertions)

Le temps n'attend pas

Attention, il y a un piège. Désolé mais ce test ne fonctionnera plus dans quelques jours. Pourquoi ? Nous avons une comparaison avec la date "système" dans l'implémentation (new \DateTime) alors que les dates sont fixes dans les tests.

La date courante est une... dépendance. Modifions notre test pour fixer la date courante pour qu'elle ne bouge plus et ne fasse plus échouer les tests dans quelques jours !

<?php
// ...
class GetPostsListTest extends TestCase
{
    public function test_get_new_and_old_posts()
    {
        $now = new \DateTime('2018-10-30');
        // ...

        $getPostsList = new GetPostsList($postRepository->reveal(), $commentRepository->reveal(), $now);

Supprimons maintenant le new \DateTime dans la class GetPostsList et utilisons la date en dépendance :

<?php
// ...
final class GetPostsList
{
    // ...

    /** @var \DateTime */
    private $now;

    public function __construct(PostRepository $postRepository, CommentRepository $commentRepository, \DateTime $now)
    {
        $this->postRepository = $postRepository;
        $this->commentRepository = $commentRepository;
        $this->now = $now;
    }

    public function __invoke(): array
    {
        $postViews = [];

        foreach ($this->postRepository->getAll() as $post) {
            $isLessThanAWeekOld = (clone $post->publishedAt)->add(new \DateInterval('P1W')) > $this->now;
            // ...

Vérifions :

    $ ./vendor/bin/phpunit
> OK (1 test, 4 assertions)

Super !

Vous trouverez le code de cet exemple ici : https://github.com/supertanuki/TDD-en-PHP

Conclusion

J'espère que cet exemple vous aura convaincu des avantages du Test Driven Development. Pour finir, un dernier conseil pour mettre le pied à l'étrier : sur votre projet en cours, dès que vous devez faire un service qui fait quelque chose d'assez simple avec une ou deux conditions, posez d'abord des tests vérifiant chacune des conditions avant d'implémenter ledit service.

À vos marques, prêts, testez !

PS : si vous souhaitez vous éclater en faisant du TDD et du code de qualité, rejoignez-nous !