---
name: php-testing
description: >
  Testing PHP con PHPUnit y Pest: factories, feature tests Laravel, Mockery,
  HTTP tests, Job/Event faking y Dusk. Cargar cuando se escriban tests PHP,
  se configuren factories o se establezca cobertura de código.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para implementar el código Laravel de producción — primero implementar con `php-experto`, luego escribir tests."
  - "No cargar para diseñar patrones Laravel — para diseño cargar `php-patrones`."
  - "No cargar para errores de Composer o extensiones PHP — para build errors cargar `build-errors-php`."
  - "No cargar para tests de carga o rendimiento en PHP — JMeter, k6 y Gatling no están cubiertos en este skill."
evolvable: true  # default para skill estandar
---
# PHP Testing — PHPUnit, Pest y Laravel Testing

Laravel incluye una suite de testing completa sobre PHPUnit. Pest es una capa
expresiva encima de PHPUnit que reduce boilerplate. Este skill cubre la escritura
de tests idiomáticos para aplicaciones Laravel: feature tests, unit tests,
factories, fakes y pruebas de browser con Dusk.

## Cuándo cargar este skill

Invoca `Skill("php-testing")` cuando:

- Se escriban tests unitarios o de feature en Laravel
- Se creen o modifiquen factories de Eloquent
- Se configuren thresholds de cobertura de código
- Se prueben Jobs, Events, Mail o Notifications
- Se implementen browser tests con Laravel Dusk

## Cuándo NO cargar

- El código Laravel de producción aún no existe — primero implementar con `php-experto`, luego escribir tests.
- La pregunta es sobre diseñar patrones Laravel (Action classes, DTOs) — cargar `php-patrones`.
- Los errores son de Composer o extensiones PHP en los tests — cargar `build-errors-php`.
- La pregunta es sobre tests de carga o rendimiento — JMeter, k6 y Gatling no están cubiertos en este skill.

## Conceptos clave

### PHPUnit vs Pest

PHPUnit usa clases que extienden `TestCase`. Pest usa funciones globales `it()`,
`test()` y `describe()` con una API fluida. Pest compila a PHPUnit internamente
— no son incompatibles, pueden coexistir.

### RefreshDatabase vs DatabaseTransactions

`RefreshDatabase` recrea el schema completo antes de cada test (lento, aislado).
`DatabaseTransactions` envuelve cada test en una transacción que se revierte
(rápido, pero no funciona con tests que usan múltiples conexiones o transacciones
anidadas reales).

### Factories con estados

Las factories generan modelos con valores por defecto. Los `states` sobreescriben
atributos para escenarios específicos sin duplicar la factory base.

## Reglas obligatorias

### SIEMPRE usar factories — nunca crear modelos manualmente en tests

```php
// MAL — fragil, se rompe si el schema cambia
$usuario = Usuario::create([
    'nombre' => 'Juan',
    'email'  => 'juan@test.com',
    'password' => bcrypt('secret'),
    'rol' => 'admin',
    // olvidaste 3 campos NOT NULL con defaults
]);

// BIEN — factory maneja defaults, solo sobreescribir lo relevante al test
$usuario = Usuario::factory()->create(['rol' => 'admin']);
```

### NUNCA usar sleep() en tests — mockear el tiempo con Carbon::setTestNow()

```php
// MAL
$token = Token::factory()->create();
sleep(3601); // esperar que expire
$this->assertFalse($token->esValido());

// BIEN
$token = Token::factory()->create();
Carbon::setTestNow(now()->addHour()->addSecond());
$this->assertFalse($token->esValido());
Carbon::setTestNow(); // limpiar en tearDown
```

### Usar fakes ANTES de ejecutar la acción — nunca después

```php
// MAL — el job ya se despacho antes de poner el fake
$this->post('/pedidos', $datos);
Queue::fake(); // tarde, no capturo el dispatch

// BIEN
Queue::fake();
$this->post('/pedidos', $datos);
Queue::assertPushed(ProcesarPedido::class);
```

## Patrones recomendados

### Pest — sintaxis moderna y expresiva

```php
// Pest con datasets (parametrizado)
it('rechaza emails invalidos', function (string $email) {
    $response = $this->postJson('/api/registro', ['email' => $email]);
    $response->assertUnprocessable();
})->with([
    'sin arroba'   => ['nombre-sin-arroba'],
    'sin dominio'  => ['nombre@'],
    'solo espacio' => [' '],
]);

// Pest con beforeEach
beforeEach(function () {
    $this->usuario = Usuario::factory()->create();
    $this->actingAs($this->usuario, 'sanctum');
});

test('puede ver sus pedidos', function () {
    $pedidos = Pedido::factory(3)->create(['usuario_id' => $this->usuario->id]);
    $this->getJson('/api/pedidos')
        ->assertOk()
        ->assertJsonCount(3, 'data');
});
```

### Factory States para escenarios específicos

```php
class PedidoFactory extends Factory
{
    public function definition(): array
    {
        return [
            'estatus'    => 'pendiente',
            'total'      => $this->faker->randomFloat(2, 10, 1000),
            'usuario_id' => Usuario::factory(),
        ];
    }

    public function pagado(): static
    {
        return $this->state(fn (array $attrs) => [
            'estatus'    => 'pagado',
            'pagado_en'  => now(),
        ]);
    }

    public function cancelado(): static
    {
        return $this->state(['estatus' => 'cancelado']);
    }
}

// Uso en tests
$pedidoPagado = Pedido::factory()->pagado()->create();
$lote = Pedido::factory(5)->pagado()->create(['usuario_id' => $usuario->id]);
```

