Modèles de test Perl
Stratégies complètes de test pour les applications Perl utilisant Test2::V0, Test::More, prove et la méthodologie TDD.
Quand activer
- Écrire du nouveau code Perl (suivre TDD : rouge, vert, refactor)
- Concevoir des suites de test pour des modules ou applications Perl
- Réviser la couverture de test Perl
- Mettre en place l'infrastructure de test Perl
- Migrer des tests de Test::More vers Test2::V0
- Déboguer des tests Perl qui échouent
Workflow TDD
Suivez toujours le cycle ROUGE-VERT-REFACTOR.
# Étape 1 : ROUGE — Écrire un test qui échoue
# t/unit/calculator.t
use v5.36;
use Test2::V0;
use lib 'lib';
use Calculator;
subtest 'addition' => sub {
my $calc = Calculator->new;
is($calc->add(2, 3), 5, 'additionne deux nombres');
is($calc->add(-1, 1), 0, 'gère les négatifs');
};
done_testing;
# Étape 2 : VERT — Écrire l'implémentation minimale
# lib/Calculator.pm
package Calculator;
use v5.36;
use Moo;
sub add($self, $a, $b) {
return $a + $b;
}
1;
# Étape 3 : REFACTOR — Améliorer tout en gardant les tests au vert
# Exécuter : prove -lv t/unit/calculator.t
Fondamentaux de Test::More
Le module de test Perl standard — largement utilisé, inclus dans le cœur.
Assertions de base
use v5.36;
use Test::More;
# Planifier à l'avance ou utiliser done_testing
# plan tests => 5; # Plan fixe (optionnel)
# Égalité
is($result, 42, 'retourne la bonne valeur');
isnt($result, 0, 'pas zéro');
# Booléen
ok($user->is_active, 'l\'utilisateur est actif');
ok(!$user->is_banned, 'l\'utilisateur n\'est pas banni');
# Comparaison profonde
is_deeply(
$got,
{ name => 'Alice', roles => ['admin'] },
'retourne la structure attendue'
);
# Correspondance de motif
like($error, qr/not found/i, 'l\'erreur mentionne "not found"');
unlike($output, qr/password/, 'la sortie cache le mot de passe');
# Vérification de type
isa_ok($obj, 'MyApp::User');
can_ok($obj, 'save', 'delete');
done_testing;
SKIP et TODO
use v5.36;
use Test::More;
# Sauter des tests conditionnellement
SKIP: {
skip 'Aucune base de données configurée', 2 unless $ENV{TEST_DB};
my $db = connect_db();
ok($db->ping, 'la base de données est accessible');
is($db->version, '15', 'version PostgreSQL correcte');
}
# Marquer des échecs attendus
TODO: {
local $TODO = 'Cache non encore implémenté';
is($cache->get('clé'), 'valeur', 'le cache retourne la valeur');
}
done_testing;
Framework moderne Test2::V0
Test2::V0 est le remplacement moderne de Test::More — assertions plus riches, meilleurs diagnostics et extensible.
Pourquoi Test2 ?
- Comparaison profonde supérieure avec les constructeurs hash/array
- Meilleure sortie de diagnostic en cas d'échec
- Sous-tests avec une portée plus propre
- Extensible via les plugins Test2::Tools::*
- Rétrocompatible avec les tests Test::More
Comparaison profonde avec les constructeurs
use v5.36;
use Test2::V0;
# Constructeur hash — vérifier une structure partielle
is(
$user->to_hash,
hash {
field name => 'Alice';
field email => match(qr/\@example\.com$/);
field age => validator(sub { $_ >= 18 });
# Ignorer les autres champs
etc();
},
'l\'utilisateur a les champs attendus'
);
# Constructeur array
is(
$result,
array {
item 'premier';
item match(qr/^second/);
item DNE(); # N'existe pas — vérifier qu'il n'y a pas d'éléments supplémentaires
},
'le résultat correspond à la liste attendue'
);
# Bag — comparaison indépendante de l'ordre
is(
$tags,
bag {
item 'perl';
item 'testing';
item 'tdd';
},
'a toutes les balises requises, quel que soit l\'ordre'
);
Sous-tests
use v5.36;
use Test2::V0;
subtest 'Création d\'utilisateur' => sub {
my $user = User->new(name => 'Alice', email => '[email protected]');
ok($user, 'objet utilisateur créé');
is($user->name, 'Alice', 'le nom est défini');
is($user->email, '[email protected]', 'l\'email est défini');
};
subtest 'Validation d\'utilisateur' => sub {
my $warnings = warns {
User->new(name => '', email => 'mauvais');
};
ok($warnings, 'avertit en cas de données invalides');
};
done_testing;
Test d'exceptions avec Test2
use v5.36;
use Test2::V0;
# Tester que le code meurt
like(
dies { divide(10, 0) },
qr/Division par zéro/,
'meurt sur division par zéro'
);
# Tester que le code vit
ok(lives { divide(10, 2) }, 'la division réussit') or note($@);
# Motif combiné
subtest 'gestion des erreurs' => sub {
ok(lives { parse_config('valide.json') }, 'la config valide est parsée');
like(
dies { parse_config('manquant.json') },
qr/Impossible d\'ouvrir/,
'le fichier manquant meurt avec un message'
);
};
done_testing;
Organisation des tests et prove
Structure des répertoires
t/
├── 00-load.t # Vérifier que les modules compilent
├── 01-basic.t # Fonctionnalité de base
├── unit/
│ ├── config.t # Tests unitaires par module
│ ├── user.t
│ └── util.t
├── integration/
│ ├── database.t
│ └── api.t
├── lib/
│ └── TestHelper.pm # Utilitaires de test partagés
└── fixtures/
├── config.json # Fichiers de données de test
└── users.csv
Commandes prove
# Exécuter tous les tests
prove -l t/
# Sortie verbeuse
prove -lv t/
# Exécuter un test spécifique
prove -lv t/unit/user.t
# Recherche récursive
prove -lr t/
# Exécution parallèle (8 jobs)
prove -lr -j8 t/
# Exécuter uniquement les tests ayant échoué lors de la dernière exécution
prove -l --state=failed t/
# Sortie colorée avec timer
prove -l --color --timer t/
# Sortie TAP pour CI
prove -l --formatter TAP::Formatter::JUnit t/ > results.xml
Configuration .proverc
-l
--color
--timer
-r
-j4
--state=save
Fixtures et Setup/Teardown
Isolation des sous-tests
use v5.36;
use Test2::V0;
use File::Temp qw(tempdir);
use Path::Tiny;
subtest 'traitement de fichier' => sub {
# Setup
my $dir = tempdir(CLEANUP => 1);
my $file = path($dir, 'entree.txt');
$file->spew_utf8("ligne1\nligne2\nligne3\n");
# Test
my $result = process_file("$file");
is($result->{line_count}, 3, 'compte les lignes');
# Teardown se produit automatiquement (CLEANUP => 1)
};
Helpers de test partagés
Placez les helpers réutilisables dans t/lib/TestHelper.pm et chargez-les avec use lib 't/lib'. Exportez des fonctions factory comme create_test_db(), create_temp_dir() et fixture_path() via Exporter.
Mocking
Test::MockModule
use v5.36;
use Test2::V0;
use Test::MockModule;
subtest 'mocker l\'API externe' => sub {
my $mock = Test::MockModule->new('MyApp::API');
# Bien : Mock retourne des données contrôlées
$mock->mock(fetch_user => sub ($self, $id) {
return { id => $id, name => 'Utilisateur Mock', email => '[email protected]' };
});
my $api = MyApp::API->new;
my $user = $api->fetch_user(42);
is($user->{name}, 'Utilisateur Mock', 'retourne l\'utilisateur mocké');
# Vérifier le nombre d'appels
my $call_count = 0;
$mock->mock(fetch_user => sub { $call_count++; return {} });
$api->fetch_user(1);
$api->fetch_user(2);
is($call_count, 2, 'fetch_user appelé deux fois');
# Le mock est automatiquement restauré quand $mock sort de portée
};
# Mauvais : Monkey-patching sans restauration
# *MyApp::API::fetch_user = sub { ... }; # JAMAIS — pollue les tests
Pour des objets mock légers, utilisez Test::MockObject pour créer des doubles de test injectables avec ->mock() et vérifier les appels avec ->called_ok().
Couverture avec Devel::Cover
Exécution de la couverture
# Rapport de couverture de base
cover -test
# Ou étape par étape
perl -MDevel::Cover -Ilib t/unit/user.t
cover
# Rapport HTML
cover -report html
open cover_db/coverage.html
# Seuils spécifiques
cover -test -report text | grep 'Total'
# Adapté au CI : échouer sous le seuil
cover -test && cover -report text -select '^lib/' \
| perl -ne 'if (/Total.*?(\d+\.\d+)/) { exit 1 if $1 < 80 }'
Tests d'intégration
Utilisez SQLite en mémoire pour les tests de base de données, mockez HTTP::Tiny pour les tests d'API.
use v5.36;
use Test2::V0;
use DBI;
subtest 'intégration base de données' => sub {
my $dbh = DBI->connect('dbi:SQLite:dbname=:memory:', '', '', {
RaiseError => 1,
});
$dbh->do('CREATE TABLE utilisateurs (id INTEGER PRIMARY KEY, nom TEXT)');
$dbh->prepare('INSERT INTO utilisateurs (nom) VALUES (?)')->execute('Alice');
my $row = $dbh->selectrow_hashref('SELECT * FROM utilisateurs WHERE nom = ?', undef, 'Alice');
is($row->{nom}, 'Alice', 'utilisateur inséré et récupéré');
};
done_testing;
Bonnes pratiques
À FAIRE
- Suivre TDD : Écrire les tests avant l'implémentation (rouge-vert-refactor)
- Utiliser Test2::V0 : Assertions modernes, meilleurs diagnostics
- Utiliser les sous-tests : Grouper les assertions liées, isoler l'état
- Mocker les dépendances externes : Réseau, base de données, système de fichiers
- Utiliser
prove -l: Toujours inclure lib/ dans@INC - Nommer les tests clairement :
'la connexion utilisateur avec mot de passe invalide échoue' - Tester les cas limites : Chaînes vides, undef, zéro, valeurs aux limites
- Viser une couverture de 80%+ : Se concentrer sur les chemins métier
- Garder les tests rapides : Mocker les E/S, utiliser des bases de données en mémoire
À NE PAS FAIRE
- Ne pas tester l'implémentation : Tester le comportement et la sortie, pas les détails internes
- Ne pas partager l'état entre sous-tests : Chaque sous-test doit être indépendant
- Ne pas sauter
done_testing: Garantit que tous les tests planifiés ont été exécutés - Ne pas surmocker : Mocker uniquement les frontières, pas le code testé
- Ne pas utiliser
Test::Morepour les nouveaux projets : Préférer Test2::V0 - Ne pas ignorer les échecs de test : Tous les tests doivent passer avant la fusion
- Ne pas tester les modules CPAN : Faire confiance aux bibliothèques pour fonctionner correctement
- Ne pas écrire de tests fragiles : Éviter les correspondances de chaînes trop spécifiques
Référence rapide
| Tâche | Commande / Modèle |
|---|---|
| Exécuter tous les tests | prove -lr t/ |
| Exécuter un test en verbeux | prove -lv t/unit/user.t |
| Exécution parallèle | prove -lr -j8 t/ |
| Rapport de couverture | cover -test && cover -report html |
| Tester l'égalité | is($got, $expected, 'label') |
| Comparaison profonde | is($got, hash { field k => 'v'; etc() }, 'label') |
| Tester une exception | like(dies { ... }, qr/msg/, 'label') |
| Tester pas d'exception | ok(lives { ... }, 'label') |
| Mocker une méthode | Test::MockModule->new('Pkg')->mock(m => sub { ... }) |
| Sauter des tests | SKIP: { skip 'reason', $count unless $cond; ... } |
| Tests TODO | TODO: { local $TODO = 'reason'; ... } |
Pièges courants
Oublier done_testing
# Mauvais : Le fichier de test s'exécute mais ne vérifie pas que tous les tests ont été exécutés
use Test2::V0;
is(1, 1, 'fonctionne');
# done_testing manquant — bugs silencieux si le code de test est sauté
# Bon : Toujours terminer par done_testing
use Test2::V0;
is(1, 1, 'fonctionne');
done_testing;
Drapeau -l manquant
# Mauvais : Modules dans lib/ non trouvés
prove t/unit/user.t
# Impossible de localiser MyApp/User.pm dans @INC
# Bon : Inclure lib/ dans @INC
prove -l t/unit/user.t
Surmocking
Mockez la dépendance, pas le code testé. Si votre test vérifie seulement qu'un mock retourne ce que vous lui avez dit, il ne teste rien.
Pollution des tests
Utilisez des variables my à l'intérieur des sous-tests — jamais our — pour éviter que l'état ne fuite entre les tests.
Souvenez-vous : Les tests sont votre filet de sécurité. Gardez-les rapides, ciblés et indépendants. Utilisez Test2::V0 pour les nouveaux projets, prove pour l'exécution et Devel::Cover pour la responsabilité.