Saltar al contenido principal

Laravel 11 + Pest: Estrategia de testing que no frena releases (con IA)

Autor
Ignacio AmatIgnacio Amat
Publicado
Lectura10 min
Terminal mostrando ejecución exitosa de tests Pest en un proyecto Laravel 11 moderno

En muchos equipos, el testing se ve como un “mal necesario” que ralentiza el desarrollo. “No tenemos tiempo para tests, hay que salir ya”. Como Full Stack, mi respuesta es siempre la misma: no tienes tiempo para NO hacer tests.

Con Laravel 11 + Pest 3, el testing ha pasado de ser una carga a ser una herramienta de velocidad. Aquí te explico mi estrategia completa (con código real, métricas y workflow IA) para mantener calidad sin frenar el despliegue.


Por Qué Pest 3: Cambio de Mentalidad, No Solo Sintaxis

Pest 3 no es “PHPUnit con sintaxis bonita”. Es un cambio de mentalidad. La sintaxis funcional + expect() API hace que los tests sean documentación viva y, por tanto, mantenibles.

// tests/Feature/Billing/SubscriptionTest.php
use App\Models\{User, Subscription, Plan};
use App\Actions\Billing\CreateSubscription;

test('allows a premium user to create monthly subscription', function () {
    $user = User::factory()->premium()->create();
    $plan = Plan::factory()->monthly()->create(['price' => 2900]); // €29.00
    
    $subscription = (new CreateSubscription())->handle(
        user: $user,
        plan: $plan,
        paymentMethodId: 'pm_test_123',
        idempotencyKey: 'idem_abc123'
    );
    
    expect($subscription)
        ->toBeInstanceOf(Subscription::class)
        ->status->toBe('active')
        ->plan_id->toBe($plan->id)
        ->ends_at->toBeNull(); // Mensual = sin fin
})
    ->group('billing', 'subscription', 'happy-path');

// Dataset parametrizado: tarjetas, errores, idempotencia
dataset('payment_scenarios', [
    'visa_ok'           => ['visa', '4242424242424242', 'succeeded'],
    'mastercard_ok'     => ['mastercard', '5555555555554444', 'succeeded'],
    'declined_card'     => ['visa', '4000000000000002', 'declined'],
    'expired_card'      => ['visa', '4000000000000069', 'expired'],
    'insufficient_funds'=> ['visa', '4000000000009995', 'insufficient_funds'],
]);

test('handles payment scenarios correctly', function (string $brand, string $number, string $expected) {
    $user = User::factory()->create();
    $plan = Plan::factory()->monthly()->create();
    
    $result = (new CreateSubscription())->handle(
        user: $user,
        plan: $plan,
        paymentMethodId: "pm_{$brand}_{$expected}",
        idempotencyKey: "idem_{$brand}"
    );
    
    expect($result->status)->toBe($expected === 'succeeded' ? 'active' : 'failed')
        ->and($result->failure_reason ?? '')->toContain($expected);
})->with('payment_scenarios');

Ventajas clave Pest 3:

  • expect() API: fluent assertions, mejor DX
  • dataset() + with(): parametrización limpia (DRY real)
  • group(): filtrado rápido (php artisan test --group=billing)
  • arch(): architecture tests nativos
  • Parallel execution nativo (--parallel --processes=4)

Mi Pirámide de Testing 2026 (ROI Real)

No todos los tests valen lo mismo. Para maximizar ROI del tiempo invertido:

Capa%Qué CubreHerramientasTiempo CI
Feature Tests60%Flujos completos usuario/API: auth, validación, permisos, business logic end-to-endPest + Laravel HTTP helpers + Factories~90s (paralelo)
Unit Tests30%Lógica compleja aislada: cálculos, servicios, value objects, edge casesPest + Mockery (solo donde necesario)~15s
Architecture Tests10%Reglas de arquitectura: capas, dependencias, globals, namingPest arch()~5s
Mutation TestingGateCalidad real de tests (no coverage)Infection (PHPStan + Pest)~60s (gate)

Regla de oro: Si un Feature Test pasa, el negocio está a salvo. Si solo pasan Unit Tests, puedes tener bugs de integración.


1. Feature Tests (60%) — El Corazón

// tests/Feature/Billing/SubscriptionTest.php
use App\Models\{User, Subscription, Plan, PaymentMethod};
use App\Actions\Billing\{CreateSubscription, CancelSubscription};
use App\Events\Billing\{SubscriptionCreated, SubscriptionCancelled};

