Pruebas en Laravel con TDD
Desarrollo guiado por pruebas para aplicaciones Laravel usando PHPUnit, Pest, factories de Laravel y utilidades de prueba.
Cuándo activarlo
- Escribir nuevas aplicaciones o funcionalidades Laravel
- Implementar endpoints de API con autenticación Sanctum o Passport
- Probar modelos Eloquent, relaciones, scopes y accessors
- Configurar la infraestructura de pruebas para proyectos Laravel
- Escribir pruebas de funcionalidad para controladores HTTP y form requests
- Mockear servicios externos (colas, correo, notificaciones, HTTP)
Flujo de trabajo TDD para Laravel
Ciclo Rojo-Verde-Refactorizar
// Paso 1: ROJO — Escribir una prueba que falla
public function test_un_producto_puede_crearse(): void
{
$producto = Producto::factory()->create(['nombre' => 'Producto de prueba']);
$this->assertDatabaseHas('productos', ['nombre' => 'Producto de prueba']);
}
// Paso 2: VERDE — Escribir la migración, el modelo y la factory
// Paso 3: REFACTORIZAR — Mejorar mientras las pruebas siguen en verde
Configuración
Configuración de PHPUnit
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">tests/Feature</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
</php>
</phpunit>
Configuración base de TestCase
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
// Llama a $this->withoutExceptionHandling() solo en pruebas que
// prueben excepciones no HTTP; suprime assertStatus() etc.
}
// Helper: Autenticar y devolver usuario
protected function actingAsUser(): mixed
{
$usuario = \App\Models\User::factory()->create();
$this->actingAs($usuario);
return $usuario;
}
protected function actingAsAdmin(): mixed
{
$admin = \App\Models\User::factory()->admin()->create();
$this->actingAs($admin);
return $admin;
}
}
Factories de Modelos
// database/factories/UserFactory.php
class UserFactory extends Factory
{
protected static ?string $password = null;
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'role' => 'user',
];
}
public function admin(): static
{
return $this->state(fn (array $attributes) => ['role' => 'admin']);
}
public function unverified(): static
{
return $this->state(fn (array $attributes) => ['email_verified_at' => null]);
}
}
// database/factories/ProductFactory.php
class ProductFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->unique()->words(3, true),
'slug' => fn (array $attrs) => Str::slug($attrs['name']),
'description' => fake()->paragraph(),
'price' => fake()->numberBetween(100, 100000),
'stock' => fake()->numberBetween(0, 100),
'is_active' => true,
'user_id' => UserFactory::new(),
];
}
public function outOfStock(): static
{
return $this->state(fn (array $attributes) => ['stock' => 0]);
}
}
Usando Factories
$usuario = User::factory()->create();
$admin = User::factory()->admin()->create();
$producto = Product::factory()->create(['user_id' => $usuario->id]);
$productos = Product::factory()->count(10)->create();
$borrador = Product::factory()->make(); // No persistido
// Con relaciones
$usuario = User::factory()->has(Product::factory()->count(3))->create();
// Secuencias
User::factory()->count(3)->sequence(
['role' => 'admin'], ['role' => 'editor'], ['role' => 'user'],
)->create();
Pruebas de Modelos
namespace Tests\Unit\Models;
use App\Models\User;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserTest extends TestCase
{
use RefreshDatabase;
public function test_oculta_atributos_sensibles(): void
{
$usuario = User::factory()->create();
$this->assertArrayNotHasKey('password', $usuario->toArray());
}
public function test_scope_admin_devuelve_solo_admins(): void
{
User::factory()->admin()->create();
User::factory()->count(3)->create();
$this->assertCount(1, User::admin()->get());
}
}
class ProductTest extends TestCase
{
use RefreshDatabase;
public function test_scope_active_filtra_correctamente(): void
{
Product::factory()->count(3)->create(['is_active' => true]);
Product::factory()->count(2)->create(['is_active' => false]);
$this->assertCount(3, Product::active()->get());
}
public function test_pertenece_a_un_usuario(): void
{
$usuario = User::factory()->create();
$producto = Product::factory()->create(['user_id' => $usuario->id]);
$this->assertTrue($producto->user->is($usuario));
}
}
Pruebas de Funcionalidad / HTTP
namespace Tests\Feature\Http\Controllers;
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProductControllerTest extends TestCase
{
use RefreshDatabase;
public function test_invitados_son_redirigidos_al_login(): void
{
$this->get(route('products.create'))->assertRedirect(route('login'));
}
public function test_almacena_un_nuevo_producto(): void
{
$usuario = User::factory()->create();
$this->actingAs($usuario);
$response = $this->post(route('products.store'), [
'name' => 'Nuevo producto',
'description' => 'Descripción',
'price' => 2999,
'stock' => 10,
]);
$response->assertRedirect(route('products.index'));
$this->assertDatabaseHas('products', [
'name' => 'Nuevo producto',
'user_id' => $usuario->id,
]);
}
public function test_valida_campos_requeridos(): void
{
$this->actingAs(User::factory()->create());
$this->post(route('products.store'), [])
->assertSessionHasErrors(['name', 'price']);
}
public function test_usuarios_no_pueden_modificar_productos_ajenos(): void
{
$propietario = User::factory()->create();
$atacante = User::factory()->create();
$producto = Product::factory()->create(['user_id' => $propietario->id]);
$this->actingAs($atacante)
->delete(route('products.destroy', $producto))
->assertForbidden();
}
}
Pruebas de API JSON
namespace Tests\Feature\Http\Controllers\Api;
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProductApiTest extends TestCase
{
use RefreshDatabase;
public function test_solicitudes_no_autenticadas_son_rechazadas(): void
{
$this->getJson('/api/products')->assertUnauthorized();
}
public function test_lista_productos_paginados(): void
{
$usuario = User::factory()->create();
Product::factory()->count(5)->create(['user_id' => $usuario->id]);
$response = $this->actingAs($usuario)->getJson('/api/products');
$response->assertOk();
$response->assertJsonCount(5, 'data');
$response->assertJsonStructure([
'data' => [['id', 'name', 'price']],
'meta' => ['current_page', 'last_page', 'total'],
]);
}
public function test_crea_un_producto(): void
{
$usuario = User::factory()->create();
$response = $this->actingAs($usuario)->postJson('/api/products', [
'name' => 'Producto API',
'price' => 4999,
]);
$response->assertCreated();
$response->assertJsonPath('data.name', 'Producto API');
}
public function test_usuarios_no_pueden_eliminar_productos_ajenos(): void
{
$propietario = User::factory()->create();
$atacante = User::factory()->create();
$producto = Product::factory()->create(['user_id' => $propietario->id]);
$this->actingAs($atacante)
->deleteJson("/api/products/{$producto->id}")
->assertForbidden();
}
}
Pruebas de Autenticación con Sanctum
namespace Tests\Feature\Http\Controllers\Api;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class AuthControllerTest extends TestCase
{
use RefreshDatabase;
public function test_usuarios_pueden_registrarse(): void
{
$response = $this->postJson('/api/register', [
'name' => 'Usuario de prueba',
'email' => '[email protected]',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
]);
$response->assertCreated();
$response->assertJsonStructure(['data' => ['user', 'token']]);
}
public function test_usuarios_pueden_iniciar_sesion(): void
{
User::factory()->create([
'email' => '[email protected]',
'password' => Hash::make('Password123!'),
]);
$response = $this->postJson('/api/login', [
'email' => '[email protected]',
'password' => 'Password123!',
]);
$response->assertOk();
$response->assertJsonStructure(['data' => ['token']]);
}
public function test_usuarios_no_pueden_iniciar_sesion_con_contrasena_incorrecta(): void
{
User::factory()->create(['email' => '[email protected]']);
$this->postJson('/api/login', [
'email' => '[email protected]',
'password' => 'incorrecta',
])->assertUnprocessable();
}
public function test_token_bearer_autentica_solicitudes(): void
{
$usuario = User::factory()->create();
$token = $usuario->createToken('test')->plainTextToken;
$this->withToken($token)
->getJson('/api/user')
->assertOk()
->assertJsonPath('data.email', $usuario->email);
}
}
Mocks y Fakes
HTTP Fake
use Illuminate\Support\Facades\Http;
public function test_maneja_pago_exitoso(): void
{
Http::fake([
'api.stripe.com/*' => Http::response(['id' => 'pi_123', 'status' => 'succeeded'], 200),
]);
$resultado = (new PaymentService())->charge(2999);
$this->assertTrue($resultado->success);
}
public function test_maneja_fallo_de_gateway(): void
{
Http::fake([
'api.stripe.com/*' => Http::response(['error' => 'card_declined'], 402),
]);
$this->expectException(PaymentFailedException::class);
(new PaymentService())->charge(2999);
}
public function test_reintenta_en_timeout(): void
{
Http::fake([
'api.stripe.com/*' => Http::sequence()
->pushStatus(408)
->pushStatus(200),
]);
$this->assertTrue((new PaymentService())->charge(2999)->success);
}
Mail Fake
Mail::fake();
$pedido->sendConfirmation();
Mail::assertSent(OrderConfirmation::class, function ($mail) use ($pedido) {
return $mail->hasTo($pedido->user->email);
});
Notification Fake
Notification::fake();
$usuario->notify(new WelcomeUser());
Notification::assertSentTo($usuario, WelcomeUser::class);
Queue Fake
Queue::fake();
ProcessImage::dispatch($producto);
Queue::assertPushed(ProcessImage::class, function ($job) use ($producto) {
return $job->product->id === $producto->id;
});
Storage Fake
Storage::fake('public');
$archivo = UploadedFile::fake()->image('foto.jpg', 200, 200);
$response = $this->actingAs($usuario)->post('/avatar', [
'avatar' => $archivo,
]);
$response->assertSessionHasNoErrors();
Storage::disk('public')->assertExists('avatars/' . $archivo->hashName());
Event Fake
Event::fake();
$pedido->markAsShipped();
Event::assertDispatched(OrderShipped::class, function ($event) use ($pedido) {
return $event->order->id === $pedido->id;
});
Pruebas de Comandos Artisan
public function test_envia_boletines(): void
{
Mail::fake();
User::factory()->count(5)->create(['subscribed' => true]);
$this->artisan('newsletter:send')
->expectsOutput('Enviando boletín a 5 suscriptores...')
->assertExitCode(0);
Mail::assertSent(NewsletterMail::class, 5);
}
public function test_maneja_sin_suscriptores(): void
{
$this->artisan('newsletter:send')
->expectsOutput('No se encontraron suscriptores.')
->assertExitCode(0);
}
Pruebas de Autorización
public function test_usuarios_pueden_actualizar_sus_propios_posts(): void
{
$usuario = User::factory()->create();
$post = Post::factory()->create(['user_id' => $usuario->id]);
$this->actingAs($usuario)
->put(route('posts.update', $post), ['title' => 'Actualizado'])
->assertRedirect();
}
public function test_usuarios_no_pueden_actualizar_posts_ajenos(): void
{
$post = Post::factory()->create();
$this->actingAs(User::factory()->create())
->put(route('posts.update', $post), ['title' => 'Hackeado'])
->assertForbidden();
}
[truncado para traducción]