🧪

CultivaMetrics::API — Suite de Testing en Perl

Estrategia TDD completa con Test2::V0, mocking y cobertura · CULTIVA IA / IA-Ingeniería-MLOps
Test2::V0 Perl 5.36+ TDD Red-Green-Refactor Devel::Cover
47
Tests OK
0
Failures
92.4%
Cobertura
0.38s
Tiempo total
Cobertura de ramas (branch) 88.6%
Cobertura de líneas (line) 92.4%
🔄 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
📄 .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
📈 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
TareaPatrón / Comando
Ejecutar toda la suiteprove -lr t/
Test verboseprove -lv t/unit/calc.t
Solo tests falladosprove -l --state=failed t/
Cobertura HTMLcover -test && cover -report html
Igualdad simpleis($got, $expected, 'msg')
Test excepciónlike(dies { ... }, qr/msg/)
Test sin excepciónok(lives { ... }, 'msg')
Comparación parcialis($h, hash { field k=>'v'; etc() })
Mock un métodoTest::MockModule->new('Pkg')->mock(m => sub { })
Skip condicionalSKIP: { skip 'reason', $n unless $ENV{X} }