Pruebas Unitarias y de Integración con PHPUnit y Pest en Laravel (Inertia y Livewire)

Video thumbnail

Índice de contenido

Las pruebas unitarias en Laravel son una de las herramientas más poderosas para asegurar que tu aplicación funcione como esperas. No solo validan el comportamiento de tu código, sino que también te ayudan a mantenerlo limpio, seguro y escalable. En esta guía, te mostraré cómo implementar pruebas con PHPUnit y Pest, las dos opciones más populares del ecosistema Laravel, y cómo migrar entre ellas fácilmente.

Ya con errores como el Eager loading y lazy loading en Laravel solucionados, vamos al siguiente paso que consiste en tener TODO probado mediante pruebas.

¿Qué son las pruebas unitarias y por qué usarlas en Laravel?

Las pruebas unitarias son pequeños fragmentos de código que verifican que cada componente (modelo, controlador, helper, etc.) funcione correctamente de forma aislada. En Laravel, las pruebas se integran de forma nativa con herramientas potentes y una sintaxis expresiva.

Ventajas del testing automatizado

  • Detecta errores antes de que lleguen a producción.
  • Permite refactorizar sin miedo.
  • Mejora la calidad del código y la confianza del equipo.
  • Facilita aplicar TDD (Test Driven Development), una metodología que parte de escribir primero las pruebas y luego el código funcional.

Tipos de pruebas en Laravel

Laravel soporta distintos tipos de pruebas:

  • Unitarias: verifican funciones o métodos individuales.
  • De integración: evalúan cómo interactúan diferentes componentes.
  • End-to-End: prueban el flujo completo del usuario, por ejemplo con Laravel Dusk.

TDD en acción

En mi experiencia, aplicar TDD en Laravel cambia la forma de programar. Cuando empecé a desarrollar módulos como el dashboard, implementé primero las pruebas, y eso me permitió establecer reglas claras sobre lo que debía hacer cada ruta o controlador; con la IA, puedes pedirle que en base a un módulo, primero genere las prueba y luego a partir de allí los módulos, ganando consistencia y estableciendo límites sobre el desarrollo que quieras realizar

Importancia de las Pruebas Automatizadas

Las pruebas son una parte crucial en cualquier aplicación que vayamos a crear. Sin importar la tecnología, siempre es recomendable realizar pruebas automáticas para validar el sistema cuando se implementen nuevos cambios. De esta forma, nos ahorramos mucho tiempo, ya que no es necesario realizar todas las pruebas de manera manual, sino simplemente ejecutar un comando.

Las pruebas consisten en verificar los componentes de forma individual. En el caso de la aplicación que hemos construido, serían cada uno de los métodos de la API, junto con cualquier dependencia asociada. Cuando se ejecutan estas pruebas automatizadas y la aplicación las supera todas, significa que no se encontraron errores; si no las supera, significa que debemos realizar cambios tanto en la aplicación como en las pruebas implementadas.


Las pruebas son una parte crucial en cualquier aplicación que vayamos a crear, sin importar la tecnología, siempre es recomendable realizar pruebas automáticas para probar el sistema cuando se implementen nuevos cambios; de esta forma nos ahorramos mucho tiempo ya que, no hay necesidad de realizar muchas de las pruebas de manera manual si no, ejecutando un simple comando.

Las pruebas consisten en probar los componentes de manera individual; en el caso de la aplicación que hemos construido, serían cada uno de los métodos de la API, al igual que cualquier otra dependencia de estos métodos; de esta manera, cuando se ejecutan estas pruebas automatizadas, si la aplicación pasa todas las pruebas, significa que no se encontraron errores, pero, si no pasa las pruebas, significa que hay que hacer cambios a nivel de la aplicación o pruebas implementadas.

¿Por qué hacer pruebas?

Las pruebas ayudan a garantizar que la aplicación funcionará como se espera. A medida que el proyecto crece en módulos y complejidad, es posible implementar nuevas pruebas y adaptar las ya existentes.

Es importante mencionar que las pruebas no son perfectas. Aunque la aplicación pase todas las pruebas, no significa que esté libre de errores, pero sí es un muy buen indicador inicial de la calidad del software. Además, el código comprobable suele ser señal de una buena arquitectura.

Las pruebas deben formar parte del ciclo de desarrollo del proyecto para garantizar un funcionamiento estable, ejecutándolas constantemente.

¿Qué probar?

Las pruebas deberían centrarse en pequeñas unidades de código de forma aislada.
Por ejemplo, en una app Laravel (o web en general):

  • Controladores
  • Respuestas de las vistas
  • Códigos de estado
  • Condiciones nominales (GET, POST, etc.)
  • Formularios
  • Funciones helper individuales

Laravel soporta oficialmente dos herramientas para pruebas: Pest y PHPUnit.

Pruebas con Pest/PHPUnit

PHPUnit es uno de los frameworks de pruebas para PHP y ya viene instalado por defecto en Laravel. Es el más antiguo dentro del ecosistema, por lo que lo cubriremos primero. Para seguir este apartado, debes haber seleccionado PHPUnit como entorno de testing al crear tu proyecto.

Al crear un proyecto en Laravel, automáticamente se genera una carpeta tests, lo que evidencia la importancia de las pruebas. Aunque no forman parte del desarrollo funcional, sí forman parte del ciclo de vida del software, y crearlas es evidencia de buenas prácticas.

En Laravel 11, aunque desaparecieron varias carpetas para simplificar la estructura, tests/ sigue estando presente, reafirmando su relevancia.

Dentro de esta carpeta encontramos:

  • tests/Unit
  • tests/Feature

Las pruebas unitarias verifican módulos concretos, normalmente código aislado: facades, modelos, helpers, etc. Estas van en tests/Unit.

Las pruebas de integración o feature prueban componentes más grandes: controladores, consultas a la base de datos, helpers, facades, respuestas JSON, vistas, etc. Estas van en tests/Feature.

Laravel ya incluye ejemplos básicos, como ExampleTest.php, que contiene un “hola mundo”.

Por defecto, ya Laravel viene con algunas pruebas y archivos listos para usar, una de las pruebas de ejemplo es el de ExampleTest.php y que trae el hola mundo para nuestra aplicación.

Independientemente si estás empleando Pest o PHPUnit, la lógica es la misma, lo que cambia es la sintaxis y para ejecutar nuestras pruebas tenemos el comando de:

$ vendor/bin/phpunit

Para PHPUnit, o:

$ vendor/bin/pest

Para Pest, o más fácil:

$ php artisan test

Para cualquiera de los anteriores.

Adicionalmente, puedes crear un archivo .env.testing en la raíz de su proyecto para manejar las configuraciones en ambiente prueba. Este archivo se utilizará en lugar del archivo .env cuando se ejecuten pruebas de Pest y PHPUnit o se ejecuten comandos de Artisan con la opción --env=testing.

Carpeta de tests

⚙️ Configuración del entorno de pruebas

Archivos y carpetas clave

Laravel ya incluye una estructura básica para testing dentro de la carpeta /tests. Allí encontrarás dos tipos principales:

  • Feature: pruebas que abarcan la aplicación completa.
  • Unit: pruebas que validan funciones específicas.

Además, el archivo phpunit.xml define la configuración principal de PHPUnit, mientras que Pest utiliza tests/Pest.php para registrar funciones globales y configuraciones como traits o base de clases.

Base de datos en modo testing

Laravel facilita el manejo de bases de datos temporales durante las pruebas. El trait RefreshDatabase asegura que cada prueba comience con una base limpia.

uses(TestCase::class, RefreshDatabase::class)->in('Feature');

Factories y seeders

Puedes crear datos rápidamente con factories:

Category::factory(10)->create();

En mi caso, uso esto para poblar categorías, usuarios o posts antes de ejecutar las pruebas de CRUD.

Crear una Prueba

Para crear una prueba unitaria:

$ php artisan make:test ClassTest --unit

Para crear una prueba de integración:

$ php artisan make:test ClassTest

Entendiendo las Pruebas

Para comenzar con algo simple, creamos una clase con operaciones matemáticas:

app\Utils\MathOperations.php

app/Utils/MathOperations.php
class MathOperations
{
   public function add($a, $b) { return $a + $b; }
   public function subtract($a, $b) { return $a - $b; }
   public function multiply($a, $b) { return $a * $b; }
   public function divide($a, $b) { return $a / $b; }
}

Luego generamos una prueba unitaria:

$ php artisan make:test MathOperationsTest --unit

Y en tests/Unit/MathOperationsTest.php agregamos los métodos para probar cada operación.

Esto generará un archivo en:

tests/Unit/MathOperationsTest.php

En el cual, creamos unas funciones que permitan probar los métodos anteriores para realizar operaciones matemáticas:

tests/Unit/MathOperationsTest.php

<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

// use App\Utils\MathOperations;

class MathOperations
{

    public function add($a, $b)
    {
        return $a + $b;
    }