### Feature tests con assertJson y assertDatabaseHas

```php
test('crea un producto correctamente', function () {
    $admin = Usuario::factory()->admin()->create();

    $response = $this->actingAs($admin, 'sanctum')
        ->postJson('/api/productos', [
            'nombre'     => 'Laptop Pro',
            'precio'     => 25000.00,
            'categoria'  => 'electronica',
        ]);

    $response
        ->assertCreated()
        ->assertJsonStructure(['data' => ['id', 'nombre', 'precio']])
        ->assertJsonPath('data.nombre', 'Laptop Pro');

    $this->assertDatabaseHas('productos', [
        'nombre' => 'Laptop Pro',
        'precio' => 25000.00,
    ]);
});
```

### Mockery para dependencias externas

```php
test('maneja error del servicio de pago', function () {
    $mockPago = Mockery::mock(ServicioPago::class);
    $mockPago->shouldReceive('cobrar')
        ->once()
        ->andThrow(new PagoRechazadoException('Fondos insuficientes'));

    $this->app->instance(ServicioPago::class, $mockPago);

    $response = $this->postJson('/api/checkout', ['pedido_id' => 1]);
    $response->assertUnprocessable()
        ->assertJsonPath('errors.0.code', 'PAGO_RECHAZADO');
});
```

### Event y Job Faking

```php
test('despacha job al crear pedido', function () {
    Queue::fake();
    Mail::fake();

    $response = $this->actingAs($this->usuario, 'sanctum')
        ->postJson('/api/pedidos', $this->datosPedido);

    $response->assertCreated();
    Queue::assertPushed(ProcesarPedido::class, fn ($job) =>
        $job->pedido->id === $response->json('data.id')
    );
    Mail::assertQueued(ConfirmacionPedidoMail::class);
});
```

## Anti-patrones conocidos

### Tests que dependen del orden de ejecución

Cada test debe ser completamente independiente. Usar `RefreshDatabase` o
`DatabaseTransactions`. NUNCA depender de datos creados en otro test.

### assertSee con texto que puede cambiar por i18n

```php
// MAL — fragil si cambia la traduccion
$response->assertSee('El correo electronico ya esta en uso');

// BIEN — verificar la estructura y el codigo de error
$response->assertJsonPath('errors.0.code', 'EMAIL_DUPLICADO');
```

### Factory::recycle para evitar crear modelos innecesarios

```php
// Cuando multiples modelos deben compartir el mismo usuario
$usuario = Usuario::factory()->create();
$pedidos = Pedido::factory(3)->recycle($usuario)->create();
// Los 3 pedidos apuntan al mismo usuario, no se crean 3 usuarios
```

## Checklist de verificación

- [ ] Factories usadas en lugar de `Model::create()` manual en tests
- [ ] `RefreshDatabase` o `DatabaseTransactions` en cada clase de test
- [ ] Fakes configurados ANTES de ejecutar la acción (Queue::fake, Mail::fake)
- [ ] Sin `sleep()` en tests — usar Carbon::setTestNow()
- [ ] Tests de feature cubren happy path Y casos de error
- [ ] Cobertura >= 80% en módulos de lógica de negocio

## Referencias

- [Laravel Testing](https://laravel.com/docs/testing)
- [Pest PHP](https://pestphp.com/docs)
- [Mockery](https://docs.mockery.io)

## Gotchas / Errores comunes no obvios

**`RefreshDatabase` recrea las migraciones en cada test de la suite completa, haciendo la suite lenta**: usar `RefreshDatabase` en todas las clases de test puede duplicar el tiempo de la suite en proyectos con muchas migraciones. Fix: usar `DatabaseTransactions` para tests que no necesitan un estado limpio absoluto — rollbackea cada test sin recrear el schema. Usar `RefreshDatabase` solo cuando el test modifica el schema o necesita seeds específicos.

**Factory `->create()` en un test de unit (no de feature) hace queries reales a la BD**: las factories de Eloquent usan `Model::create()` que ejecuta SQL — en un test unitario sin base de datos, esto lanza `PDOException`. Fix: usar `->make()` en lugar de `->create()` para tests unitarios que solo necesitan el objeto en memoria, o configurar SQLite en memoria en el `phpunit.xml` para tests que requieren BD.

**`Event::fake()` en un test de feature que usa `ShouldQueue` no verifica que el listener fue encolado**: `Event::fake()` intercepta el evento pero no verifica que el listener fue despachado a la queue. Causa: `Event::fake()` reemplaza el sistema de eventos completo. Fix: usar `Queue::fake()` junto con `Event::fake()` si se necesita verificar que el listener fue encolado, y usar `Queue::assertPushed(MiListener::class)`.

**`assertDatabaseHas` verifica la presencia de una fila pero no que sea exactamente la correcta**: `assertDatabaseHas('facturas', ['monto' => 100])` pasa si hay al menos una factura con monto=100, incluso si el test debería haber creado una específica. Fix: agregar columnas adicionales al assert para identificar la fila específica (ej: `['id' => $factura->id, 'monto' => 100]`), o usar `assertDatabaseCount` para verificar que exactamente N filas coinciden.

---
*Skill creado con swl:crear-skill el 2026-03-31. Versión 1.0.0.*
