Aller au contenu

Tests en Perl

L'écosystème de test Perl repose sur le format TAP (Test Anything Protocol), un protocole texte simple qui permet l'interopérabilité entre frameworks et runners. Test::More et Test2::V0 sont les frameworks les plus utilisés, tandis que prove orchestre l'exécution et Devel::Cover mesure la couverture de code.


Comparatif des outils de test

Outil Rôle Points forts Usage typique
Test::More Framework de base Ubiquite, compatibilité universelle, docs abondantes Tests unitaires classiques
Test2::V0 Framework moderne Meilleure gestion des erreurs, API claire Nouveaux projets
Test::Harness Runner TAP Aggregation des résultats, rapport détaillé CI/CD, suites de tests
prove CLI de test Parallèle, couleurs, .proverc configurable Ligne de commande locale
Test::Mojo Test d'applications Mojo Requêtes HTTP simulees, assertions JSON/HTML Tests d'intégration Mojolicious
Test::MockModule Mocking de modules Remplacement temporaire de sous-routines Isolation des dépendances
Devel::Cover Couverture de code Rapports HTML, line/branch/condition coverage Mesure de qualité

Configuration prove et .proverc

# Installation des outils de test
cpanm Test::More Test2::V0 Test::Mojo Test::MockModule Devel::Cover
# .proverc — configuration du runner prove
# Place a la racine du projet

# Executer en parallele sur 4 workers
--jobs 4

# Affichage verbeux
--verbose

# Recursion dans le dossier t/
--recurse

# Format TAP colore
--color

# Dossiers de bibliotheques locales
--lib
--blib
# Lancement des tests
prove t/                          # Tous les tests dans t/
prove t/unit/ t/integration/      # Dossiers specifiques
prove -v t/api.t                  # Un seul fichier en mode verbeux
prove -j4 t/                      # Parallele (4 workers)
prove --timer t/                  # Affiche la duree de chaque test

Tests unitaires avec Test::More

Test::More est le module de test standard, disponible dans le core Perl depuis la version 5.8.

#!/usr/bin/perl
# t/unit/item_model.t — Tests unitaires du modele Item
use strict;
use warnings;
use Test::More;
use DBI;

# Chargement du module a tester
use lib 'lib';
use MojoCRUD::Model::Item;

# --- Setup : base de donnees en memoire ---
my $dbh = DBI->connect(
    'dbi:SQLite:dbname=:memory:',
    undef, undef,
    { RaiseError => 1, AutoCommit => 1 }
);

my $model = MojoCRUD::Model::Item->new($dbh);

# --- Tests de creation ---
subtest 'creation d\'un item' => sub {
    my $id = $model->creer({ nom => 'Widget', description => 'Un widget', quantite => 5 });

    ok(defined $id, 'creer() retourne un id');
    ok($id > 0,     'l\'id est positif');

    my $item = $model->par_id($id);
    is($item->{nom},      'Widget',   'nom correct');
    is($item->{quantite}, 5,          'quantite correcte');
    is($item->{description}, 'Un widget', 'description correcte');
};

# --- Tests de liste ---
subtest 'liste des items' => sub {
    # Ajout de deux items supplementaires
    $model->creer({ nom => 'Gadget', quantite => 10 });
    $model->creer({ nom => 'Truc',   quantite => 3  });

    my $items = $model->liste;
    ok(ref($items) eq 'ARRAY', 'liste() retourne un arrayref');
    cmp_ok(scalar(@$items), '>=', 3, 'au moins 3 items dans la liste');
};

# --- Tests de mise a jour ---
subtest 'mise a jour d\'un item' => sub {
    my $id = $model->creer({ nom => 'Original', quantite => 1 });

    my $lignes = $model->mettre_a_jour($id, {
        nom      => 'Modifie',
        quantite => 99,
        description => 'Nouveau',
    });

    is($lignes, 1, 'une ligne mise a jour');

    my $item = $model->par_id($id);
    is($item->{nom},      'Modifie', 'nom mis a jour');
    is($item->{quantite}, 99,        'quantite mise a jour');
};

