Introducción al Testing
El testing automatizado es fundamental para desarrollar aplicaciones robustas y mantenibles. Laravel incluye soporte completo para PHPUnit y Pest, facilitando la escritura de tests desde el primer día.
¿Por qué hacer testing?
Los tests automatizados ofrecen múltiples beneficios:
- Detectar errores temprano: encuentras bugs antes de que lleguen a producción
- Refactorizar con confianza: puedes modificar código sabiendo que los tests detectarán regresiones
- Documentación viva: los tests muestran cómo se espera que funcione el código
- Diseño mejorado: escribir tests te obliga a pensar en la estructura del código
- Despliegues seguros: integración continua verifica que todo funciona antes de desplegar
Tipos de tests en Laravel
Laravel organiza los tests en dos categorías principales:
Feature Tests (tests de funcionalidad)
Prueban funcionalidades completas de la aplicación, como peticiones HTTP, autenticación o flujos de usuario. Son tests de alto nivel que verifican que todo funciona en conjunto.
<?php
// tests/Feature/UserRegistrationTest.php
namespace Tests\Feature;
use Tests\TestCase;
class UserRegistrationTest extends TestCase
{
public function test_user_can_view_registration_form(): void
{
$response = $this->get('/register');
$response->assertStatus(200);
$response->assertSee('Crear cuenta');
}
}
Unit Tests (tests unitarios)
Prueban piezas pequeñas y aisladas de código, como una clase o método específico. Son rápidos y ayudan a verificar la lógica de negocio.
<?php
// tests/Unit/PriceCalculatorTest.php
namespace Tests\Unit;
use App\Services\PriceCalculator;
use PHPUnit\Framework\TestCase;
class PriceCalculatorTest extends TestCase
{
public function test_applies_discount_correctly(): void
{
$calculator = new PriceCalculator();
$result = $calculator->applyDiscount(100, 20);
$this->assertEquals(80, $result);
}
}
Estructura de tests en Laravel
Laravel viene preconfigurado con una estructura de
tests en el directorio tests/:
tests/
├── Feature/ # Tests de funcionalidad
│ └── ExampleTest.php
├── Unit/ # Tests unitarios
│ └── ExampleTest.php
├── TestCase.php # Clase base para tests
└── Pest.php # Configuración de Pest (si lo usas)
PHPUnit vs Pest
Laravel soporta dos frameworks de testing:
PHPUnit (tradicional)
El framework de testing estándar de PHP. Usa clases
y métodos con el prefijo test_:
<?php
namespace Tests\Feature;
use Tests\TestCase;
class PostTest extends TestCase
{
public function test_can_list_posts(): void
{
$response = $this->get('/posts');
$response->assertStatus(200);
}
public function test_can_create_post(): void
{
$response = $this->post('/posts', [
'title' => 'Mi post',
'content' => 'Contenido del post',
]);
$response->assertStatus(201);
}
}
Pest (moderno)
Un framework más expresivo con sintaxis funcional. Desde Laravel 11, los proyectos nuevos pueden incluirlo por defecto:
<?php
// tests/Feature/PostTest.php
use function Pest\Laravel\get;
use function Pest\Laravel\post;
test('can list posts', function () {
get('/posts')->assertStatus(200);
});
test('can create post', function () {
post('/posts', [
'title' => 'Mi post',
'content' => 'Contenido del post',
])->assertStatus(201);
});
Para instalar Pest en un proyecto existente:
composer require pestphp/pest --dev --with-all-dependencies
./vendor/bin/pest --init
Nota: En este curso usaremos PHPUnit ya que es el estándar y viene incluido en Laravel. Sin embargo, Pest es totalmente compatible y puedes usarlo si prefieres su sintaxis.
Ejecutar tests
Laravel incluye varios comandos para ejecutar tests:
# Ejecutar todos los tests
php artisan test
# Ejecutar con PHPUnit directamente
./vendor/bin/phpunit
# Ejecutar solo tests de Feature
php artisan test --testsuite=Feature
# Ejecutar solo tests de Unit
php artisan test --testsuite=Unit
# Ejecutar un archivo específico
php artisan test tests/Feature/PostTest.php
# Ejecutar un test específico por nombre
php artisan test --filter=test_can_list_posts
# Ejecutar en paralelo (más rápido)
php artisan test --parallel
# Detener en el primer fallo
php artisan test --stop-on-failure
La salida muestra el resultado de cada test:
PASS Tests\Unit\ExampleTest
✓ that true is true
PASS Tests\Feature\ExampleTest
✓ the application returns a successful response
Tests: 2 passed (2 assertions)
Duration: 0.15s
Crear tests con Artisan
Usa Artisan para generar nuevos tests:
# Crear un Feature test (por defecto)
php artisan make:test UserTest
# Crear un Unit test
php artisan make:test PriceCalculatorTest --unit
# Crear test con Pest
php artisan make:test UserTest --pest
Anatomía de un test
Un test bien estructurado sigue el patrón Arrange-Act-Assert (AAA):
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_view_their_posts(): void
{
// Arrange: preparar los datos
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
// Act: ejecutar la acción
$response = $this->actingAs($user)->get('/my-posts');
// Assert: verificar el resultado
$response->assertStatus(200);
$response->assertSee($post->title);
}
}
Assertions comunes
Laravel proporciona muchos métodos de aserción:
Assertions de respuesta HTTP
<?php
$response = $this->get('/posts');
// Códigos de estado
$response->assertStatus(200);
$response->assertOk(); // 200
$response->assertCreated(); // 201
$response->assertNoContent(); // 204
$response->assertNotFound(); // 404
$response->assertForbidden(); // 403
$response->assertUnauthorized(); // 401
// Redirecciones
$response->assertRedirect('/login');
$response->assertRedirectToRoute('login');
// Contenido
$response->assertSee('Bienvenido');
$response->assertDontSee('Error');
$response->assertSeeText('Título'); // Solo texto, ignora HTML
// Vistas
$response->assertViewIs('posts.index');
$response->assertViewHas('posts');
$response->assertViewHas('posts', function ($posts) {
return $posts->count() === 5;
});
// JSON
$response->assertJson(['success' => true]);
$response->assertJsonPath('data.name', 'Laravel');
$response->assertJsonCount(3, 'data');
$response->assertJsonStructure([
'data' => ['id', 'name', 'email']
]);
Assertions de base de datos
<?php
// Verificar que existe en la base de datos
$this->assertDatabaseHas('users', [
'email' => 'user@example.com',
]);
// Verificar que NO existe
$this->assertDatabaseMissing('users', [
'email' => 'deleted@example.com',
]);
// Verificar número de registros
$this->assertDatabaseCount('posts', 10);
// Verificar que fue eliminado (soft delete)
$this->assertSoftDeleted('posts', ['id' => 1]);
// Verificar modelo específico
$this->assertModelExists($user);
$this->assertModelMissing($deletedUser);
Assertions de PHPUnit
<?php
// Igualdad
$this->assertEquals(100, $total);
$this->assertSame('texto', $valor); // Compara tipo también
// Booleanos
$this->assertTrue($result);
$this->assertFalse($isDeleted);
// Nulos
$this->assertNull($value);
$this->assertNotNull($user);
// Arrays
$this->assertCount(3, $items);
$this->assertContains('admin', $roles);
$this->assertArrayHasKey('id', $data);
// Tipos
$this->assertIsArray($result);
$this->assertIsString($name);
$this->assertInstanceOf(User::class, $model);
Base de datos para tests
Laravel incluye el trait RefreshDatabase
para resetear la base de datos entre tests:
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostTest extends TestCase
{
use RefreshDatabase;
public function test_creates_post_in_database(): void
{
// La base de datos está vacía al inicio de cada test
$this->post('/posts', [
'title' => 'Test Post',
'content' => 'Content',
]);
$this->assertDatabaseCount('posts', 1);
}
}
Configura una base de datos específica para tests
en phpunit.xml:
<!-- phpunit.xml -->
<php>
<!-- Usar SQLite en memoria (rápido) -->
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
Factories para generar datos
Las factories (vistas en la lección 22) son esenciales para testing:
<?php
use App\Models\User;
use App\Models\Post;
// Crear un usuario
$user = User::factory()->create();
// Crear 5 posts
$posts = Post::factory()->count(5)->create();
// Usuario con atributos específicos
$admin = User::factory()->create([
'role' => 'admin',
'email' => 'admin@example.com',
]);
// Usuario con posts relacionados
$user = User::factory()
->has(Post::factory()->count(3))
->create();
// Usar estados definidos en la factory
$published = Post::factory()->published()->create();
$draft = Post::factory()->draft()->create();
Autenticación en tests
Usa actingAs() para autenticar usuarios:
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DashboardTest extends TestCase
{
use RefreshDatabase;
public function test_guest_cannot_access_dashboard(): void
{
$response = $this->get('/dashboard');
$response->assertRedirect('/login');
}
public function test_user_can_access_dashboard(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/dashboard');
$response->assertOk();
$response->assertSee('Bienvenido');
}
public function test_admin_sees_admin_panel(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)->get('/admin');
$response->assertOk();
}
}
Ejemplo completo: Test de un CRUD
Veamos un ejemplo completo testeando un CRUD de posts:
<?php
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostCrudTest extends TestCase
{
use RefreshDatabase;
private User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
}
public function test_can_list_all_posts(): void
{
Post::factory()->count(3)->create();
$response = $this->get('/posts');
$response->assertOk();
$response->assertViewIs('posts.index');
$response->assertViewHas('posts');
}
public function test_can_view_single_post(): void
{
$post = Post::factory()->create(['title' => 'Test Post']);
$response = $this->get("/posts/{$post->id}");
$response->assertOk();
$response->assertSee('Test Post');
}
public function test_guest_cannot_create_post(): void
{
$response = $this->post('/posts', [
'title' => 'New Post',
'content' => 'Content here',
]);
$response->assertRedirect('/login');
$this->assertDatabaseCount('posts', 0);
}
public function test_user_can_create_post(): void
{
$response = $this->actingAs($this->user)->post('/posts', [
'title' => 'New Post',
'content' => 'Content here',
]);
$response->assertRedirect('/posts');
$this->assertDatabaseHas('posts', [
'title' => 'New Post',
'user_id' => $this->user->id,
]);
}
public function test_post_requires_title(): void
{
$response = $this->actingAs($this->user)->post('/posts', [
'title' => '',
'content' => 'Content here',
]);
$response->assertSessionHasErrors('title');
}
public function test_user_can_update_own_post(): void
{
$post = Post::factory()->create(['user_id' => $this->user->id]);
$response = $this->actingAs($this->user)->put("/posts/{$post->id}", [
'title' => 'Updated Title',
'content' => 'Updated content',
]);
$response->assertRedirect("/posts/{$post->id}");
$this->assertDatabaseHas('posts', [
'id' => $post->id,
'title' => 'Updated Title',
]);
}
public function test_user_cannot_update_others_post(): void
{
$otherUser = User::factory()->create();
$post = Post::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($this->user)->put("/posts/{$post->id}", [
'title' => 'Hacked Title',
]);
$response->assertForbidden();
}
public function test_user_can_delete_own_post(): void
{
$post = Post::factory()->create(['user_id' => $this->user->id]);
$response = $this->actingAs($this->user)->delete("/posts/{$post->id}");
$response->assertRedirect('/posts');
$this->assertDatabaseMissing('posts', ['id' => $post->id]);
}
}
Resumen
- El testing automatizado mejora la calidad y mantenibilidad del código
- Feature tests prueban funcionalidades completas (HTTP, flujos)
- Unit tests prueban piezas aisladas de código
- Laravel soporta PHPUnit (tradicional) y Pest (moderno)
- Usa
php artisan testpara ejecutar tests - El trait
RefreshDatabaseresetea la BD entre tests - Las factories generan datos de prueba fácilmente
actingAs()simula usuarios autenticados- Sigue el patrón Arrange-Act-Assert para estructurar tests
Ejercicios
Ejercicio 1: Test de página de inicio
Crea un Feature test que verifique que la página de inicio (/) devuelve un código 200, contiene el texto "Bienvenido" y renderiza la vista correcta.
Ver solución
<?php
namespace Tests\Feature;
use Tests\TestCase;
class HomePageTest extends TestCase
{
public function test_home_page_returns_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
public function test_home_page_contains_welcome_message(): void
{
$response = $this->get('/');
$response->assertSee('Bienvenido');
}
public function test_home_page_uses_correct_view(): void
{
$response = $this->get('/');
$response->assertViewIs('welcome');
}
// O todo en un solo test
public function test_home_page_loads_correctly(): void
{
$response = $this->get('/');
$response->assertOk()
->assertViewIs('welcome')
->assertSee('Bienvenido');
}
}
Ejercicio 2: Test de validación de formulario
Crea tests para un formulario de contacto que requiera nombre, email y mensaje. Verifica que los campos requeridos muestran errores cuando están vacíos y que un email inválido también falla la validación.
Ver solución
<?php
namespace Tests\Feature;
use Tests\TestCase;
class ContactFormTest extends TestCase
{
public function test_contact_form_requires_name(): void
{
$response = $this->post('/contact', [
'name' => '',
'email' => 'test@example.com',
'message' => 'Hello!',
]);
$response->assertSessionHasErrors('name');
}
public function test_contact_form_requires_email(): void
{
$response = $this->post('/contact', [
'name' => 'John',
'email' => '',
'message' => 'Hello!',
]);
$response->assertSessionHasErrors('email');
}
public function test_contact_form_requires_valid_email(): void
{
$response = $this->post('/contact', [
'name' => 'John',
'email' => 'not-an-email',
'message' => 'Hello!',
]);
$response->assertSessionHasErrors('email');
}
public function test_contact_form_requires_message(): void
{
$response = $this->post('/contact', [
'name' => 'John',
'email' => 'test@example.com',
'message' => '',
]);
$response->assertSessionHasErrors('message');
}
public function test_valid_contact_form_is_accepted(): void
{
$response = $this->post('/contact', [
'name' => 'John Doe',
'email' => 'john@example.com',
'message' => 'This is my message',
]);
$response->assertSessionHasNoErrors();
$response->assertRedirect('/contact/thanks');
}
public function test_empty_form_shows_all_errors(): void
{
$response = $this->post('/contact', [
'name' => '',
'email' => '',
'message' => '',
]);
$response->assertSessionHasErrors(['name', 'email', 'message']);
}
}
Ejercicio 3: Test de acceso restringido
Crea tests para una ruta /admin que solo debe ser accesible por usuarios con rol "admin". Los usuarios no autenticados deben ser redirigidos a login, y los usuarios normales deben recibir un error 403.
Ver solución
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AdminAccessTest extends TestCase
{
use RefreshDatabase;
public function test_guest_is_redirected_to_login(): void
{
$response = $this->get('/admin');
$response->assertRedirect('/login');
}
public function test_regular_user_cannot_access_admin(): void
{
$user = User::factory()->create(['role' => 'user']);
$response = $this->actingAs($user)->get('/admin');
$response->assertForbidden();
}
public function test_admin_can_access_admin_panel(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)->get('/admin');
$response->assertOk();
$response->assertViewIs('admin.dashboard');
}
public function test_admin_sees_admin_navigation(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)->get('/admin');
$response->assertSee('Panel de Administración');
$response->assertSee('Gestionar usuarios');
}
public function test_moderator_cannot_access_admin(): void
{
$moderator = User::factory()->create(['role' => 'moderator']);
$response = $this->actingAs($moderator)->get('/admin');
$response->assertForbidden();
}
}
¿Has encontrado un error o tienes una sugerencia para mejorar esta lección?
Escríbenos¿Quieres dominar el testing en Laravel?
Aprende TDD, mocking, testing de APIs y más en nuestros cursos premium con proyectos reales.
Ver cursos premium