Comment j'ai écrit des centaines de tests pour mon portfolio Laravel, et pourquoi je recommencerais

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
12 min de lecture
1,570 vues

Écouter l'article

Comment j'ai écrit des centaines de tests pour mon portfolio Laravel, et pourquoi je recommencerais

À l'époque où j'ai écrit cet article, mon portfolio comptait environ 82 fichiers de tests et plus de 760 méthodes de test. Ces chiffres décrivent un état à un instant donné, ils ne sont pas destinés à rester à jour. Ce qui compte, c'est le chemin : un apprentissage progressif et quelques régressions qui m'ont convaincu que tester n'était pas optionnel.

Je vais être honnête : pendant longtemps, j'ai considéré les tests comme une perte de temps. « C'est un portfolio, pas une application bancaire. » C'est ce que je me disais en déployant sans aucun filet de sécurité. Puis j'ai cassé le formulaire de contact en refactorant le système anti-spam, et je ne m'en suis rendu compte qu'après coup, parce que rien ne signalait l'échec : le formulaire ne produisait simplement plus de messages.

Ce jour-là, j'ai ouvert PHPUnit et j'ai commencé.

Comprendre la vraie différence entre test unitaire et test fonctionnel

La théorie dit : un test unitaire teste une unité de code isolée, un test fonctionnel teste une fonctionnalité complète. En pratique, la frontière n'est pas toujours aussi nette, et les erreurs de catégorisation sont fréquentes au début.

Mon test du trait HasSlug illustre la zone grise. Il crée une table temporaire et vérifie le comportement du trait sans dépendre du reste de l'application :

class HasSlugTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // Table temporaire juste pour ce test
        Schema::create('slug_test_models', function (Blueprint $table) {
            $table->id();
            $table->string('titre');
            $table->string('slug')->nullable();
            $table->timestamps();
        });
    }

    public function test_auto_generates_slug_on_creation(): void
    {
        $model = SlugTestModel::create(['titre' => 'Développement Web']);
        $this->assertEquals('developpement-web', $model->slug);
    }

    public function test_does_not_overwrite_custom_slug(): void
    {
        $model = SlugTestModel::create(['titre' => 'Test', 'slug' => 'mon-slug']);
        $this->assertEquals('mon-slug', $model->slug);
    }
}

Ce test vérifie deux comportements précis : la génération automatique et le respect d'un slug fourni manuellement. Il n'y a ni HTTP ni vue, mais il n'est pas pour autant « sans base de données » : il démarre le framework, crée une vraie table dans la base de test (SQLite en mémoire) et passe par Eloquent. C'est un test d'intégration léger, rapide et ciblé, plutôt qu'un test unitaire pur. Cette distinction compte peu en pratique, tant qu'on sait ce qu'on teste.

En face, mon test du BlogController est un test fonctionnel : il fait une vraie requête HTTP, passe par le routing, le middleware, le controller, le modèle et la vue :

public function test_blog_index_shows_published_articles(): void
{
    Article::factory()->create([
        'status' => 'published',
        'published_at' => now()->subDay(),
        'title' => 'Published Article',
    ]);
    Article::factory()->create([
        'status' => 'draft',
        'title' => 'Draft Article',
    ]);

    $response = $this->get(route('blog.index'));
    $articles = $response->viewData('articles');
    $this->assertEquals(1, $articles->count());
}

Une erreur fréquente consiste à ranger des tests fonctionnels dans le dossier Unit/. Ça marche, mais les résultats deviennent trompeurs : si un test unitaire échoue, on sait immédiatement quelle méthode est cassée. Si un test fonctionnel échoue, ça peut être le controller, le modèle ou la vue. L'information n'est pas de même nature.

Tester la sécurité : là où ça devient indispensable

Le formulaire de contact est la surface d'attaque la plus exposée d'un portfolio : n'importe qui peut envoyer des données. J'ai donc des tests spécifiques pour chaque couche de protection :

