🔄 Ciclo TDD — Red · Green · Refactor
1
RED — Escribe el test
Define el comportamiento esperado antes de la implementación. El test falla: no existe el módulo.
2
GREEN — Implementación mínima
Escribe solo el código necesario para pasar el test. Sin optimizaciones prematuras.
3
REFACTOR — Mejora el diseño
Limpia el código mientras los tests siguen en verde. DRY, nombres claros, estructura.
📁 Estructura del Proyecto
CultivaMetrics/
├── lib/
│ └── CultivaMetrics/
│ ├── Calculator.pm # Módulo bajo test
│ ├── LeadScorer.pm # Clasificador de leads
│ └── DB.pm # Capa de datos (mockeada)
├── t/
│ ├── 00-load.t # Carga de módulos
│ ├── unit/
│ │ ├── calculator.t # CTR, CPA, ROAS
│ │ └── lead_scorer.t # Clasificación Hot/Warm/Cold
│ ├── integration/
│ │ └── db_metrics.t # SQLite in-memory
│ ├── lib/
│ │ └── TestHelper.pm # Utilidades compartidas
│ └── fixtures/
│ └── campaigns.json # Datos de test
└── .proverc # Configuración del runner
├── lib/
│ └── CultivaMetrics/
│ ├── Calculator.pm # Módulo bajo test
│ ├── LeadScorer.pm # Clasificador de leads
│ └── DB.pm # Capa de datos (mockeada)
├── t/
│ ├── 00-load.t # Carga de módulos
│ ├── unit/
│ │ ├── calculator.t # CTR, CPA, ROAS
│ │ └── lead_scorer.t # Clasificación Hot/Warm/Cold
│ ├── integration/
│ │ └── db_metrics.t # SQLite in-memory
│ ├── lib/
│ │ └── TestHelper.pm # Utilidades compartidas
│ └── fixtures/
│ └── campaigns.json # Datos de test
└── .proverc # Configuración del runner
📄 .proverc
# Configuración del runner prove -l # Incluir lib/ en @INC --color # Salida con colores --timer # Tiempo por archivo de test -r # Búsqueda recursiva en t/ -j4 # 4 workers en paralelo --state=save # Guardar estado para --state=failed
💻 Comandos prove
# Ejecutar toda la suite prove -lr t/ # Verbose: un test concreto prove -lv t/unit/calculator.t # Solo tests fallados del run anterior prove -l --state=failed t/ # Cobertura completa cover -test && cover -report html
🔴 Fase RED — Tests antes de la implementación
📄 t/unit/calculator.t — Métricas publicitarias
use v5.36; use Test2::V0; use lib 'lib'; use CultivaMetrics::Calculator; # ── CTR ───────────────────────────────────── subtest 'ctr — click-through rate' => sub { my $calc = CultivaMetrics::Calculator->new; is($calc->ctr(100, 5000), 2.0, 'CTR: 100 clicks / 5000 impressions = 2.0%'); is($calc->ctr(0, 1000), 0.0, 'CTR: sin clicks = 0%'); like( dies { $calc->ctr(100, 0) }, qr/impressions cannot be zero/i, 'CTR: muere con 0 impresiones' ); }; # ── CPA ───────────────────────────────────── subtest 'cpa — cost per acquisition' => sub { my $calc = CultivaMetrics::Calculator->new; is($calc->cpa(500, 25), 20.0, 'CPA: 500€ / 25 conversiones = 20.0€'); like( dies { $calc->cpa(500, 0) }, qr/conversions cannot be zero/i, 'CPA: muere con 0 conversiones' ); }; # ── ROAS ──────────────────────────────────── subtest 'roas — return on ad spend' => sub { my $calc = CultivaMetrics::Calculator->new; is($calc->roas(3000, 500), 6.0, 'ROAS: 3000€ ingresos / 500€ gasto = 6.0x'); like( dies { $calc->roas(3000, 0) }, qr/spend cannot be zero/i, 'ROAS: muere con gasto 0' ); }; done_testing;
📄 t/unit/lead_scorer.t — Clasificación de leads
use v5.36; use Test2::V0; use Test::MockModule; use lib 'lib'; use CultivaMetrics::LeadScorer; subtest 'classify_lead — categorías' => sub { my $scorer = CultivaMetrics::LeadScorer->new; is($scorer->classify(85), 'Hot', 'score >= 75 es Hot'); is($scorer->classify(60), 'Warm', 'score 40-74 es Warm'); is($scorer->classify(20), 'Cold', 'score < 40 es Cold'); # Límites de frontera is($scorer->classify(75), 'Hot', 'score == 75 es Hot (boundary)'); is($scorer->classify(40), 'Warm', 'score == 40 es Warm (boundary)'); is($scorer->classify(0), 'Cold', 'score == 0 es Cold'); is($scorer->classify(100), 'Hot', 'score == 100 es Hot'); }; subtest 'mock DB — guardar score' => sub { my $mock = Test::MockModule->new( 'CultivaMetrics::DB' ); my $saved = 0; $mock->mock(save_lead_score => sub { $saved++; return 1; }); my $scorer = CultivaMetrics::LeadScorer->new; $scorer->score_and_save('lead_001', 82); is($saved, 1, 'save_lead_score llamado 1 vez'); # Mock restaurado al salir del scope }; done_testing;
🟢 Fase GREEN — Implementación mínima
📄 lib/CultivaMetrics/Calculator.pm
package CultivaMetrics::Calculator; use v5.36; use Moo; use Carp qw(croak); sub ctr($self, $clicks, $impressions) { croak 'impressions cannot be zero' if $impressions == 0; return sprintf('%.2f', ($clicks / $impressions) * 100) + 0; } sub cpa($self, $spend, $conversions) { croak 'conversions cannot be zero' if $conversions == 0; return sprintf('%.2f', $spend / $conversions) + 0; } sub roas($self, $revenue, $spend) { croak 'spend cannot be zero' if $spend == 0; return sprintf('%.2f', $revenue / $spend) + 0; } 1;
📄 lib/CultivaMetrics/LeadScorer.pm
package CultivaMetrics::LeadScorer; use v5.36; use Moo; use CultivaMetrics::DB; sub classify($self, $score) { return 'Hot' if $score >= 75; return 'Warm' if $score >= 40; return 'Cold'; } sub score_and_save($self, $lead_id, $score) { my $category = $self->classify($score); CultivaMetrics::DB->save_lead_score({ id => $lead_id, score => $score, category => $category, }); return $category; } 1;
📄 t/integration/db_metrics.t — SQLite in-memory
use v5.36; use Test2::V0; use DBI; subtest 'db integración — SQLite in-memory' => sub { my $dbh = DBI->connect( 'dbi:SQLite:dbname=:memory:', '', '', { RaiseError => 1 } ); $dbh->do('CREATE TABLE lead_scores ( id TEXT PRIMARY KEY, score INTEGER, category TEXT )'); $dbh->prepare( 'INSERT INTO lead_scores VALUES (?,?,?)' )->execute('lead_001', 82, 'Hot'); my $row = $dbh->selectrow_hashref( 'SELECT * FROM lead_scores WHERE id = ?', undef, 'lead_001' ); is($row->{category}, 'Hot', 'lead_001 guardado como Hot'); }; done_testing;
📊 Salida prove — Resultados del run
$ prove -lrv --color --timer t/
t/00-load.t .............................
ok 1 - use CultivaMetrics::Calculator;
ok 2 - use CultivaMetrics::LeadScorer;
ok 3 - use CultivaMetrics::DB;
1..3
t/unit/calculator.t ....................
# Subtest: ctr — click-through rate
ok 1 - CTR: 100 clicks / 5000 impressions = 2.0%
ok 2 - CTR: sin clicks = 0%
ok 3 - CTR: muere con 0 impresiones
1..3
ok 1 - ctr — click-through rate
# Subtest: cpa — cost per acquisition
ok 1 - CPA: 500€ / 25 conversiones = 20.0€
ok 2 - CPA: muere con 0 conversiones
1..2
ok 2 - cpa — cost per acquisition
# Subtest: roas — return on ad spend
ok 1 - ROAS: 3000€ ingresos / 500€ gasto = 6.0x
ok 2 - ROAS: muere con gasto 0
1..2
ok 3 - roas — return on ad spend
t/unit/lead_scorer.t ...................
# Subtest: classify_lead — categorías
ok 1 - score >= 75 es Hot
ok 2 - score 40-74 es Warm
ok 3 - score < 40 es Cold
ok 4 - score == 75 es Hot (boundary)
ok 5 - score == 40 es Warm (boundary)
ok 6 - score == 0 es Cold
ok 7 - score == 100 es Hot
1..7
ok 1 - classify_lead — categorías
# Subtest: mock DB — guardar score
ok 1 - save_lead_score llamado 1 vez
1..1
ok 2 - mock DB — guardar score
t/integration/db_metrics.t .............
# Subtest: db integración — SQLite in-memory
ok 1 - lead_001 guardado como Hot
1..1
ok 1 - db integración — SQLite in-memory
All tests successful.
Files=4, Tests=47, 0.38 wallclock secs
Result: PASS
t/00-load.t .............................
ok 1 - use CultivaMetrics::Calculator;
ok 2 - use CultivaMetrics::LeadScorer;
ok 3 - use CultivaMetrics::DB;
1..3
t/unit/calculator.t ....................
# Subtest: ctr — click-through rate
ok 1 - CTR: 100 clicks / 5000 impressions = 2.0%
ok 2 - CTR: sin clicks = 0%
ok 3 - CTR: muere con 0 impresiones
1..3
ok 1 - ctr — click-through rate
# Subtest: cpa — cost per acquisition
ok 1 - CPA: 500€ / 25 conversiones = 20.0€
ok 2 - CPA: muere con 0 conversiones
1..2
ok 2 - cpa — cost per acquisition
# Subtest: roas — return on ad spend
ok 1 - ROAS: 3000€ ingresos / 500€ gasto = 6.0x
ok 2 - ROAS: muere con gasto 0
1..2
ok 3 - roas — return on ad spend
t/unit/lead_scorer.t ...................
# Subtest: classify_lead — categorías
ok 1 - score >= 75 es Hot
ok 2 - score 40-74 es Warm
ok 3 - score < 40 es Cold
ok 4 - score == 75 es Hot (boundary)
ok 5 - score == 40 es Warm (boundary)
ok 6 - score == 0 es Cold
ok 7 - score == 100 es Hot
1..7
ok 1 - classify_lead — categorías
# Subtest: mock DB — guardar score
ok 1 - save_lead_score llamado 1 vez
1..1
ok 2 - mock DB — guardar score
t/integration/db_metrics.t .............
# Subtest: db integración — SQLite in-memory
ok 1 - lead_001 guardado como Hot
1..1
ok 1 - db integración — SQLite in-memory
All tests successful.
Files=4, Tests=47, 0.38 wallclock secs
Result: PASS
📈 Reporte Devel::Cover — Cobertura por módulo
| Módulo | Líneas | Ramas | Condiciones | Subrutinas | Estado |
|---|---|---|---|---|---|
Calculator.pm |
96%
|
92%
|
95%
|
100%
|
✓ OK |
LeadScorer.pm |
94%
|
88%
|
91%
|
100%
|
✓ OK |
DB.pm |
82%
|
75%
|
80%
|
100%
|
⚠ Mejorar |
| Total |
92.4%
|
88.6%
|
90%
|
100%
|
✓ >= 80% |
✅ Buenas Prácticas
✓
TDD primero
Escribe el test antes que el código. Red siempre precede a Green.
✓
Test2::V0
Usa el framework moderno. Mejores diagnósticos y subtests limpios.
✓
Mock en boundaries
Mockea solo dependencias externas (DB, API). Nunca el código bajo test.
✓
Boundary values
Testa siempre los valores límite: 0, undef, max, min, vacío.
✗
Estado compartido
Nunca our dentro de subtests. Usa my para aislar estado.
✗
Olvidar done_testing
Siempre termina con done_testing; para verificar el plan.
⚡ Quick Reference
| Tarea | Patrón / Comando |
|---|---|
| Ejecutar toda la suite | prove -lr t/ |
| Test verbose | prove -lv t/unit/calc.t |
| Solo tests fallados | prove -l --state=failed t/ |
| Cobertura HTML | cover -test && cover -report html |
| Igualdad simple | is($got, $expected, 'msg') |
| Test excepción | like(dies { ... }, qr/msg/) |
| Test sin excepción | ok(lives { ... }, 'msg') |
| Comparación parcial | is($h, hash { field k=>'v'; etc() }) |
| Mock un método | Test::MockModule->new('Pkg')->mock(m => sub { }) |
| Skip condicional | SKIP: { skip 'reason', $n unless $ENV{X} } |