test('create subscription: happy path with Stripe', function () {
    $user = User::factory()->withStripeCustomer()->create();
    $plan = Plan::factory()->monthly()->create(['price' => 4900]);
    $pm = PaymentMethod::factory()->for($user)->default()->create();
    
    // Arrange: idempotency key for retry safety
    $idempotencyKey = 'sub_create_' . $user->id . '_' . $plan->id . '_' . now()->format('Ym');
    
    // Act
    $subscription = (new CreateSubscription())->handle(
        user: $user,
        plan: $plan,
        paymentMethod: $pm,
        idempotencyKey: $idempotencyKey
    );
    
    // Assert: business outcome
    expect($subscription)
        ->toBeInstanceOf(Subscription::class)
        ->status->toBe('active')
        ->plan_id->toBe($plan->id)
        ->stripe_subscription_id->toStartWith('sub_')
        ->current_period_ends_at->toBeAfter(now()->addMonth());
    
    // Assert: side effects
    $this->assertDatabaseHas('stripe_events', [
        'type' => 'customer.subscription.created',
        'payload->data->object->id' => $subscription->stripe_subscription_id,
    ]);
    
    // Assert: event dispatched
    Event::assertDispatched(SubscriptionCreated::class, fn($e) => $e->subscription->id === $subscription->id);
});

test('create subscription: idempotency key prevents double charge', function () {
    $user = User::factory()->withStripeCustomer()->create();
    $plan = Plan::factory()->monthly()->create();
    $pm = PaymentMethod::factory()->for($user)->default()->create();
    $idemKey = 'idem_test_123';
    
    // First call succeeds
    $sub1 = (new CreateSubscription())->handle($user, $plan, $pm, $idemKey);
    
    // Second call with SAME key returns existing (no double charge)
    $sub2 = (new CreateSubscription())->handle($user, $plan, $pm, $idemKey);
    
    expect($sub1->id)->toBe($sub2->id)
        ->and($sub1->stripe_subscription_id)->toBe($sub2->stripe_subscription_id);
});

test('create subscription: failed validation returns 422', function () {
    $user = User::factory()->create();
    
    $response = $this->actingAs($user)
        ->postJson('/api/v1/subscriptions', [
            'plan_id' => 99999, // Not found
            'payment_method_id' => 'pm_invalid',
        ]);
    
    $response->assertStatus(422)
        ->assertJsonValidationErrors(['plan_id', 'payment_method_id']);
});

test('create subscription: unauthorized user gets 403', function () {
    $user = User::factory()->create(); // No premium role
    $plan = Plan::factory()->monthly()->create();
    
    $response = $this->actingAs($user)
        ->postJson('/api/v1/subscriptions', [
            'plan_id' => $plan->id,
            'payment_method_id' => 'pm_test',
        ]);
    
    $response->assertStatus(403);
});

2. Unit Tests (30%) — Lógica Pura, Sin BD

// tests/Unit/Billing/SubscriptionCalculatorTest.php
use App\Services\Billing\SubscriptionCalculator;
use App\ValueObjects\Billing\ProrationResult;

test('calculates proration for mid-cycle upgrade', function () {
    $calculator = new SubscriptionCalculator();
    
    $result = $calculator->calculateProration(
        currentPlan: Plan::factory()->monthly()->create(['price' => 2900]),
        newPlan: Plan::factory()->yearly()->create(['price' => 29000]),
        currentPeriodEnd: now()->addDays(15), // Mitad del mes
        currentPrice: 2900,
        newPrice: 29000,
    );
    
    expect($result)
        ->toBeInstanceOf(ProrationResult::class)
        ->credit->toBe(1450) // 15 días * €29 = €14.50 crédito
        ->charge->toBe(14500) // €290 - €14.50 = €275.50 cargo neto
        ->netCharge->toBe(13050);
});

test('calculate proration: downgrade mid-cycle creates credit', function () {
    $calculator = new SubscriptionCalculator();
    
    $result = $calculator->calculateProration(
        currentPlan: Plan::factory()->yearly()->create(['price' => 29000]),
        newPlan: Plan::factory()->monthly()->create(['price' => 2900]),
        currentPeriodEnd: now()->addDays(180), // 6 meses restantes
        currentPrice: 29000,
        newPrice: 2900,
    );
    
    expect($result)
        ->credit->toBeGreaterThan(0)
        ->charge->toBe(0) // No cargo inmediato en downgrade
        ->creditNote->toBeTrue();
});

// Dataset para edge cases
dataset('proration_edge_cases', [
    'day_1_of_30'       => [1, 30, 0.967],
    'day_15_of_30'      => [15, 30, 0.5],
    'day_29_of_30'      => [29, 30, 0.033],
    'exact_half'        => [15, 30, 0.5],
]);