public function test_honeypot_blocks_spam(): void
{
    $token = $this->formToken(); // helper de test : jeton du formulaire (_form_token)

    $response = $this->post(route('contact.send'), [
        'nom'         => 'Bot',
        'email'       => 'bot@example.com',
        'sujet'       => 'Spam',
        'message'     => 'Ceci est un message de test.',
        'website'     => 'http://spam.com', // champ honeypot
        '_form_token' => $token,
    ]);

    // Le bot reçoit une fausse confirmation
    $response->assertRedirect();
    $response->assertSessionHas('success');
    // Mais rien n'est sauvegardé
    $this->assertDatabaseMissing('contact_messages', [
        'email' => 'bot@example.com'
    ]);
}

Ce test vérifie un détail subtil : le honeypot retourne un faux succès au bot. Si on retournait une erreur, le bot saurait que le honeypot existe et pourrait s'adapter. En simulant un succès avec assertSessionHas('success') tout en vérifiant que rien n'est sauvegardé avec assertDatabaseMissing, on confirme que la protection fonctionne sans révéler son existence.

Il y a aussi des tests pour les emails jetables et le contenu spam (le rate limiting se teste de la même façon, en répétant la requête au-delà de la limite) :

public function test_disposable_email_rejected(): void
{
    $response = $this->post(route('contact.send'), [
        'nom'         => 'Test',
        'email'       => 'test@yopmail.com',
        'sujet'       => 'Sujet normal',
        'message'     => 'Message légitime suffisamment long.',
        '_form_token' => $token,
    ]);

    $response->assertRedirect();
    $response->assertSessionHas('error');
}

public function test_spam_content_blocked(): void
{
    $response = $this->post(route('contact.send'), [
        'nom'         => 'Spammer',
        'email'       => 'spam@example.com',
        'sujet'       => 'casino offer',
        'message'     => 'Click here to win at our casino. Buy viagra now!',
        '_form_token' => $token,
    ]);

    $this->assertDatabaseMissing('contact_messages', [
        'email' => 'spam@example.com'
    ]);
}

Ces tests protègent contre deux régressions typiques. La première : un filtre anti-lien dont le regex est trop agressif et bloque aussi des messages légitimes qui mentionnent « http ». La seconde : un refactoring du controller de contact qui casse le honeypot silencieusement, sans qu'aucune erreur visible n'apparaisse.

Tester les headers de sécurité : un cas d'école

Mon middleware SecurityHeaders ajoute six headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Strict-Transport-Security et Content-Security-Policy, les deux derniers uniquement en production, comme décrit dans l'article sur la sécurité). Le tester est un bon exercice pour apprendre à tester un middleware en isolation :

class SecurityHeadersTest extends TestCase
{
    private function runMiddleware(): Response
    {
        $request    = Request::create('/');
        $middleware  = new SecurityHeaders();
        return $middleware->handle($request, fn ($r) => response('OK'));
    }

    public function test_sets_content_type_options(): void
    {
        $response = $this->runMiddleware();
        $this->assertEquals('nosniff',
            $response->headers->get('X-Content-Type-Options'));
    }

    public function test_sets_hsts_in_production(): void
    {
        app()->detectEnvironment(fn () => 'production');
        $response = $this->runMiddleware();
        $this->assertStringContainsString('max-age=31536000',
            $response->headers->get('Strict-Transport-Security'));
    }

    public function test_csp_only_in_production(): void
    {
        $response = $this->runMiddleware();
        $this->assertNull(
            $response->headers->get('Content-Security-Policy')
        );
    }

    public function test_deprecated_xss_protection_header_is_absent(): void
    {
        $response = $this->runMiddleware();
        $this->assertNull($response->headers->get('X-XSS-Protection'));
    }
}

La méthode runMiddleware() instancie le middleware à la main et lui passe une closure comme $next. C'est plus rapide qu'un test HTTP complet, et ça isole le middleware : si un test échoue, c'est le middleware qui est en cause, pas le routing ou le controller.