    public function subtract($a, $b)
    {
        return $a - $b;
    }


    public function multiply($a, $b)
    {
        return $a * $b;
    }

    public function divide($a, $b)
    {
        return $a / $b;
    }
}

class MathOperationsTest extends TestCase
{

    // public function test_example(): void
    // {
    //     $this->assertTrue(true);
    // }

 public function testAdd()
    {
        $mathOperations = new MathOperations();
        $result = $mathOperations->add(2, 3);
        $this->assertEquals(5, $result);
    }
    public function testSubtract()
    {
        $mathOperations = new MathOperations();
        $result = $mathOperations->subtract(5, 3);
        $this->assertEquals(2, $result);
    }
    public function testSubtraction()
    {
        $mathOperations = new MathOperations();
        $result = $mathOperations->multiply(5, 3);
        $this->assertEquals(15, $result);
    }
    public function testDivide()
    {
        $mathOperations = new MathOperations();
        $result = $mathOperations->divide(8, 2);
        $this->assertEquals(4, $result);
    }
}

Para facilitar el ejercicio, copiamos el contenido de MathOperations dentro del archivo unitario.

En este ejemplo, tenemos cuatro métodos de prueba, uno por cada método definido en la clase auxiliar MathOperations que permite probar las operaciones de suma, resta, multiplicación y división respectivamente y con esto podemos apreciar el corazón de las pruebas que es mediante métodos assert o métodos de tipo aserción.

Puedes ver la inmensa lista completa en:

https://laravel.com/docs/master/http-tests#response-assertions

Aunque no te preocupes por tener que aprenderlos todos, usualmente usamos unos pocos de ellos.

Finalmente, para ejecutar las pruebas unitarias, usamos el comando de:

$ vendor/bin/phpunit

Y deberíamos de ver una salida como la siguiente:

Time: 00:00.850, Memory: 42.50 MB
OK (29 tests, 65 assertions)

Si provocamos algún error en la la clase auxiliar, como sumar dos veces el mismo parámetro, ignorando el otro:

public function add($a, $b)
{
    return $a + $a;
}

Y ejecutamos:

$ vendor/bin/phpunit

Veremos una salida como la siguiente:

/MathOperationsTest.php:47

FAILURES!
Tests: 29, Assertions: 65, Failures: 1.

Que indica claramente de que ocurrió un error.

Las pruebas unitarias no son infalibles, ya que, todo depende de las pruebas que ejecutemos, manteniendo el mismo error que provocamos antes, si la prueba fuera como la siguiente:

public function testAdd()
{
    $mathOperations = new MathOperations();
    $result = $mathOperations->add(2, 2);
    $this->assertEquals(4, $result);
}

Las pruebas pasarían:

Time: 00:00.733, Memory: 42.50 MB
OK (29 tests, 65 assertions)

Pero, claramente tenemos un problema en la definición de la clase auxiliar, por lo tanto, las pruebas no son infalibles, son solamente un medio para verificar que no encontramos errores en la aplicación pero no significa de que la aplicación está libre de errores, con esto, podemos tener un entendimiento básico y necesario de cómo funcionan las pruebas unitarias, con este ejemplo, podemos ahora a pasar a probar realmente módulos que conforman la aplicación.

Las pruebas se apoyan en métodos assert, como:

  • assertStatus: Verifica el código de estado en la respuesta.
  • assertOk: Verifica si la respuesta obtenida es de tipo 200.
  • assertJson: Verifica si la respuesta es de tipo JSON.
  • assertRedirect: Verifica si la respuesta es una redirección.
  • assertSee: Verifica en base a un string suministrador, si forma parte de la respuesta.
  • assertDontSee: Verifica si el string suministrado no forma parte de la respuesta.
  • assertViewIs: Verifica si la vista fue retornada por la ruta.
  • assertValid: Verifica si no hay errores de validación en el formulario enviado.

Los métodos assert no son más que condicionales avanzados.

Peticiones HTTP

Nuestra aplicación está formada por controladores que se consumen mediante peticiones HTTP. Este tipo de pruebas se realiza mediante métodos como:

  • get
  • post
  • put
  • patch
  • delete

Y requieren heredar de:

use Tests\TestCase;

Pruebas unitarias con PHPUnit en Laravel

PHPUnit es el framework de testing más veterano en el ecosistema PHP. Laravel lo integra perfectamente y ofrece una sintaxis clara para definir clases de prueba.

Estructura básica

class PostTest extends TestCase
{
   use DatabaseMigrations;
   protected function setUp(): void
   {
       parent::setUp();
       User::factory(1)->create();
       $user = User::first();
       $this->actingAs($user);
   }
   public function test_index()
   {
       $response = $this->get(route('post.index'))
           ->assertOk()
           ->assertViewIs('dashboard.post.index')
           ->assertSee('Dashboard');
   }
}

Métodos de aserción más usados

assertOk() — Verifica respuestas HTTP 200.

assertStatus(404) — Verifica códigos de estado específicos.

Video thumbnail

Estos son los primeros métodos de aserción que veremos y los más imprescindibles que debemos usar al momento de evaluar nuestras pruebas. En primer lugar, tenemos assertStatus() y assertOk(), siendo este último equivalente a usar assertStatus(200).

El código 200 corresponde al estado HTTP que se devuelve en una petición normal, como la que tenemos aquí. Si cargamos esta página, por defecto obtenemos un 200, ya que es el código utilizado para indicar que todo está “Ok”.

assertSee('Texto') — Confirma que una vista contiene cierto texto.

Video thumbnail

Otro método de aserción imprescindible es assertSee, el cual nos indica qué es lo que está “viendo” la vista. Simplemente le pasamos un texto, y dicho texto debe estar presente en la respuesta obtenida.

En este caso, esta sería una prueba para la vista de detalle, y por lo tanto debemos asegurarnos de que los elementos principales aparezcan correctamente. Sería esta belleza que tenemos aquí: el título del post, la categoría y el contenido.

Entonces, primero obtenemos el post (que generamos con sus dependencias). Luego, buscamos el post por su ID y verificamos que la vista contenga el título, el contenido y, por supuesto, la categoría asociada.

Para eso sirve assertSee: asegurarnos de que elementos concretos de texto están visibles en la vista retornada por la aplicación.

assertViewHas('posts') — Comprueba que una vista recibe un parámetro.

Video thumbnail

Otro método de aserción imprescindible, que debemos emplear cuando trabajamos con controladores que devuelven una vista, es el método assertViewHas(). Con este método podemos indicar el nombre del parámetro que esperamos recibir y, además, especificar qué debe contener dicho parámetro.

En este caso, el parámetro llamado posts (en plural) debe contener una paginación con solo dos niveles. Esto es exactamente lo que podemos ver aquí, ya que es lo que está devolviendo el controlador. Para este escenario funciona perfectamente el método de aserción assertViewHas().

assertDatabaseHas('posts', $data) — Valida registros en base de datos.

⚡ Migrar de PHPUnit a Pest paso a paso

PestPHP es una alternativa moderna y minimalista a PHPUnit, compatible al 100%. Su principal ventaja es la sintaxis más limpia y legible.

Diferencias clave

  • Pest no usa clases, sino funciones globales (test() o it()).
  • setUp() se reemplaza por beforeEach().
  • Los assertions se escriben igual, salvo pequeñas diferencias.
  • assertStringContainsString()  se reemplaza por  assertMatchesRegularExpression()
  • setUp()    beforeEach()
  • Clases con extends TestCase  se reemplaza por  Funciones test() o it()
  • @test  se reemplaza por  test('...')

Ejemplo práctico

PHPUnit:

class CategoryTest extends TestCase
{
   function test_all() {
       $this->get('/api/category/all')
            ->assertOk()
            ->assertJson([...]);
   }
}

Pest:

test('test all', function () {
   $this->get('/api/category/all')
        ->assertOk()
        ->assertJson([...]);
});


En mi caso, al migrar las pruebas, descubrí que la lógica era idéntica, solo cambiaba la forma de escribirla. La función beforeEach() me resultó ideal para preparar usuarios y roles:

beforeEach(function () {
   User::factory(1)->create();
   $this->user = User::first();
   $this->actingAs($this->user);
});

Puedes ver el código de la prueba en PHPUnit y comparar tu mismo los cambios en base a lo comentado antes:

<?php
namespace Tests\Feature\dashboard;

use App\Models\Category;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseMigrations;

use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