test('proration ratio matches expected formula', function (int $day, int $total, float $expectedRatio) {
    $ratio = SubscriptionCalculator::calculateUnusedRatio($day, $total);
    expect($ratio)->toBeApproximately($expectedRatio, 0.01);
})->with('proration_edge_cases');

3. Architecture Tests (10%) — Guardianes Invisibles

// tests/Architecture/ArchitectureTest.php
use Pest\Arch\Arch;

arch('layer boundaries')
    ->expect('App\Modules\*')
    ->toOnlyBeUsedIn('App\Modules\*')
    ->and('App\Shared\*')
    ->and('App\Http\*');

arch('actions in modules')
    ->expect('App\Modules\*')
    ->toOnlyBeUsedIn('App\Modules\*\Actions', 'App\Modules\*\Http\Controllers', 'App\Modules\*\Services');

arch('models only in repositories/actions')
    ->expect('App\Models')
    ->toOnlyBeUsedIn('App\Repositories', 'App\Actions', 'App\Http\Controllers');

arch('forbidden globals')
    ->expect(['dd', 'dump', 'var_dump', 'ray', 'logger'])
    ->not->toBeUsed();

arch('controllers thin')
    ->expect('App\Http\Controllers')
    ->toOnlyCall(['App\Actions', 'App\Http\Requests', 'App\Http\Resources']);

arch('no facades in domain')
    ->expect('Illuminate\Support\Facades\*')
    ->not->toBeUsedIn('App\Modules\*\Actions', 'App\Modules\*\Services', 'App\Modules\*\DTOs');

arch('events naming')
    ->expect('App\Events\*')
    ->toHaveSuffix('Created|Updated|Deleted|Completed|Failed');

arch('jobs on correct queue')
    ->expect('App\Jobs\*')
    ->toHaveProperty('queue', fn($queue) => in_array($queue, ['critical', 'default', 'low']));

Ejecutar en CI:

php artisan test --filter=Architecture --parallel
# Falla el build si arquitectura se rompe → catch early

4. Mutation Testing — El Quality Gate Real

Coverage ≠ Calidad. Mutation testing = ¿detectan tus tests bugs reales?

# .github/workflows/ci.yml
- name: Mutation Testing (Infection)
  if: runner.os == 'Linux'  # Solo Linux por performance
  run: |
    composer install --no-dev --prefer-dist --no-progress
    php vendor/bin/infection \
      --min-msi=80 \
      --min-covered-msi=70 \
      --threads=4 \
      --show-mutations \
      --coverage=build/coverage.xml \
      --log-verbosity=all
// phpstan.neon — Static analysis complementario
parameters:
    level: 6
    paths:
        - app/
        - tests/
    excludes_analyse:
        - tests/Feature/**/*Test.php  # Tests tienen sus propias reglas
    rules:
        - PHPStan\Rules\Variables\UnusedVariableRule
        - PHPStan\Rules\Methods\MethodCallOnNonObjectRule

Umbrales 2026 que defiendo:

MétricaMínimoObjetivo
MSI (Mutation Score Indicator)80%90%
Covered MSI70%80%
PHPStan Level68
Feature Test Coverage85%95% (business critical)
Flaky Test Rate0%0% (cuarentena inmediata)

Testing Aumentado por IA: Claude Code como “Andamio”

No dejes que la IA escriba tus tests ciegamente. Úsala como andamio (scaffolding), tú pones el criterio.

# Mi flujo real (8 meses probado)
# 1. Escribo la lógica del Action/Controller
# 2. Prompt a Claude Code:
cat > /tmp/prompt.md << 'EOF'
Lee `app/Actions/Billing/CreateSubscription.php` y genera tests Pest para:
- Happy path completo (usuario premium, plan mensual, pago OK)
- Validación fallida (plan inexistente, payment method inválido)
- Permisos (usuario sin rol premium → 403)
- Idempotency key (mismo key = misma suscripción, no doble cargo)
- Edge cases: tarjeta declinada, fondos insuficientes, tarjeta expirada
- Webhook: invoice.payment_succeeded → activa suscripción
- Webhook: invoice.payment_failed → marca failed + email

Patrones a seguir:
- Usa `dataset()` para tarjetas Stripe de prueba
- Usa `group('billing', 'subscription')`
- Factories: User::factory()->premium(), Plan::factory()->monthly()
- Asserts: `expect()->toBe()`, `assertDatabaseHas()`, `Event::assertDispatched()`
- NO mocks Stripe (usamos Stripe CLI en CI para pruebas reales)
EOF

claude-code --prompt-file /tmp/prompt.md --output tests/Feature/Billing/SubscriptionTest.php
# 3. Reviso y ajusto (15 min vs 2h manual)
# - Añado asserts de negocio que IA no conoce
# - Corrijo edge cases sutiles (proration, downgrades)
# - Ajusto factories a mi schema real
# 4. Ejecuto: php artisan test --filter=Subscription --parallel
# 5. Mutation test: php vendor/bin/infection --min-msi=80

