Pruebas Unitarias y de Integración con PHPUnit y Pest en Laravel

Video thumbnail

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.

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.

Acepto recibir anuncios de interes sobre este Blog.

Hablaremos sobre como funcionan las pruebas en Laravel, tips, consideraciones, importancia y primeros pasos en Laravel.

| 👤 Andrés Cruz

🇺🇸 In english