class PostTest extends TestCase
{
    use DatabaseMigrations;
    public User $user;
    protected function setUp(): void
    {
        parent::setUp();

        User::factory(1)->create();
        $this->user = User::first();

        $role = Role::firstOrCreate(['name' => 'Admin']);

        Permission::firstOrCreate(['name' => 'editor.post.index']);
        Permission::firstOrCreate(['name' => 'editor.post.create']);
        Permission::firstOrCreate(['name' => 'editor.post.update']);
        Permission::firstOrCreate(['name' => 'editor.post.destroy']);

        $role->syncPermissions([1, 2, 3, 4]);

        $this->user->assignRole($role);

        $this->actingAs($this->user);
    }
    function test_index()
    {
        User::factory(1)->create();
        $user = User::first();

        $this->actingAs($user);

        Category::factory(3)->create();
        User::factory(3)->create();
        Post::factory(20)->create();

        $response = $this->get(route('post.index'))
            ->assertOk()
            ->assertViewIs('dashboard.post.index')
            ->assertSee('Dashboard')
            ->assertSee('Create')
            ->assertSee('Show')
            ->assertSee('Delete')
            ->assertSee('Edit')
            ->assertSee('Id')
            ->assertSee('Title')
            // ->assertViewHas('posts', Post::paginate(10))
        ;

        $this->assertInstanceOf(LengthAwarePaginator::class, $response->viewData('posts'));
    }

    function test_create_get()
    {

        Category::factory(10)->create();

        $response = $this->get(route('post.create'))
            ->assertOk()
            ->assertSee('Dashboard')
            ->assertSee('Title')
            ->assertSee('Slug')
            ->assertSee('Content')
            ->assertSee('Category')
            ->assertSee('Description')
            ->assertSee('Posted')
            ->assertSee('Send')
            ->assertViewHas('categories', Category::pluck('id', 'title'))
            ->assertViewHas('post', new Post());
        $this->assertInstanceOf(Post::class, $response->viewData('post'));
        $this->assertInstanceOf(Collection::class, $response->viewData('categories'));
    }

    function test_create_post()
    {
        Category::factory(1)->create();

        $data = [
            'title' => 'Title',
            'slug' => 'title',
            'content' => 'Content',
            'description' => 'Content',
            'category_id' => 1,
            'posted' => 'yes',
            'user_id' => $this->user->id
        ];

        $this->post(route('post.store', $data))
            ->assertRedirect(route('post.index'));

        $this->assertDatabaseHas('posts', $data);
    }
    function test_create_post_invalid()
    {
        Category::factory(1)->create();

        $data = [
            'title' => '',
            'slug' => '',
            'content' => '',
            'description' => '',
            // 'category_id' => 1,
            'posted' => '',
        ];

        $this->post(route('post.store', $data))
            ->assertRedirect('/')
            ->assertSessionHasErrors([
                'title' => 'The title field is required.',
                'slug' => 'The slug field is required.',
                'content' => 'The content field is required.',
                'description' => 'The description field is required.',
                'posted' => 'The posted field is required.',
                'category_id' => 'The category id field is required.',
            ]);

    }
    function test_edit_get()
    {
        User::factory(3)->create();
        Category::factory(10)->create();
        Post::factory(1)->create();
        $post = Post::first();

        $response = $this->get(route('post.edit', $post))
            ->assertOk()
            ->assertSee('Dashboard')
            ->assertSee('Title')
            ->assertSee('Slug')
            ->assertSee('Content')
            ->assertSee('Category')
            ->assertSee('Description')
            ->assertSee('Posted')
            ->assertSee('Send')
            ->assertSee($post->title)
            ->assertSee($post->content)
            ->assertSee($post->description)
            ->assertSee($post->slug)
            ->assertViewHas('categories', Category::pluck('id', 'title'))
            ->assertViewHas('post', $post);
        $this->assertInstanceOf(Post::class, $response->viewData('post'));
        $this->assertInstanceOf(Collection::class, $response->viewData('categories'));
    }

    function test_edit_put()
    {
        User::factory(3)->create();
        Category::factory(10)->create();
        Post::factory(1)->create();
        $post = Post::first();

        $data = [
            'title' => 'Title',
            'slug' => 'title',
            'content' => 'Content',
            'description' => 'Content',
            'category_id' => 1,
            'posted' => 'yes'
        ];

        $this->put(route('post.update', $post), $data)
            ->assertRedirect(route('post.index'));

        $this->assertDatabaseHas('posts', $data);
        $this->assertDatabaseMissing('posts', $post->toArray());
    }

    function test_edit_put_invalid()
    {
        User::factory(3)->create();
        Category::factory(10)->create();
        Post::factory(1)->create();
        $post = Post::first();

        $this->get(route('post.edit', $post));

        $data = [
            'title' => 'a',
            'slug' => '',
            'content' => '',
            'description' => '',
            // 'category_id' => 1,
            'posted' => '',
        ];

        $this->put(route('post.update', $post), $data)
            ->assertRedirect(route('post.edit', $post))
            ->assertSessionHasErrors([
                'title' => 'The title field must be at least 5 characters.',
                'slug' => 'The slug field is required.',
                'content' => 'The content field is required.',
                'description' => 'The description field is required.',
                'posted' => 'The posted field is required.',
                'category_id' => 'The category id field is required.',
            ])
        ;

    }

    function test_edit_destroy()
    {
        User::factory(3)->create();
        Category::factory(10)->create();
        Post::factory(1)->create();
        $post = Post::first();

        $data = [
            'id' => $post->id
        ];

        $this->delete(route('post.destroy', $post))
            ->assertRedirect(route('post.index'));

        $this->assertDatabaseMissing('posts', $data);
    }

}

Y con Pest:

<?php

use App\Models\User;
use App\Models\Post;
use App\Models\Category;

use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;

use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

beforeEach(function () {
    User::factory(1)->create();
    $this->user = User::first();

    $role = Role::firstOrCreate(['name' => 'Admin']);

    Permission::firstOrCreate(['name' => 'editor.post.index']);
    Permission::firstOrCreate(['name' => 'editor.post.create']);
    Permission::firstOrCreate(['name' => 'editor.post.update']);
    Permission::firstOrCreate(['name' => 'editor.post.destroy']);

    $role->syncPermissions([1, 2, 3, 4]);

    $this->user->assignRole($role);

    $this->actingAs($this->user);
});

test('test index', function () {
    Category::factory(3)->create();
    User::factory(3)->create();
    Post::factory(20)->create();

    $response = $this->get(route('post.index'))
        ->assertOk()
        ->assertViewIs('dashboard.post.index')
        ->assertSee('Dashboard')
        ->assertSee('Create')
        ->assertSee('Show')
        ->assertSee('Delete')
        ->assertSee('Edit')
        ->assertSee('Id')
        ->assertSee('Title')
        // ->assertViewHas('posts', Post::paginate(10))
    ;

    $this->assertInstanceOf(LengthAwarePaginator::class, $response->viewData('posts'));


});
test('test create get', function () {
    Category::factory(10)->create();

    $response = $this->get(route('post.create'))
        ->assertOk()
        ->assertSee('Dashboard')
        ->assertSee('Title')
        ->assertSee('Slug')
        ->assertSee('Content')
        ->assertSee('Category')
        ->assertSee('Description')
        ->assertSee('Posted')
        ->assertSee('Send')
        ->assertViewHas('categories', Category::pluck('id', 'title'))
        ->assertViewHas('post', new Post());
    $this->assertInstanceOf(Post::class, $response->viewData('post'));
    $this->assertInstanceOf(Collection::class, $response->viewData('categories'));
});
test('test create post', function () {

    Category::factory(1)->create();

    $data = [
        'title' => 'Title',
        'slug' => 'title',
        'content' => 'Content',
        'description' => 'Content',
        'category_id' => 1,
        'posted' => 'yes',
        'user_id' => $this->user->id
    ];

    $this->post(route('post.store', $data))
        ->assertRedirect(route('post.index'));

    $this->assertDatabaseHas('posts', $data);

});
test('test create post invalid', function () {

    Category::factory(1)->create();

    $data = [
        'title' => '',
        'slug' => '',
        'content' => '',
        'description' => '',
        // 'category_id' => 1,
        'posted' => '',
    ];

    $this->post(route('post.store', $data))
        ->assertRedirect('/')
        ->assertSessionHasErrors([
            'title' => 'The title field is required.',
            'slug' => 'The slug field is required.',
            'content' => 'The content field is required.',
            'description' => 'The description field is required.',
            'posted' => 'The posted field is required.',
            'category_id' => 'The category id field is required.',
        ]);
});


test('test edit get', function () {
    User::factory(3)->create();
    Category::factory(10)->create();
    Post::factory(1)->create();
    $post = Post::first();

    $response = $this->get(route('post.edit', $post))
        ->assertOk()
        ->assertSee('Dashboard')
        ->assertSee('Title')
        ->assertSee('Slug')
        ->assertSee('Content')
        ->assertSee('Category')
        ->assertSee('Description')
        ->assertSee('Posted')
        ->assertSee('Send')
        ->assertSee($post->title)
        ->assertSee($post->content)
        ->assertSee($post->description)
        ->assertSee($post->slug)
        ->assertViewHas('categories', Category::pluck('id', 'title'))
        ->assertViewHas('post', $post);
    $this->assertInstanceOf(Post::class, $response->viewData('post'));
    $this->assertInstanceOf(Collection::class, $response->viewData('categories'));
});