# --- Tests de suppression ---
subtest 'suppression d\'un item' => sub {
    my $id = $model->creer({ nom => 'A supprimer', quantite => 0 });

    my $lignes = $model->supprimer($id);
    is($lignes, 1, 'une ligne supprimee');

    my $item = $model->par_id($id);
    ok(!defined $item, 'item introuvable apres suppression');
};

# --- Tests des cas limites ---
subtest 'cas limites' => sub {
    my $item = $model->par_id(99999);
    ok(!defined $item, 'par_id() retourne undef pour un id inexistant');

    my $lignes = $model->supprimer(99999);
    is($lignes, 0, 'supprimer() retourne 0 pour un id inexistant');
};

done_testing;

Tests d'intégration avec Test::Mojo

Test::Mojo permet de tester une application Mojolicious sans serveur HTTP réel, en envoyant des requêtes directement à l'application PSGI.

#!/usr/bin/perl
# t/integration/api.t — Tests de l'API REST Mojolicious
use strict;
use warnings;
use Test::More;
use Test::Mojo;

# Charge l'application en mode test
use lib 'lib';
$ENV{MOJO_MODE} = 'test';

# Utilise une base SQLite en memoire pour les tests
$ENV{DATABASE_URL} = 'dbi:SQLite:dbname=:memory:';

my $t = Test::Mojo->new('app.pl');  # ou le nom de la classe app

# --- Test GET /api/items (liste vide) ---
$t->get_ok('/api/items')
  ->status_is(200)
  ->json_is([]);  # tableau vide attendu

# --- Test POST /api/items ---
$t->post_ok('/api/items' => json => {
    nom      => 'Clavier',
    quantite => 10,
})
->status_is(201)
->json_has('/id')
->json_is('/nom' => 'Clavier')
->json_is('/quantite' => 10);

# Recuperation de l'id cree
my $id = $t->tx->res->json('/id');
ok(defined $id && $id > 0, "id cree : $id");

# --- Test GET /api/items/:id ---
$t->get_ok("/api/items/$id")
  ->status_is(200)
  ->json_is('/nom' => 'Clavier');

# --- Test PUT /api/items/:id ---
$t->put_ok("/api/items/$id" => json => {
    nom      => 'Clavier Mecanique',
    quantite => 5,
})
->status_is(200)
->json_is('/nom' => 'Clavier Mecanique')
->json_is('/quantite' => 5);

# --- Test DELETE /api/items/:id ---
$t->delete_ok("/api/items/$id")
  ->status_is(204);

# Verification que l'item n'existe plus
$t->get_ok("/api/items/$id")
  ->status_is(404)
  ->json_has('/erreur');

# --- Test de validation ---
$t->post_ok('/api/items' => json => { quantite => 5 })  # nom manquant
  ->status_is(400)
  ->json_has('/erreurs');

done_testing;

Mode test Mojolicious

Definissez $ENV{MOJO_MODE} = 'test' avant de créer l'objet Test::Mojo. Mojolicious charge alors le fichier de configuration app.test.conf s'il existe, permettant de séparer la configuration de test de la production.


Mocking avec Test::MockModule

Test::MockModule permet de remplacer temporairement des sous-routines dans un module, sans modifier le code de production.

#!/usr/bin/perl
# t/unit/service_email.t — Mocking d'un service externe
use strict;
use warnings;
use Test::More;
use Test::MockModule;

use lib 'lib';
use MonApp::Service::Email;
use MonApp::Service::Notification;

# --- Mocking du service email ---
subtest 'notification envoyee par email en cas de succes' => sub {
    # On intercepte MonApp::Service::Email::envoyer
    my $mock_email = Test::MockModule->new('MonApp::Service::Email');
    my @emails_envoyes;

    $mock_email->mock('envoyer', sub {
        my ($self, %args) = @_;
        push @emails_envoyes, \%args;
        return 1;  # Succes simule
    });

    my $notif = MonApp::Service::Notification->new;
    $notif->notifier(
        destinataire => 'alice@example.com',
        sujet        => 'Test',
        message      => 'Bonjour',
    );

    is(scalar @emails_envoyes, 1, 'un email envoye');
    is($emails_envoyes[0]{destinataire}, 'alice@example.com', 'bon destinataire');

    # Le mock est restaure automatiquement en sortie de scope
};

