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 DXdataset()+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é Cubre | Herramientas | Tiempo CI |
|---|---|---|---|---|
| Feature Tests | 60% | Flujos completos usuario/API: auth, validación, permisos, business logic end-to-end | Pest + Laravel HTTP helpers + Factories | ~90s (paralelo) |
| Unit Tests | 30% | Lógica compleja aislada: cálculos, servicios, value objects, edge cases | Pest + Mockery (solo donde necesario) | ~15s |
| Architecture Tests | 10% | Reglas de arquitectura: capas, dependencias, globals, naming | Pest arch() | ~5s |
| Mutation Testing | Gate | Calidad 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étrica | Mínimo | Objetivo |
|---|---|---|
| MSI (Mutation Score Indicator) | 80% | 90% |
| Covered MSI | 70% | 80% |
| PHPStan Level | 6 | 8 |
| Feature Test Coverage | 85% | 95% (business critical) |
| Flaky Test Rate | 0% | 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étrica | Antes IA | Con IA (mi flujo) |
|---|---|---|
| Tiempo scaffolding tests | 90-120 min/feature | 10-15 min |
| Coverage feature tests | 75% | 92%+ |
| Bugs en producción | 2-3/release | 0-1/release |
| Tiempo code review | 45 min/PR | 18 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:
- Refactorizar con confianza (arquitectura evoluciona sin miedo)
- Desplegar los viernes a las 5pm (CI/CD bloquea regressions)
- Onboardar devs en días, no semanas (tests = documentación viva)
- 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
- Cómo hago Code Review con Claude Code: Humano + IA — Workflow de revisión aumentada
- Métricas que un Senior Full-Stack debe defender — Cycle Time, MTTR, Core Web Vitals, coste infra, Bus Factor
- Laravel + Vue para equipos distribuidos — Arquitectura Inertia, workflow async, onboarding
- Async-first: Cómo trabajo en remoto desde Barcelona — Comunicación, tooling, rituales