test('test edit put', function () {

    User::factory(3)->create();
    Category::factory(10)->create();
    Post::factory(1)->create();
    $post = Post::first();

    $data = [
        'title' => 'Title',
        'slug' => 'title',
        'content' => 'Content',
        'description' => 'Content',
        'category_id' => 1,
        'posted' => 'yes'
    ];

    $this->put(route('post.update', $post), $data)
        ->assertRedirect(route('post.index'));

    $this->assertDatabaseHas('posts', $data);
    $this->assertDatabaseMissing('posts', $post->toArray());
});
test('test edit put invalid', function () {

    User::factory(3)->create();
    Category::factory(10)->create();
    Post::factory(1)->create();
    $post = Post::first();

    $this->get(route('post.edit', $post));

    $data = [
        'title' => 'a',
        'slug' => '',
        'content' => '',
        'description' => '',
        // 'category_id' => 1,
        'posted' => '',
    ];

    $this->put(route('post.update', $post), $data)
        ->assertRedirect(route('post.edit', $post))
        ->assertSessionHasErrors([
            'title' => 'The title field must be at least 5 characters.',
            'slug' => 'The slug field is required.',
            'content' => 'The content field is required.',
            'description' => 'The description field is required.',
            'posted' => 'The posted field is required.',
            'category_id' => 'The category id field is required.',
        ])
    ;
});

test('test destroy', function () {
    
    User::factory(3)->create();
    Category::factory(10)->create();
    Post::factory(1)->create();
    $post = Post::first();

    $data = [
        'id' => $post->id
    ];

    $this->delete(route('post.destroy', $post))
        ->assertRedirect(route('post.index'));

    $this->assertDatabaseMissing('posts', $data);

});

Resto de las pruebas:

https://github.com/libredesarrollo/book-course-laravel-base-11

Prueba en una API con autenticación por tokens y Pest

Y para autenticación API, implementé un helper global:

function generateTokenAuth()
{
   User::factory()->create();
   return User::first()->createToken('myapptoken')->plainTextToken;
}

A partir de este mismo método, podemos emplearlo en cualquier parte sin ningún problema.

Por ejemplo, en nuestro test, podemos utilizarlo para probar la obtención de las categorías sin paginar, tal como lo tenemos aquí: se recuperan todas las categorías y puedes ver que no hay ningún cambio.

De manera similar, podemos usar assertOk() para verificar el código HTTP y assertJson() para comprobar la respuesta en formato JSON.

Por lo demás, aquí también utilizamos Factory para generar los datos de prueba de manera automática.

test('test all', function () {

    Category::factory(10);
    $categories = Category::get()->toArray();

    $this->get(
        '/api/category/all',
        [
            'Authorization' => 'Bearer ' . generateTokenAuth()
        ]
    )->assertOk()->assertJson($categories);
});

Luego, hacer la petición, al igual que hacemos con PHPUnit:

$this->get(
        '/api/category/all',
        [
            'Authorization' => 'Bearer ' . generateTokenAuth()
        ]

En este caso, le pasamos el token directamente como parámetro. La única diferencia es que ya no usamos el encabezado (header) para definirlo; en cambio, lo pasamos directamente:

'Bearer ' . generateTokenAuth()

Recuerda que, si tienes alguna duda, puedes dejarla en el bloque de comentarios. No voy a repetir la explicación, porque ya lo hemos hecho varias veces. Nuevamente, la generación de datos de prueba y los métodos de aserción son exactamente los mismos.

Para pruebas de creación o actualización, también aplicamos lo mismo que antes. La diferencia principal está en este cambio:

Anteriormente usábamos:

$this->assertStringContainsString("The title field is required.", $response->getContent());

Este método ya no existe, y en su lugar debemos emplear:

$this->assertMatchesRegularExpression("/The title field is required./", $response->getContent());

Lo que indica que estamos evaluando la respuesta en base a una expresión regular, funcionando de manera similar a contains.

El resto del código permanece igual:

  • Se mantienen las verificaciones con assertContent(), assertStatus(), assertOk(), según corresponda.

Las pruebas de métodos put o de creación son muy similares entre sí.

Al final, realicé una pequeña refactorización del archivo de pruebas: eliminé la variable response intermedia y coloqué todos los códigos directamente en el archivo de prueba. Esto mantiene la prueba más clara y ordenada, sin afectar su funcionamiento.

Ejecución y depuración de pruebas

Para ejecutar tus pruebas en Laravel puedes usar cualquiera de los siguientes comandos:

$ php artisan test

O

vendor/bin/pest

Ambos muestran un resumen claro de los tests ejecutados. En mi flujo, siempre provoco un error intencional antes de confirmar que todo funciona, para verificar que el sistema de testing responde correctamente.

Método de setUp() en PHPUnit para el código común

En este ejemplo, estamos usando permisos con Spatie y podemos tener un método común para poder crear los roles y permisos y luego, en cada prueba, este método se ejecuta ANTES y llena la base de datos con estos registros para poder luego implementarlas pruebas.

tests/Feature/dashboard/PostTest.php

class PostTest extends TestCase
{
    use DatabaseMigrations;

    protected function setUp(): void
    {     
        parent::setUp();

        User::factory(1)->create();
        $user = User::first();
        // dd($user);
        $role = Role::firstOrCreate(['name' => 'Admin']);
        Permission::firstOrCreate(['name' => 'editor.post.index']);
        Permission::firstOrCreate(['name' => 'editor.post.create']);
        Permission::firstOrCreate(['name' => 'editor.post.edit']);
        Permission::firstOrCreate(['name' => 'editor.post.delete']);
        $role->syncPermissions([1,2,3,4]);

        $user->assignRole($role);

        $this->actingAs($user);//->withSession(['role' => 'Admin']);
    }
}

En el código anterior, usamos el método de setUp() que se ejecuta antes de cada prueba, en dicho método, podemos colocar código común para ejecutar en cada una de las pruebas, en este ejemplo, la de crear el usuario y establecer un rol de Admin y permisos.

Además, una vez configurado el usuario, configuramos la autenticación mediante actingAs() al cual, podemos establecer datos en sesión en caso de que sea necesario.

Habilitar DB_CONNECTION y el DB_DATABASE en los phpunit.xml en Laravel

Te quería explicar la importancia de porqué tenemos que configurar una base de datos al momento del desarrollo o para el ambiente de testing. Por defecto, yo tengo estos datos:

Usualmente las pruebas unitarias se deben de realizar en una base de datos de prueba, que no sea la de desarrollo y mucho menos la de producción, de momento, hemos estado empleando la base de datos que empleamos en desarrollo, entonces, todas las operaciones realizadas por las pruebas persisten en la misma y con esto, no tenemos un entorno controlado para hacer las pruebas, para establecer una base de datos paralela para hacer las pruebas debemos de realizar una configuración desde el siguiente archivo:

phpunit.xml

***
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="APP_MAINTENANCE_DRIVER" value="file"/>
        <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="PULSE_ENABLED" value="false"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
    </php>
***

Aquí puedes personalizar la base de datos a emplear, en este ejemplo, SQLite (DB_DATABASE) y que sea en memoria  (DB_CONNECTION), lo que significa que las operaciones a la base de datos se van a realizar en una base de datos en memoria y no haciendo operaciones de lectura/escritura sobre la base de datos.

Fijate que, está empleando aquí nuestra base de datos de desarrollo y qué pasaría si ejecutamos otra vez básicamente va a eliminar TODA la base de datos de desarrollo, para evitar esto, debemos de activar la base de datos para testing y en memoria para que las operaciones se simulen en memoria y no realizar las operaciones en la base de datos y con esto, sean más rápidos:

***
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="APP_MAINTENANCE_DRIVER" value="file"/>
        <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="PULSE_ENABLED" value="false"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
    </php>
***

Buenas prácticas

  • Nombrar las pruebas según el comportamiento que validan.
  • Evitar dependencias entre pruebas.
  • Mantener datos limpios con RefreshDatabase.
  • Integrar testing en tu pipeline CI/CD (por ejemplo, GitHub Actions).

⚖️ PHPUnit vs Pest: comparativa rápida

Aspecto    PHPUnit    Pest
Sintaxis    Tradicional (clases y métodos)    Moderna, minimalista
Curva de aprendizaje    Ligeramente más técnica    Muy amigable
Compatibilidad    Total con Laravel    Total (usa PHPUnit internamente)
Enfoque    Formal y estructurado    Ligero y expresivo
Ideal para    Equipos grandes con proyectos legacy    Proyectos nuevos o de rápido desarrollo

En mi experiencia, no existe una “mejor” opción universal. Si ya tienes un proyecto avanzado, PHPUnit es más estable. Pero si comienzas desde cero o prefieres una sintaxis fluida, Pest es un placer de usar.

Pest PHP: Pruebas en Paralelo y Cobertura

Si tienes una suite de pruebas que tarda 8 segundos porque cada test hace un sleep(2), al ejecutar pest --parallel, el tiempo se reduce a solo 2 segundos al aprovechar todos los núcleos de tu procesador.

Cobertura de Código y de Tipos

  • Code Coverage: Con --coverage obtienes un reporte visual de qué partes de tu código no tienen pruebas. Puedes definir un mínimo (ej. 40%) y si alguien sube código que baje ese promedio, el test fallará.
  • Type Coverage: Con --type-coverage Pest te dice qué porcentaje de tu código no tiene tipos definidos. ¡Ideal para mantener la calidad!

Laravel Pint: Estilo de Código sin Esfuerzo

Por defecto, Pint funciona "por arte de magia" sin configuración. Pero si configuramos el pint.json para interactuar con el motor subyacente (PHPCS Fixer), podemos hacer cosas como estas:

  • Tipos estrictos y clases finales: Podemos ordenar a Pint que añada declare(strict_types=1); al inicio de cada archivo y convierta cada clase en final.
  • Orden de métodos: Puedes configurar que los métodos privados siempre aparezcan al final de la clase para priorizar la lectura de los métodos públicos.
  • Comparaciones estrictas: Pint puede buscar automáticamente cada comparación débil (==) y reemplazarla por una estricta (===).
{
    "rules": {
        "declare_strict_types": true,
        "final_class": true,
        "strict_comparison": true
    }
}

⌨️ Peck PHP: Adiós a los errores de ortografía

Si tienes una propiedad llamada naame (con doble 'e'), Pack la detectará. Ejecutas vendor/bin/pack en tu terminal, y te señalará exactamente en qué archivo y línea está el error, incluso dentro de las anotaciones o comentarios. Es ideal para que tu código se vea profesional y consistente.

PHPStan: El "TypeScript" para PHP

PHPStan es el equivalente a TypeScript pero para PHP. Realiza una verificación estática de tipos y te dice qué está mal antes de que ejecutes el código.

¿Qué detecta PHPStan?

Código inalcanzable: Si tienes un return después de un dd() o un die().

Errores de tipo en retornos: Si tu método dice que devuelve un int pero en realidad devuelve una View.

Clases no importadas: Detecta si olvidaste hacer el use de una clase.

Un ejemplo:

public function index(): int{
  dd()
  return view(***);
}
$/vendor/bin/phpstan

️ Rector PHP: Refactorización Automática

Si PHPStan te dice qué está mal, Rector PHP lo arregla por ti. Es perfecto para modernizar aplicaciones antiguas (de PHP 5.x a PHP 8.4) en segundos.

Ejemplo de Refactorización

  • Rector puede transformar automáticamente:
  • Sintaxis de arreglos vieja array() a la moderna [].
  • Sentencias switch a expresiones match.
  • Eliminar "código muerto" (variables o condiciones innecesarias).
  • Añadir tipos de retorno faltantes.
public function index(): int{
  $result = array()
}

public function user(User $user): User{
   if(true){
     $user = $user
   }
   
   return $user;
}

Al ejecutar:

$/vendor/bin/phpstan

Obtenemos el código saneado:

public function index(): int{
  $result = []
}

public function user(User $user): User{
   return $user;
}

Pruebas unitarias e integración en Laravel Livewire

Video thumbnail

En Livewire, también podemos crear pruebas como hacemos las Pruebas Unitarias y de integración con Laravel base, su estructura es lo mismo una clase con extensión de test para que se para que se entienda que es una prueba extiende de TestCase:

tests/Feature/Blog/IndexTest.php

namespace Tests\Feature\Blog;

// use Illuminate\Foundation\Testing\RefreshDatabase;
// use Illuminate\Foundation\Testing\WithFaker;
// use PHPUnit\Framework\TestCase;

use Livewire\Livewire;
use Tests\TestCase;

use App\Livewire\Blog\Index;
use App\Models\Post;

class IndexTest extends TestCase
{
   /**
    * A basic feature test example.
    */
   public function test_index(): void
   {
       $this
           ->get(route('web.index'))
           ->assertSeeLivewire(Index::class)
           ->assertStatus(200)
           ->assertSee("Post List")
           // ->assertViewHas('posts', Post::paginate(15))
           // ->assertViewIs('livewire.blog.index');
       ;
   }
}

Es fundamental que en nuestros archivos de prueba aparezca la clase TestCase. Esto es lo que nos permite emplear el conjunto de métodos de tipo GET, POST, PUT y DELETE para realizar peticiones a las rutas de nuestra aplicación.

Es importante aclarar que no me refiero al funcionamiento interno de Laravel Livewire para sincronizar propiedades, sino a probar los componentes de nuestra aplicación. Por defecto, ya sea que emplees Pest o PHPUnit, ambos tienen una clase llamada TestCase. Sin embargo, hay una diferencia crucial en las importaciones:

// use PHPUnit\Framework\TestCase;
use Tests\TestCase;

Laravel incorpora estos métodos mediante un anidamiento de clases propio del framework. Gracias a esto, podemos evaluar componentes de múltiples formas. Por ejemplo, podemos usar aserciones para saber si un componente se está empleando internamente o si estamos usando correctamente los elementos de formulario, como el componente button de Livewire.

Comparado con Inertia, que es un poco más cerrado porque las pruebas suelen hacerse directamente en Vue, Livewire ofrece opciones más ricas para el testing desde el lado del servidor:

Livewire::test(Index::class)
  ->assertSee("Post List")
  ->assertViewHas('posts', Post::with('category')

Pest

Si ya has realizado mi curso de Laravel Base, conocerás la filosofía: las pruebas no son solo una fachada. Su verdadero valor aparece cuando la aplicación crece, tiene 50,000 módulos y necesitas garantizar que un cambio en el punto A no rompa el punto B. Aunque en este curso la aplicación es finita, es vital que entiendas cómo crear y ejecutar una "bendita prueba" antes de enfrentarte a proyectos del mundo real.

El Entorno de Pruebas: Pest vs. PHPUnit

En este curso estamos utilizando Pest, que es mi recomendación personal por su sintaxis descriptiva y limpia. Aunque en versiones anteriores o en mi libro utilicé PHPUnit, funcionalmente son equivalentes; lo que cambia es la forma de escribir los métodos (usando it() o test()).

Para ejecutar tus pruebas, simplemente corre el comando:

$ php artisan test

Generación de Pruebas con IA

Hoy en día, la IA es nuestra mejor aliada para generar el esqueleto de las pruebas. Sin embargo, como han visto en la demostración, hay que auditar lo que genera. A veces la IA ignora las mejores prácticas de Pest o comete errores de sintaxis.

Lo que debemos verificar en una prueba de Livewire:

  • Renderizado: ¿La página carga el componente correcto? (assertSee)
  • Autenticación: ¿El componente está protegido? (actingAs($user))
  • Propiedades: ¿Se establecen los valores correctamente? (set('title', '...'))
  • Validación: ¿Falla cuando los datos son nulos o incorrectos? (assertHasErrors)

A diferencia de Laravel Base, donde probamos peticiones HTTP y rutas, en Livewire además probamos componentes, para ello, tenemos la clase de Livewire para las pruebas:

it('renders the blog show page with post', function () {
    $post = Post::factory()->create();

    Livewire::test('pages::blog.show', ['post' => $post])
        ->assertSee($post->title);
});

test('team invitations cannot be created by members', function () {
    $owner = User::factory()->create();
    $member = User::factory()->create();
    $team = Team::factory()->create();

    $team->members()->attach($owner, ['role' => TeamRole::Owner->value]);
    $team->members()->attach($member, ['role' => TeamRole::Member->value]);

    $this->actingAs($member);

    Livewire::test('pages::teams.invite-member-modal', ['team' => $team])
        ->set('inviteEmail', 'invited@example.com')
        ->set('inviteRole', TeamRole::Member->value)
        ->call('createInvitation')
        ->assertForbidden();
});

Configuración y Base de Datos Limpia

En el archivo Pest.php configuramos el trait RefreshDatabase. Esto garantiza que cada prueba se ejecute en una base de datos limpia, evitando que los datos de una prueba "ensucien" o afecten los resultados de la siguiente. Por eso, siempre debemos crear nuestros registros de prueba (factories) dentro de cada test.

Finalmente, para generar al menos unas pruebas iniciales para empezar a trabajar, puedes ejecutar algo como:

$ php artisan pest:test pages/blog/indexTest

Para generar el componente y la prueba, o:

$ php artisan pest:test pages/blog/indexTest --feature

Para generar solamente la prueba.

Las pruebas son una red de seguridad. En tu día a día profesional, la cantidad y calidad de estas dependerá de las reglas de tu empresa, pero aquí ya tienes la base para empezar.

Primeros pasos en las Pruebas en Laravel Livewire

Video thumbnail

Vamos a iniciar creando nuestra prueba para el módulo de Blog, específicamente para el Index. Lo primero y más importante es ejecutar el comando de pruebas y verificar que todo pase correctamente:

$ php artisan test

Si algo falla, debemos revisar qué está mal antes de continuar. Para este ejercicio, realizaré las pruebas mínimas necesarias. Podríamos probar distintos filtros, pero para no complicar el asunto al inicio, probaremos simplemente el acceso sin filtros aplicados para validar que se muestre el listado de publicaciones y categorías.

<?php

namespace Tests\Feature\Blog;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;

// use PHPUnit\Framework\TestCase;

use Tests\TestCase;

class IndexTest extends TestCase
{
    public function test_example(): void
    {
        $response = $this->get('/');
        $response->assertStatus(200);
    }
}

Primera prueba con Laravel Livewire

Video thumbnail

En una prueba rutinaria de Laravel básico, comenzaríamos verificando un status 200, el nombre de la ruta y los datos pasados a la vista. Sin embargo, al usar Livewire, la lógica cambia un poco.

Recomendación: Antes de empezar, duplica tu base de datos actual (copia y pega el archivo .sqlite o haz un respaldo) para no perder tus datos de prueba actuales. Más adelante configuraremos una base de datos específica para el entorno de testing.

Identificando la ruta

Debemos tener clara la ruta a probar (en este caso /blog). Si intentas acceder a una ruta inválida, obtendrás un error 404; si esperabas un 200 y recibes un 404, sabrás que el problema está en la definición de la ruta.

Aquí tienes el esquema inicial de la prueba:

<?php

namespace Tests\Feature\Blog;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class IndexTest extends TestCase
{
    public function test_example(): void
    {
        // Petición de tipo GET a la raíz o a /blog
        $response = $this->get('/blog');

        $response->assertStatus(200);
    }
}

Diferencia entre Feature y Unit Tests

Es vital heredar de Tests\TestCase. Si heredas accidentalmente de la clase interna de PHPUnit, el método $this->get() no existirá y la prueba fallará.

Estas son pruebas de tipo Feature y no de tipo Unit porque no estamos probando un módulo aislado o una función matemática simple; estamos probando el módulo completo del blog como un todo, incluyendo sus componentes y su respuesta HTTP.

 $this->get(route('web.index'))->assertStatus(200);

Al realizar pruebas, es fundamental indicar qué método HTTP vamos a emplear. En este ejemplo, realizaremos una petición de tipo GET. Para que esto funcione, es imprescindible que la clase de prueba herede de Tests\TestCase.

Es común confundirse con las importaciones, ya que existen dos clases con el mismo nombre. Fíjate en la diferencia:

  • Incorrecto: PHPUnit\Framework\TestCase (Proviene del núcleo de PHPUnit).
  • Correcto: Tests\TestCase (La clase propia de Laravel).
// use PHPUnit\Framework\TestCase;
use Tests\TestCase;

Si utilizas la de PHPUnit por error, la prueba fallará al ejecutarse porque no encontrará los métodos necesarios para realizar peticiones. Aunque Tests\TestCase hereda lo básico de PHPUnit (o Pest, dependiendo de lo que estés usando), también agrega funcionalidades vitales de Laravel, como el manejo de peticiones HTTP.

Pruebas de Feature vs. Pruebas Unitarias

El uso de estas peticiones es imprescindible para nosotros, ya que nuestra meta es probar el módulo completo. No estamos evaluando funciones aisladas, sino nuestro módulo basado en componentes y la aplicación en su conjunto.

Por esta razón, estas son pruebas de tipo Feature (funcionales) y no de tipo Unit (unitarias). Es por ello que se encuentran organizadas en la carpeta de Feature, pues validamos la aplicación como un todo, específicamente en este caso el módulo de Blog.

Prueba Base en Laravel Livewire

Video thumbnail

Un aspecto fundamental es que podemos emplear rutas con nombre, aprovechando todas las bondades que esto nos trae (tal como vimos en el curso de Laravel básico).

Si decidimos cambiar la URL de blog por algo como /bloquecito, no tendremos que modificar nuestras pruebas, ya que están referenciadas por su nombre técnico, por ejemplo, web.index. Al usar la función route(), la prueba se adapta automáticamente al cambio:

public function test_index(): void {
  $this->get(route('web.index'))
}

Si lo hiciéramos de forma estática, como $this->get('blog'), cualquier cambio en la URL rompería el test devolviendo un error 404, obligándonos a actualizar el código manualmente en cada archivo de prueba.

public function test_index(): void {
  $this->get('blog')
}

✅ Métodos de Aserción y Código 200

Cuando probamos si una página se encontró correctamente, lo habitual es verificar el código de estado 200 (OK). Aunque en la documentación los métodos aparecen en orden alfabético y no siempre clasificados por utilidad, Laravel nos ofrece variantes muy potentes para evaluar:

  • Redirecciones.
  • Contenido HTML.
  • Si el usuario está autorizado o autenticado.

Lo mejor es que no tenemos que navegar manualmente por la respuesta para extraer valores; Laravel lo hace por nosotros de forma automática mediante sus métodos de aserción.

Si estamos empleando la clase TestCase de Laravel y queremos validar específicamente qué vista está cargando el componente, podemos usar el método assertViewIs:

public function test_index_view(): void {
   $response = $this->get(route('web.index'));
   
   $response->assertStatus(200);
   // Validamos que la vista sea la correcta
   $response->assertViewIs('web.index');
}

Esto nos permite asegurar que, más allá de que la página cargue, el usuario esté viendo exactamente la interfaz que le corresponde.

Probar código 200

Para verificar si la página se encontró correctamente, Laravel nos ofrece diversos métodos. Aunque en la documentación aparecen por orden alfabético y no siempre clasificados por utilidad, el más común es el que valida el estado "OK", que es equivalente al status 200.

Además de los estados básicos, existen variantes para probar redirecciones o contenido HTML. En el fondo, estas herramientas son condicionales, pero no son genéricos que solo evalúan valores exactos. También pueden aplicar lógica compleja, como verificar si un usuario está autorizado o autenticado. Lo mejor es que Laravel hace todo esto "de gratis" y automáticamente a través de sus métodos de aserción, sin necesidad de que nosotros naveguemos manualmente por la respuesta para extraer datos.

Aserciones Específicas para Livewire

Si estamos empleando la clase TestCase de Laravel y queremos validar qué vista se está cargando, utilizamos el método assertViewIs. Aquí podemos encadenar múltiples pruebas para verificar el comportamiento de nuestro componente:

Livewire::test(Index::class)
    ->assertSee("Post List")
    ->assertViewHas('posts', Post::with('category')
        ->where('posted', 'yes')->paginate(15))
        ->assertViewIs('livewire.blog.index')

A veces, necesitamos ver exactamente qué contiene la respuesta o qué datos están llegando a la vista. Para ello, podemos pasar un callback al método assertViewHas y realizar un volcado de datos con dd():

->assertViewHas('posts', function ($posts){
    dd(Post::paginate(15));
     dd($posts);
})

Sin embargo, hay que tener cuidado: al hacer un dd() de la página completa, el contenido suele ser tan extenso que la consola lo corta, lo que puede dificultar la lectura. Aunque en esta clase el propósito es experimentar y ver qué herramientas tenemos disponibles, este método es muy útil para depurar de forma puntual si los datos de la colección coinciden con lo esperado.

Evaluar parámetros importantes

También, podemos utilizar Livewire::test() para evaluar un componente de Laravel Livewire:

***
use Livewire\Livewire;

class IndexTest extends TestCase {
  public function test_index(): void
   {
       $this
           ->get(route('web.index'))
           ***
       ;

        Livewire::test(Index::class);
   }
}

Y desde aquí, podemos emplear los métodos se aserción que no pudimos emplear antes:

tests/Feature/Blog/IndexTest.php

***
use Livewire\Livewire;

class IndexTest extends TestCase {
  public function test_index(): void
   {
       $this
           ->get(route('web.index'))
           ***
       ;

         Livewire::test(Index::class)
           ->assertSee("Post List")
           ->assertViewHas('posts', Post::with('category')
               ->where('posted', 'yes')->paginate(15))
               ->assertViewIs('livewire.blog.index')
       ;

   }
}

Pruebas unitarias e integración en Laravel Inertia

Video thumbnail

vamos a realizar las pruebas para el proyecto creado anteriormente, para cada uno de los módulos, crearemos las pruebas no en el mismo orden en el cual fueron desarrollados los módulos en el libro, si no, crearemos las pruebas comenzando con los módulos más sencillos como sería el del blog.

Pruebas Automatizadas con Laravel Boost

Llegamos a la última parte del curso y es momento de hablar de un tópico esencial: el uso de las pruebas (testing). Aquí es donde la Inteligencia Artificial realmente brilla, especialmente cuando trabaja sobre un contexto existente. A diferencia de cuando le pides que "invente" algo, al generar pruebas sobre módulos que ya escribiste, es muy difícil que la IA alucine; simplemente analiza tu código y propone cómo evaluarlo.

Esto ha reducido enormemente la duración de esta sección, algo que se agradece, porque seamos sinceros: ¡hacer pruebas manualmente puede ser un fastidio!

1. Pruebas de Inertia vs. Pruebas Tradicionales

La gran diferencia que vamos a ver aquí es el uso de métodos de aserción específicos para componentes de Vue a través de Inertia, en lugar de simplemente evaluar respuestas de texto plano. En la documentación oficial verás tres métodos principales que debemos dominar:

  • has(): Se utiliza para verificar la estructura de los datos. Por ejemplo, si el componente recibe un prop llamado posts, podemos usar has para confirmar que existe o para medir su tamaño.
  • where(): Se emplea para evaluar valores absolutos. Si ya sabemos que el prop existe gracias al has, con where confirmamos que el contenido sea exactamente el que esperamos (un ID específico, un título concreto, etc.).

Generando Pruebas con la IA (Pest Framework)

Para este curso hemos instalado Pest, que es el framework de pruebas recomendado por su simplicidad y elegancia. La estrategia que seguiremos será pedirle a la IA que genere pruebas módulo por módulo.

En lugar de pedirle "hazme todas las pruebas", es mejor ser específico: "Genera las pruebas para el módulo PostController usando Pest y las mejores prácticas de Laravel".

Ejemplo de flujo de trabajo:

  • Contexto: Le pasamos a la IA el controlador que queremos probar (ej. PostController).
  • Ejecución: La IA generará un archivo en la carpeta tests/ siguiendo la convención de nombres (ej. PostControllerTest.php).
  • Verificación: La IA creará casos para el index (comprobando paginación de 15 registros), el show (casos de éxito y casos 404 para registros inexistentes) y filtros.

Prompt: Haz las pruebas para el modulo de app/Http/Controllers/Blog/PostController.php y usa la skill de Pest

Ejecución y Mejora Continua

Una vez que la IA nos entrega el código, simplemente ejecutamos el comando en la terminal:

$ php artisan test --compact tests/Feature/Blog/PostControllerTest.php       

Si alguna prueba falla, no te preocupes; es parte del proceso. Puedes iterar con la IA: "Mira, la prueba de filtrado falló porque la estructura de datos es X, ajústala".

Recuerda que, por filosofía de desarrollo, ninguna cantidad de pruebas garantiza que el software esté libre de errores, pero sí nos dan la seguridad necesaria para seguir evolucionando el código.

Primeros pasos con PHPUnit  (Creando Pruebas Manuales)

Usaremos el framework de pruebas de PHPUnit para crear cada una de las pruebas, pero, puedes emplear Pest si así lo prefieres ya que, hay casi que una relación de uno a uno con los métodos de aserción entre PHPUnit y Pest.

Comenzaremos creando las pruebas para el módulo de blog, es decir, para el listado y para la página de detalle.

Crearemos la prueba para el blog:

$ php artisan make:test Blog/PostTest

De Laravel a Inertia

Definiremos la siguiente prueba para el listado, la cual, nos permitirá ejemplificar los cambios entre las pruebas de Laravel o Inertia:

tests\Feature\BlogTest.php

<?php
namespace Tests\Unit\Blog;
// use PHPUnit\Framework\TestCase;
use Tests\TestCase;
class PostTest extends TestCase
{
    public function test_index(): void
    {
        $this->get(route('web.index'))
            ->assertViewIs('blog.show');
            ->assertStatus(200);
    }
}

La prueba anterior permite verificar que el listado de posts en el index devuelve un código de estado 200.

Ejecutamos:

$ php artisan test

Y veremos un resultado como:

Unable to locate file in Vite manifest: resources/js/Pages/Blog/Index.vue. (View: C:\Users\andre\Herd\inertiastore\resources\views\app.blade.php)

En el cual, nos indica que debemos de tener habilitado los archivos generados por vite, así que, o habilitas el modo desarrollo:

$ npm run dev

O puedes generar los archivos a producción:

$ npm run prod

Si ejecutas las pruebas otra vez, verás que ahora tenemos un error como el siguiente:

+++ Actual
@@ @@
-'blog.show'
+'app'

Esto se debe a que el método de assertViewIs() es empleado para verificar vistas de blade y no componentes en Vue, en base al error anterior, puedes ver que inertia internamente al emplear el método de inertia() para retornar un componente, emplea una vista llamada app:

->assertViewIs('app')

Si ejecutas otra vez, veras que ya no sucede el error anterior, pero, dejar definido una vista app que nosotros no estamos empleando, ya que nosotros. Estamos usando los componentes en Vue no tiene sentido, es ahora donde entran las aserciones creadas específicamente para inertia que veremos en el siguiente apartado.

Assert Inertia

En Inertia, tenemos aserciones específicas para trabajar con los componentes de Vue, para ello, debemos de importar a nivel del archivo de pruebas:

tests\Unit\Blog\PostTest.php

// use PHPUnit\Framework\TestCase;
use Tests\TestCase;
use Inertia\Testing\AssertableInertia as Assert;
class YourTest extends TestCase
  public function test_test(): void
    {
        $this->get(route('web.index'))
            ->assertInertia(fn (Assert $page) => dd($page)));
    }
}