Le test test_csp_only_in_production vérifie que la CSP n'est pas appliquée en environnement de test. C'est un choix délibéré : une CSP stricte peut gêner les outils de développement qui injectent des scripts ou des styles inline (Debugbar, par exemple). Le dernier test garantit qu'on n'a pas réintroduit par erreur l'en-tête X-XSS-Protection, déprécié.

Les tests de modèle : la couche de confiance

Je teste systématiquement les accesseurs, les scopes et les relations de chaque modèle. Ça peut sembler excessif, mais c'est la couche qui évite le plus de régressions :

public function test_formatted_views_accessor(): void
{
    $article = Article::factory()->create(['views_count' => 500]);
    $this->assertEquals('500', $article->formatted_views);

    $article2 = Article::factory()->create(['views_count' => 1500]);
    $this->assertEquals('1.5k', $article2->formatted_views);

    $article3 = Article::factory()->create(['views_count' => 1500000]);
    $this->assertEquals('1.5M', $article3->formatted_views);
}

public function test_scope_published(): void
{
    Article::factory()->published()->create();
    Article::factory()->draft()->create();
    Article::factory()->create([
        'status' => Article::STATUS_PUBLISHED,
        'published_at' => now()->addDay(), // publié dans le futur
    ]);

    $published = Article::published()->get();
    $this->assertCount(1, $published);
}

Le test test_scope_published vérifie un cas subtil : un article dont le status est « published » mais dont published_at est dans le futur ne doit pas apparaître. C'est un article programmé. Sans ce test, ce cas se perd facilement et les articles programmés deviennent visibles immédiatement.

Le test test_formatted_views_accessor vérifie les trois seuils de formatage (normal, milliers, millions). Ce genre de test attrape des défauts discrets : afficher « 1500 » au lieu de « 1.5k » ne casse rien, mais rend l'affichage incohérent et passe facilement inaperçu.

La gestion des exceptions : tester le comportement en cas d'erreur

C'est le test que je trouve le plus intéressant à écrire, parce qu'on vérifie ce que l'application fait quand les choses tournent mal :

public function test_404_json_returns_proper_format(): void
{
    $response = $this->getJson('/api/resource-qui-nexiste-pas');
    $response->assertStatus(404);

    // Vérifier que le contenu ne contient pas d'infos sensibles
    $content = $response->getContent();
    $this->assertStringNotContainsString('QueryException', $content);
    $this->assertStringNotContainsString('SQLSTATE', $content);
}

Ce test vérifie deux choses : que l'API retourne bien un 404 (et non un 500), et surtout que la réponse ne contient pas de trace technique. En production, un message comme SQLSTATE[HY000] dans une réponse JSON est une fuite d'information qui donne aux attaquants des indices sur la base de données. Ce test est invisible pour l'utilisateur mais utile pour la sécurité.

La configuration PHPUnit : les détails qui comptent

Mon phpunit.xml utilise SQLite en mémoire pour les tests, ce qui les rend rapides :

<php>
    <env name="APP_ENV" value="testing"/>
    <env name="BCRYPT_ROUNDS" value="4"/>
    <env name="CACHE_DRIVER" value="array"/>
    <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/>
    <env name="MAIL_MAILER" value="array"/>
    <env name="QUEUE_CONNECTION" value="sync"/>
    <env name="SESSION_DRIVER" value="array"/>
</php>

Quelques choix et leurs raisons :

BCRYPT_ROUNDS à 4. Le hashing bcrypt est volontairement lent pour les mots de passe, c'est une protection en production. Dans les tests, chaque hash coûte du temps pour rien : réduire le coût accélère la suite. Cette valeur est déjà celle du phpunit.xml fourni avec le squelette Laravel, il suffit de la conserver.

CACHE_DRIVER en array : le cache vit en mémoire et rien ne persiste entre les tests. À partir de Laravel 11, la variable s'appelle CACHE_STORE ; CACHE_DRIVER correspond à Laravel 10. SESSION_DRIVER en array suit la même logique. C'est essentiel pour l'isolation : un cas fréquent est celui de tests qui passent individuellement mais échouent en lot, parce qu'un test précédent a laissé des données en cache.