# --- Mocking d'une erreur reseau ---
subtest 'retry en cas d\'erreur reseau' => sub {
    my $mock_email = Test::MockModule->new('MonApp::Service::Email');
    my $tentatives = 0;

    # Premier appel echoue, second reussit
    $mock_email->mock('envoyer', sub {
        $tentatives++;
        die "Connexion refusee\n" if $tentatives == 1;
        return 1;
    });

    my $notif = MonApp::Service::Notification->new;
    my $ok = eval { $notif->notifier_avec_retry(
        destinataire => 'bob@example.com',
        sujet        => 'Retry test',
        message      => 'Hello',
        max_tentatives => 3,
    )};

    is($tentatives, 2,  'deux tentatives effectuees');
    ok($ok,            'succes apres retry');
};

done_testing;

Structure TAP

TAP (Test Anything Protocol) est le format de sortie standard de tous les modules de test Perl. Comprendre TAP permet de lire les sorties brutes et d'intégrer n'importe quel outil.

# Sortie TAP typique d'un fichier de test
TAP version 14
1..5
ok 1 - connexion a la base de donnees
ok 2 - creation d'un item
ok 3 - lecture par id
not ok 4 - mise a jour - quantite
# Failed test 'mise a jour - quantite'
# at t/item_model.t line 67.
# got: '10'
# expected: '99'
ok 5 - suppression

# Tests run: 5
# Tests passed: 4
# Tests failed: 1
# Test2::V0 — alternative moderne avec meilleure gestion des erreurs
#!/usr/bin/perl
use strict;
use warnings;
use Test2::V0;

use lib 'lib';
use MojoCRUD::Model::Item;
use DBI;

my $dbh = DBI->connect('dbi:SQLite:dbname=:memory:', undef, undef,
    { RaiseError => 1, AutoCommit => 1 });

my $model = MojoCRUD::Model::Item->new($dbh);

# Test2::V0 utilise is() pour les comparaisons profondes
my $id = $model->creer({ nom => 'Test', quantite => 1 });
my $item = $model->par_id($id);

# Comparaison profonde de structures
is($item, hash {
    field nom      => 'Test';
    field quantite => 1;
    etc();           # Ignore les champs supplementaires
}, 'item cree correctement');

# Verification qu'une exception est levee
like(
    dies { $model->creer({}) },  # nom manquant -> erreur SQL
    qr/NOT NULL constraint/,
    'erreur si nom absent'
);

done_testing;

Couverture avec Devel::Cover

# Installation
cpanm Devel::Cover

# Lancement des tests avec instrumentation de couverture
cover -test

# Ou manuellement
PERL5OPT="-MDevel::Cover" prove t/
cover                          # Genere le rapport HTML dans cover_db/coverage.html

# Rapport dans le terminal
cover -report text

# Nettoyage de la base de couverture
cover -delete
# .coveragerc equivalent Perl : Makefile.PL ou fichier de config cover
# Options courantes lors de l'appel a cover

# Ignorer les fichiers de tests eux-memes
cover -ignore_re '^t/'

# Seuil minimum (echoue si inferieur)
cover -threshold 75

Couverture et code mort

Une couverture a 100% ne garantit pas l'absence de bugs. Privilegiez une couverture de branches (branch coverage) plutôt que simplement de lignes (statement coverage). Devel::Cover rapporte les deux avec le drapeau -coverage branch.


Intégration CI/CD

# .github/workflows/test.yml — Pipeline GitHub Actions
name: Tests Perl

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        perl: ['5.36', '5.38', '5.40']

    steps:
      - uses: actions/checkout@v4

      - name: Installer Perl ${{ matrix.perl }}
        uses: shogo82148/actions-setup-perl@v1
        with:
          perl-version: ${{ matrix.perl }}

      - name: Installer les dependances
        run: |
          cpanm --notest --installdeps .
          cpanm --notest Test::More Test::Mojo Test::MockModule Devel::Cover

      - name: Lancer les tests
        run: prove -r t/

      - name: Rapport de couverture
        run: |
          cover -test -report text
          cover -threshold 70