En la prueba de listado, en cuyo controlador empleamos el componente de Assert, debemos de configurarlo de la siguiente manera a nivel de la prueba:

tests\Unit\Blog\PostTest.php

<?php
namespace Tests\Unit\Blog;
// use PHPUnit\Framework\TestCase;
use Tests\TestCase;
use Inertia\Testing\AssertableInertia as Assert;
class PostTest extends TestCase
{
    public function test_index(): void
    {
        $this->get(route('web.index'))
            // ->assertViewIs('app')
            ->assertStatus(200);
        $this->get(route('web.index'))
            ->assertInertia(fn (Assert $page) => dd($page)
                ->component('Blog/Index')
                ->has('posts', fn (Assert $page) => $page
                )
            );
    }
}

Y veremos que, si ejecutamos las prueba, la misma pasa; hay muchas verificaciones que se pueden realizar a nivel de las aserciones de Inertia como puedes ver en la documentación oficial:

https://inertiajs.com/testing

Aunque la sintaxis es algo extraña, vamos a ir paso a paso para saber exactamente que como está estructurada, evaluemos el objeto llamado $page a ver que provee:

->assertInertia(fn (Assert $page) => dd($page)

Al ejecutar la prueba, veremos una salida cómo la siguiente:

Inertia\Testing\AssertableInertia {#2397
  -props: array:14 [
    "errors" => []
    "jetstream" => array:11 [
      "canCreateTeams" => false
      "canManageTwoFactorAuthentication" => true
      "canUpdatePassword" => true
      "canUpdateProfileInformation" => true
      "hasEmailVerification" => false
      "flash" => []
      "hasAccountDeletionFeatures" => true
      "hasApiFeatures" => true
      "hasTeamFeatures" => true
      "hasTermsAndPrivacyPolicyFeature" => true
      "managesProfilePhotos" => true
    ]
    "auth" => array:1 [
      "user" => null
    ]
    "errorBags" => []
    "flash" => array:1 [
      "message" => null
    ]
    "step" => 1
    "cart" => []
    "posts" => array:13 [
      "current_page" => 1
      "data" => array:15 [
        0 => array:13 [
          "id" => 2
          "title" => "Post 5111"
          "slug" => "post-4"
          "date" => "2024-08-22"
          "image" => "1729333215.png"
          "text" => "asasasasas"
          "description" => "asasasas"
          "posted" => "not"
          "type" => "course"
          "category_id" => 1
          "created_at" => "2024-08-18T09:54:24.000000Z"
          ***
          "category_id" => 2
          "created_at" => "2024-09-21T09:43:12.000000Z"
          "updated_at" => "2024-09-21T09:43:12.000000Z"
          "category" => array:7 [
            "id" => 2
            "title" => "Cate 2"
            "slug" => "cate-2"
            "image" => null
            "text" => null
            "created_at" => "2024-08-15T10:08:19.000000Z"
            "updated_at" => "2024-08-15T10:08:19.000000Z"
          ]
        ]
      ]
      "first_page_url" => "http://inertiastore.test/blog?page=1"
      "from" => 1
      "last_page" => 47
      "last_page_url" => "http://inertiastore.test/blog?page=47"
      "links" => array:15 [
        0 => array:3 [
          "url" => null
          "label" => "&laquo; Previous"
          "active" => false
       ***
          "active" => false
        ]
      ]
      "next_page_url" => "http://inertiastore.test/blog?page=2"
      "path" => "http://inertiastore.test/blog"
      "per_page" => 15
      "prev_page_url" => null
      "to" => 15
      "total" => 702
    ]
    "categories" => array:2 [
      0 => array:7 [
        "id" => 1
        "title" => "Cate 1"
        "slug" => "category-1"
        "image" => null
        "text" => null
        "created_at" => "2024-08-15T10:08:12.000000Z"
        "updated_at" => "2024-08-15T10:08:12.000000Z"
      ]
      1 => array:7 [
        "id" => 2
        "title" => "Cate 2"
        "slug" => "cate-2"
        "image" => null
        "text" => null
        "created_at" => "2024-08-15T10:08:19.000000Z"
        "updated_at" => "2024-08-15T10:08:19.000000Z"
      ]
    ]
    "prop_type" => null
    "prop_category_id" => null
    "prop_from" => null
    "prop_to" => null
    "prop_search" => null
  ]
  -path: null
  #interacted: []
  -component: "Blog/Index"
  -url: "/blog"
  -version: "b05311e78830e9fb34e382b9802ceab2"

La salida anterior fue recordaba para evitar llenar 4 páginas con datos que nosotros no vamos a evaluar en esta guía, pero, se recomienda al lector que haga la prueba y evalúe el resultado.

Veremos que la salida anterior corresponde al objeto global llamado $page que es el que empleamos anteriormente para obtener datos sobre la página, como el componente de Vue empleado, props, datos compartidos, de usuario, y más:

resources\js\Pages\Blog\Index.vue

{{ $page }}

Los datos suministrados dependen del recurso que queramos evaluar, ya que al igual que las pruebas en Laravel básico, todo depende de cómo esté formado el recurso o controlador a evaluar.

Podemos ir segmentando, por ejemplo, objetos complejos como son el de posts para la paginación:

$this->get(route('web.index'))
    ->assertInertia(fn (Assert $page) => $page
        ->component('Blog/Index')
        ->has('posts', fn (Assert $page) => dd($page)

Y el detalle que obtendremos, será solo de la paginación:

"errors" => []
    "jetstream" => array:11 [
     ***
Inertia\Testing\AssertableInertia {#2305
  -props: array:13 [
    "current_page" => 1
    "data" => array:15 [
      ***
      14 => array:13 [
        "id" => 18
        "title" => "NSf"
        "slug" => "nsf"
        "date" => "2023-11-07 00:00:00"
        "image" => null
        "text" => "59nZkTBFh1v6Nt42Xz4Gsx1YbrujpKOykmXOkoIZ1uv5o4HVHKpvuciBQbqukXwUPrnGxirZkvspZPK1zQnHuODvpQL98CyoqM053CKmi2KKmIiBkKSzTgKt38jwGk5YUkKdOc06YwuyzwQ0AFmxxZp1fEnAvl1dpb1f038Fj6y3YeIk4gqB7cTcMkpaq5zyhQY2guGYL8vB8Et1lO605kqL8HvpHGymZRoOoabEKBbIjBsA687NZwNFWqEEoVQdkekOM2tv0xXfXC2HM5l4t5ystQMR7oflo8wuDFyDyraGI5H4sz0ZSIT8HIt7gDt26RMzhYARx9aaw1vY0U2cPiLPCU9MNf4aoIUvSsHcfLiU50xSVbbCxZHlo0eT5zJFq7K59IcStqipbDFV0HjuYZmorXkz3hJA9Nb6ZwmjdYOzhCgt66R6AFxq0QmguUvi6rertuPP"
        "description" => null
        "posted" => "yes"
        "type" => "movie"
        "category_id" => 2
        "created_at" => "2024-09-21T09:43:12.000000Z"
        "updated_at" => "2024-09-21T09:43:12.000000Z"
        "category" => array:7 [
          "id" => 2
          "title" => "Cate 2"
          "slug" => "cate-2"
          "image" => null
          "text" => null
          "created_at" => "2024-08-15T10:08:19.000000Z"
          "updated_at" => "2024-08-15T10:08:19.000000Z"
        ]
      ]
    ]
    "first_page_url" => "http://inertiastore.test/blog?page=1"
    "from" => 1
    "last_page" => 47
    "last_page_url" => "http://inertiastore.test/blog?page=47"
    "links" => array:15 [
      ***
      14 => array:3 [
        "url" => "http://inertiastore.test/blog?page=2"
        "label" => "Next &raquo;"
        "active" => false
      ]
    ]
    "next_page_url" => "http://inertiastore.test/blog?page=2"
    "path" => "http://inertiastore.test/blog"
    "per_page" => 15
    "prev_page_url" => null
    "to" => 15
    "total" => 702
  ]
  ***

Como puedes ver, el detalle devuelto NO es el objeto de $page, que tiene muchísima data, es simplemente del objeto que se está observando que en este ejemplo es el prop de posts, por lo tanto, podemos darle un mejor nombrado acorde a la respuesta:

$this->get(route('web.index'))
    ->assertInertia(fn (Assert $page) => $page
        ->component('Blog/Index')
        ->has('posts', fn (Assert $posts) => dd($posts)

Métodos has y where

Existen un par de métodos claves que debemos de tener en cuenta al momento de crear las pruebas empleando el Assert de Inertia, el método de has() y where() que ejemplificamos en el siguiente apartado.

Desde el componente de listado, tenemos varios props que podemos verificar su integridad y si están presentes, qué datos deben de manejar:

resources\js\Pages\Blog\Index.vue

props: {
    posts: Object,
    categories: Array,
    prop_category_id: String,
    prop_type: String,
    prop_from: String,
    prop_to: String,
    prop_search: String,
},

El listado paginado de los posts, es algo complejo, ya que tiene los filtros de tipo when que implementamos antes, pero, al hacer la petición desde la prueba, no estamos inyectando valores al filtro:

app\Http\Controllers\Blog\PostController.php

$posts = Post::
                when(***)
        })->with('category')
            ->paginate(15);

Por lo tanto, al crear el query en la prueba, podemos prescindir de los filtros quedando la consulta con lo señalado en negritas en el fragmento de código anterior.

Recordemos que en el controlador, exponemos las siguientes variables/props al componente de Vue:

$this->get(route('web.index'))
            ->assertInertia(fn (Assert $page) => $page
                ->component('Blog/Index')
                ->where('categories', Category::get())
                ->where('prop_from', null)
                ->where('posts', Post::with('category')->paginate(15))
            );

Mediante el método de where(), podemos comprobar por cada uno de los props por el valor que deben de tener.

Mediante el método has() podemos verificar si el prop existe, pasando solamente la key:

->has(<KEY>)
->has('prop_from')

O si la longitud del mismo, pasando el segundo parámetro que corresponde a la longitud:

->has(<KEY>,<LENGTH>)
->has('posts',15)

Por ejemplo:

$this->get(route('web.index'))
    ->assertInertia(fn (Assert $page) => $page
        ->component('Blog/Index')
        ->where('categories', Category::get())
        ->where('prop_from', null)
        ->where('posts', Post::with('category')->paginate(15))
        ->has('prop_from')
        ->has('posts', 15)
        ->has('posts', fn (Assert $page) => $page
            ->where('last_page', 47)

También podemos emplear el método de has() para navegar sobre el objeto observado y aplicar condiciones, por ejemplo:

->has('categories', fn(Assert $page) => dd($page)

De igual manera, podemos inspeccionar o navegar por el objeto:

->has('categories.0.title')
->where('categories.0.title', 'Cate 1')

Conclusión

Laravel pone el testing al alcance de todos los desarrolladores. Tanto PHPUnit como Pest permiten crear pruebas robustas, pero Pest simplifica la sintaxis sin perder potencia.

Aplicar pruebas unitarias de forma constante te ahorra tiempo, reduce errores y aumenta la confianza en tu código.

Si puedes, integra un flujo TDD y automatiza tus pruebas con pipelines CI/CD. Tu aplicación —y tu equipo— lo agradecerán.

Siempre ejecuta las pruebas después de cada cambio.

Usa dd() para inspeccionar respuestas.

Verifica primero el código de estado (assertStatus(200)), ya que si la ruta falla, el resto de pruebas no tiene sentido.

❓ Preguntas frecuentes

  1. ¿Qué son las pruebas unitarias en Laravel?
    1. Son fragmentos de código que verifican el funcionamiento correcto de pequeñas partes de tu aplicación.
  2. ¿Cuál es la diferencia entre PHPUnit y Pest?
    1. Pest usa una sintaxis más limpia, pero internamente se apoya en PHPUnit, por lo que ambos ofrecen las mismas capacidades.
  3. ¿Cómo se configura el archivo Pest.php?
    1. Incluye los traits necesarios y define funciones globales, por ejemplo uses(TestCase::class, RefreshDatabase::class)->in('Feature');.
  4. ¿Cómo implementar autenticación en las pruebas?
    1. Puedes usar $this->actingAs($user) o crear un token con generateTokenAuth() si pruebas APIs.
  5. ¿Vale la pena aplicar TDD?
    1. Sí, porque te obliga a escribir código más claro y verificable desde el principio.

Hablaremos sobre como funcionan las pruebas en Laravel, tips, consideraciones, importancia y primeros pasos en Laravel e integración con Livewire e Inertia.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.

Andrés Cruz

EN In english