Resultados medidos (mis proyectos):

MétricaAntes IACon IA (mi flujo)
Tiempo scaffolding tests90-120 min/feature10-15 min
Coverage feature tests75%92%+
Bugs en producción2-3/release0-1/release
Tiempo code review45 min/PR18 min/PR

Pipeline CI/CD: La Verdad Definitiva

# .github/workflows/ci.yml
name: CI Pipeline

on:
  pull_request:
    branches: [main, develop]
  push:
    branches: [main]

jobs:
  test:
    name: Tests (PHP ${{ matrix.php }}, Laravel ${{ matrix.laravel }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        php: ['8.3']
        laravel: ['11.*']
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: redis, pdo, pdo_mysql, mbstring, bcmath
          coverage: pcov
          tools: composer:v2
      
      - name: Cache dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.composer/cache
            vendor
          key: ${{ runner.os }}-php-${{ hashFiles('composer.lock') }}
      
      - name: Install dependencies
        run: composer install --prefer-dist --no-progress --no-interaction
      
      - name: Cache npm
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
      
      - name: Install frontend
        run: npm ci
      
      - name: Build frontend
        run: npm run build
      
      - name: Run Pest (parallel, coverage)
        run: |
          php artisan test --parallel --coverage --min=80
        env:
          DB_CONNECTION: sqlite
          DB_DATABASE: ":memory:"
          CACHE_DRIVER: array
          QUEUE_CONNECTION: sync
          SESSION_DRIVER: array
      
      - name: Static Analysis (PHPStan)
        run: php vendor/bin/phpstan analyse --memory-limit=512M
      
      - name: Code Style (Laravel Pint)
        run: ./vendor/bin/pint --test
      
      - name: Mutation Testing (Infection) - Only on main PRs
        if: github.event_name == 'pull_request' && github.base_ref == 'main'
        run: |
          php vendor/bin/infection \
            --min-msi=80 \
            --min-covered-msi=70 \
            --threads=4 \
            --show-mutations \
            --coverage=build/coverage.xml
      
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./build/coverage.xml
          flags: unittests

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to Forge
        run: |
          curl -X POST "https://forge.laravel.com/api/v1/servers/${{ secrets.FORGE_SERVER }}/sites/${{ secrets.FORGE_SITE }}/deploy" \
            -H "Authorization: Bearer ${{ secrets.FORGE_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d '{"commit":"${{ github.sha }}","branch":"main"}'

Conclusión: Testing = Ventaja de Producto, No Obstáculo

El testing con Laravel 11 + Pest 3 + IA + Mutation Testing no es un obstáculo. Es el seguro de vida de tu producto. Te permite:

  1. Refactorizar con confianza (arquitectura evoluciona sin miedo)
  2. Desplegar los viernes a las 5pm (CI/CD bloquea regressions)
  3. Onboardar devs en días, no semanas (tests = documentación viva)
  4. Dormir tranquilo (mutation testing = tests que detectan bugs reales)

La diferencia: Un equipo que “tiene tests” vs un equipo que usa testing como ventaja real de delivery.


¿Quieres Implementar Esto en Tu Equipo?

Disponible para consultoría técnica (2-4 semanas) o incorporación como Senior que trae este workflow listo:

  • Auditoría arquitectura testing (2 días): Identifico gaps, propongo fixes priorizados
  • Implementación hands-on (1-2 semanas): Pest 3, Infection, IA workflow, CI/CD, quality gates
  • Load testing realista: Simulo tu patrón de tráfico, valido límites, documento runbooks

Modalidades:

  • EOR (Deel, Remote, Oyster) — rol core indefinido
  • Freelance B2B (Autónomo, factura intracomunitaria 0% IVA) — proyectos 3-12 meses
  • Indefinido directo — si tenéis entidad en España

Stack actual: Laravel 11, PHP 8.3+, Vue 3 + TS, Inertia.js, Livewire 3, Astro, Docker, GitHub Actions, Laravel Pulse, Sentry, Claude Code / Cursor / MCP servers.

Ver mi perfil, stack completo y condiciones →


Artículos Relacionados en Este Blog

Artículos relacionados

Revisa mi perfil como desarrollador

Si este artículo encaja con los retos técnicos de tu equipo, revisa mi stack o mi disponibilidad profesional.

Cuéntame qué necesitas

Puedes escribirme por un rol, contrato, colaboración técnica, una duda o una consulta general. Con 2-3 líneas de contexto suelo poder responder en menos de 24 horas hábiles.

0/500
Disponibilidad