Lección 39 de 45 15 min de lectura

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
<?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
<?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/:

text
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
<?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
<?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:

bash
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:

bash
# 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:

text
   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:

bash
# 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
<?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
<?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
<?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
<?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
<?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:

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
<?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
<?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
<?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 test para ejecutar tests
  • El trait RefreshDatabase resetea 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();
    }
}

¿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