MAIL_MAILER en array : aucun vrai email n'est envoyé pendant les tests. On utilise Mail::fake() en plus dans les tests de contact, mais la variable d'environnement est un filet de sécurité.

Un compromis assumé : SQLite en mémoire est rapide, mais ce n'est pas MySQL, qui sert en production. Les différences peuvent masquer des bugs : gestion des contraintes de clés étrangères, sensibilité à la casse et collations, fonctions de dates et de JSON, types stricts (ENUM, DECIMAL), index FULLTEXT, certaines opérations de modification de colonnes dans les migrations. Pour les requêtes qui reposent sur un comportement propre au moteur, il vaut la peine de faire tourner une partie de la suite (ou toute la suite en intégration continue) contre un vrai MySQL du même type que la production.

Les factories : l'investissement le plus rentable

Les factories avec états (states) simplifient la création de données de test :

class ArticleFactory extends Factory
{
    public function definition(): array
    {
        return [
            'title' => fake()->sentence(),
            'slug' => fake()->unique()->slug(),
            'content' => fake()->paragraphs(3, true),
            'user_id' => User::factory(),
            'status' => 'draft',
            'views_count' => 0,
        ];
    }

    public function published(): static
    {
        return $this->state([
            'status' => 'published',
            'published_at' => now()->subDay(),
        ]);
    }

    public function draft(): static
    {
        return $this->state([
            'status' => 'draft',
            'published_at' => null,
        ]);
    }

    public function archived(): static
    {
        return $this->state([
            'status' => 'archived',
            'archived_at' => now()->subWeek(),
        ]);
    }
}

Article::factory()->published()->create() est lisible, réutilisable, et garantit que le statut et la date restent cohérents. Sans les states, on copie-colle les mêmes attributs dans chaque test, et le jour où published_at devient une condition du scope published(), il faut retoucher tous ces tests. Avec les states, il n'y a qu'un seul endroit à changer.

Ce que les tests ne testent pas (et c'est normal)

Je ne teste pas le rendu CSS, ni les animations, ni le JavaScript côté client. Ces cas demandent Laravel Dusk ou Cypress, et pour un portfolio, le rapport coût/bénéfice n'y est pas.

Ce que je teste :

  • Chaque route publique retourne le bon code HTTP
  • Chaque modèle a ses scopes, relations et accesseurs vérifiés
  • Le formulaire de contact résiste au spam (honeypot, emails jetables, rate limit)
  • Les headers de sécurité sont présents
  • Les erreurs ne divulguent pas d'information technique
  • Les actions admin sont protégées par authentification

Ce que je ne teste pas :

  • Le rendu visuel (tests navigateur)
  • Les performances (Debugbar et les DevTools s'en chargent)
  • L'intégration email en conditions réelles (Mail::fake() suffit)

Mon conseil après des centaines de tests écrits

Si tu lances un projet et que tu hésites à écrire des tests, commence par les tests fonctionnels des routes critiques. Sur un portfolio, ce sont le formulaire de contact et le blog. Cinq tests bien choisis sur le contact (envoi réussi, validation échoue, honeypot fonctionne, rate limit déclenché, email jetable bloqué) t'apporteront souvent plus de confiance qu'une cinquantaine de tests unitaires sur des accesseurs.

Ensuite, la règle que je suis : chaque bug corrigé en production doit avoir un test de régression. Si le bug a pu arriver une fois, il peut arriver deux fois, et le test empêche la deuxième.

Dernier point, rarement mentionné : les tests servent aussi de documentation. Quand je reviens sur mon code trois mois plus tard et que je me demande « qu'est-ce qui se passe si un bot remplit le honeypot ? », la réponse est dans test_honeypot_blocks_spam(). C'est plus fiable qu'un commentaire dans le code, parce que le test, lui, est exécuté à chaque push.

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.