Patrones de Prueba en Perl
Estrategias integrales de prueba para aplicaciones Perl usando Test2::V0, Test::More, prove y metodología TDD.
Cuándo activarlo
- Al escribir nuevo código Perl (seguir TDD: rojo, verde, refactorizar)
- Al diseñar suites de prueba para módulos o aplicaciones Perl
- Al revisar la cobertura de pruebas Perl
- Al configurar la infraestructura de pruebas Perl
- Al migrar pruebas de Test::More a Test2::V0
- Al depurar pruebas Perl que fallan
Flujo de trabajo TDD
Sigue siempre el ciclo ROJO-VERDE-REFACTORIZAR.
# Paso 1: ROJO — Escribe una prueba que falle
# t/unit/calculator.t
use v5.36;
use Test2::V0;
use lib 'lib';
use Calculator;
subtest 'suma' => sub {
my $calc = Calculator->new;
is($calc->add(2, 3), 5, 'suma dos números');
is($calc->add(-1, 1), 0, 'maneja negativos');
};
done_testing;
# Paso 2: VERDE — Escribe la implementación mínima
# lib/Calculator.pm
package Calculator;
use v5.36;
use Moo;
sub add($self, $a, $b) {
return $a + $b;
}
1;
# Paso 3: REFACTORIZAR — Mejora mientras las pruebas sigan en verde
# Ejecuta: prove -lv t/unit/calculator.t
Fundamentos de Test::More
El módulo de prueba estándar de Perl — ampliamente usado, incluido en el núcleo.
Afirmaciones Básicas
use v5.36;
use Test::More;
# Planificar de antemano o usar done_testing
# plan tests => 5; # Plan fijo (opcional)
# Igualdad
is($result, 42, 'devuelve el valor correcto');
isnt($result, 0, 'no es cero');
# Booleano
ok($user->is_active, 'el usuario está activo');
ok(!$user->is_banned, 'el usuario no está baneado');
# Comparación profunda
is_deeply(
$got,
{ name => 'Alice', roles => ['admin'] },
'devuelve la estructura esperada'
);
# Coincidencia de patrones
like($error, qr/not found/i, 'el error menciona "not found"');
unlike($output, qr/password/, 'la salida oculta la contraseña');
# Verificación de tipo
isa_ok($obj, 'MyApp::User');
can_ok($obj, 'save', 'delete');
done_testing;
SKIP y TODO
use v5.36;
use Test::More;
# Saltar pruebas condicionalmente
SKIP: {
skip 'No hay base de datos configurada', 2 unless $ENV{TEST_DB};
my $db = connect_db();
ok($db->ping, 'la base de datos es accesible');
is($db->version, '15', 'versión correcta de PostgreSQL');
}
# Marcar fallos esperados
TODO: {
local $TODO = 'Caché aún no implementada';
is($cache->get('key'), 'value', 'la caché devuelve el valor');
}
done_testing;
Marco Moderno Test2::V0
Test2::V0 es el reemplazo moderno de Test::More — afirmaciones más ricas, mejor diagnóstico y extensible.
¿Por qué Test2?
- Comparación profunda superior con constructores de hash/array
- Mejor salida de diagnóstico en fallos
- Subpruebas con ámbito más limpio
- Extensible mediante plugins Test2::Tools::*
- Compatible hacia atrás con pruebas de Test::More
Comparación Profunda con Constructores
use v5.36;
use Test2::V0;
# Constructor de hash — verificar estructura parcial
is(
$user->to_hash,
hash {
field name => 'Alice';
field email => match(qr/\@example\.com$/);
field age => validator(sub { $_ >= 18 });
# Ignorar otros campos
etc();
},
'el usuario tiene los campos esperados'
);
# Constructor de array
is(
$result,
array {
item 'first';
item match(qr/^second/);
item DNE(); # Does Not Exist — verificar que no hay elementos extra
},
'el resultado coincide con la lista esperada'
);
# Bag — comparación independiente del orden
is(
$tags,
bag {
item 'perl';
item 'testing';
item 'tdd';
},
'tiene todos los tags requeridos sin importar el orden'
);
Subpruebas
use v5.36;
use Test2::V0;
subtest 'Creación de usuario' => sub {
my $user = User->new(name => 'Alice', email => '[email protected]');
ok($user, 'objeto usuario creado');
is($user->name, 'Alice', 'el nombre está establecido');
is($user->email, '[email protected]', 'el email está establecido');
};
subtest 'Validación de usuario' => sub {
my $warnings = warns {
User->new(name => '', email => 'bad');
};
ok($warnings, 'advierte sobre datos inválidos');
};
done_testing;
Pruebas de Excepciones con Test2
use v5.36;
use Test2::V0;
# Probar que el código lanza una excepción
like(
dies { divide(10, 0) },
qr/División por cero/,
'lanza excepción al dividir por cero'
);
# Probar que el código no lanza excepción
ok(lives { divide(10, 2) }, 'la división tiene éxito') or note($@);
# Patrón combinado
subtest 'manejo de errores' => sub {
ok(lives { parse_config('valid.json') }, 'configuración válida se parsea');
like(
dies { parse_config('missing.json') },
qr/No se puede abrir/,
'archivo faltante lanza excepción con mensaje'
);
};
done_testing;
Organización de Pruebas y prove
Estructura de Directorios
t/
├── 00-load.t # Verificar que los módulos compilan
├── 01-basic.t # Funcionalidad central
├── unit/
│ ├── config.t # Pruebas unitarias por módulo
│ ├── user.t
│ └── util.t
├── integration/
│ ├── database.t
│ └── api.t
├── lib/
│ └── TestHelper.pm # Utilidades de prueba compartidas
└── fixtures/
├── config.json # Archivos de datos de prueba
└── users.csv
Comandos de prove
# Ejecutar todas las pruebas
prove -l t/
# Salida verbosa
prove -lv t/
# Ejecutar una prueba específica
prove -lv t/unit/user.t
# Búsqueda recursiva
prove -lr t/
# Ejecución paralela (8 trabajos)
prove -lr -j8 t/
# Ejecutar solo pruebas que fallaron en la última ejecución
prove -l --state=failed t/
# Salida coloreada con temporizador
prove -l --color --timer t/
# Salida TAP para CI
prove -l --formatter TAP::Formatter::JUnit t/ > results.xml
Configuración .proverc
-l
--color
--timer
-r
-j4
--state=save
Fixtures y Configuración/Limpieza
Aislamiento de Subpruebas
use v5.36;
use Test2::V0;
use File::Temp qw(tempdir);
use Path::Tiny;
subtest 'procesamiento de archivos' => sub {
# Configuración
my $dir = tempdir(CLEANUP => 1);
my $file = path($dir, 'input.txt');
$file->spew_utf8("linea1\nlinea2\nlinea3\n");
# Prueba
my $result = process_file("$file");
is($result->{line_count}, 3, 'cuenta líneas');
# La limpieza ocurre automáticamente (CLEANUP => 1)
};
Ayudantes de Prueba Compartidos
Coloca ayudantes reutilizables en t/lib/TestHelper.pm y cárgalos con use lib 't/lib'. Exporta funciones de fábrica como create_test_db(), create_temp_dir() y fixture_path() mediante Exporter.
Mocking
Test::MockModule
use v5.36;
use Test2::V0;
use Test::MockModule;
subtest 'simular API externa' => sub {
my $mock = Test::MockModule->new('MyApp::API');
# Bien: Mock devuelve datos controlados
$mock->mock(fetch_user => sub ($self, $id) {
return { id => $id, name => 'Usuario Simulado', email => '[email protected]' };
});
my $api = MyApp::API->new;
my $user = $api->fetch_user(42);
is($user->{name}, 'Usuario Simulado', 'devuelve usuario simulado');
# Verificar número de llamadas
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 fue llamado dos veces');
# El mock se restaura automáticamente cuando $mock sale del ámbito
};
# Mal: Monkey-patching sin restauración
# *MyApp::API::fetch_user = sub { ... }; # NUNCA — se filtra entre pruebas
Para objetos mock ligeros, usa Test::MockObject para crear dobles de prueba inyectables con ->mock() y verifica llamadas con ->called_ok().
Cobertura con Devel::Cover
Ejecutar Cobertura
# Informe básico de cobertura
cover -test
# O paso a paso
perl -MDevel::Cover -Ilib t/unit/user.t
cover
# Informe HTML
cover -report html
open cover_db/coverage.html
# Umbrales específicos
cover -test -report text | grep 'Total'
# Amigable para CI: fallar por debajo del umbral
cover -test && cover -report text -select '^lib/' \
| perl -ne 'if (/Total.*?(\d+\.\d+)/) { exit 1 if $1 < 80 }'
Pruebas de Integración
Usa SQLite en memoria para pruebas de base de datos, simula HTTP::Tiny para pruebas de API.
use v5.36;
use Test2::V0;
use DBI;
subtest 'integración con base de datos' => sub {
my $dbh = DBI->connect('dbi:SQLite:dbname=:memory:', '', '', {
RaiseError => 1,
});
$dbh->do('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)');
$dbh->prepare('INSERT INTO users (name) VALUES (?)')->execute('Alice');
my $row = $dbh->selectrow_hashref('SELECT * FROM users WHERE name = ?', undef, 'Alice');
is($row->{name}, 'Alice', 'usuario insertado y recuperado');
};
done_testing;
Buenas Prácticas
HACER
- Seguir TDD: Escribir pruebas antes de la implementación (rojo-verde-refactorizar)
- Usar Test2::V0: Afirmaciones modernas, mejor diagnóstico
- Usar subpruebas: Agrupar afirmaciones relacionadas, aislar estado
- Simular dependencias externas: Red, base de datos, sistema de archivos
- Usar
prove -l: Incluir siempre lib/ en@INC - Nombrar pruebas claramente:
'el inicio de sesión con contraseña inválida falla' - Probar casos límite: Cadenas vacías, undef, cero, valores frontera
- Apuntar a 80%+ de cobertura: Enfocarse en rutas de lógica de negocio
- Mantener pruebas rápidas: Simular E/S, usar bases de datos en memoria
NO HACER
- No probar la implementación: Probar comportamiento y resultado, no internas
- No compartir estado entre subpruebas: Cada subprueba debe ser independiente
- No omitir
done_testing: Asegura que todas las pruebas planificadas se ejecutaron - No sobremoquear: Simular solo los límites, no el código bajo prueba
- No usar
Test::Morepara proyectos nuevos: Preferir Test2::V0 - No ignorar fallos en pruebas: Todas las pruebas deben pasar antes de fusionar
- No probar módulos de CPAN: Confiar en que las bibliotecas funcionan correctamente
- No escribir pruebas frágiles: Evitar coincidencias de cadenas demasiado específicas
Referencia Rápida
| Tarea | Comando / Patrón |
|---|---|
| Ejecutar todas las pruebas | prove -lr t/ |
| Ejecutar una prueba con verbosidad | prove -lv t/unit/user.t |
| Ejecución paralela de pruebas | prove -lr -j8 t/ |
| Informe de cobertura | cover -test && cover -report html |
| Probar igualdad | is($got, $expected, 'etiqueta') |
| Comparación profunda | is($got, hash { field k => 'v'; etc() }, 'etiqueta') |
| Probar excepción | like(dies { ... }, qr/msg/, 'etiqueta') |
| Probar que no hay excepción | ok(lives { ... }, 'etiqueta') |
| Simular un método | Test::MockModule->new('Pkg')->mock(m => sub { ... }) |
| Saltar pruebas | SKIP: { skip 'razón', $count unless $cond; ... } |
| Pruebas TODO | TODO: { local $TODO = 'razón'; ... } |
Errores Comunes
Olvidar done_testing
# Mal: El archivo de prueba se ejecuta pero no verifica que todas las pruebas se ejecutaron
use Test2::V0;
is(1, 1, 'funciona');
# Falta done_testing — errores silenciosos si el código de prueba se salta
# Bien: Siempre terminar con done_testing
use Test2::V0;
is(1, 1, 'funciona');
done_testing;
Falta la Bandera -l
# Mal: Los módulos en lib/ no se encuentran
prove t/unit/user.t
# Can't locate MyApp/User.pm in @INC
# Bien: Incluir lib/ en @INC
prove -l t/unit/user.t
Sobremoquear
Simula la dependencia, no el código bajo prueba. Si tu prueba solo verifica que un mock devuelve lo que le indicaste, no prueba nada.
Contaminación de Pruebas
Usa variables my dentro de las subpruebas — nunca our — para evitar que el estado se filtre entre pruebas.
Recuerda: Las pruebas son tu red de seguridad. Mantenlas rápidas, enfocadas e independientes. Usa Test2::V0 para proyectos nuevos, prove para ejecutarlas y Devel::Cover para la responsabilidad.