Saltar al contenido

Si has creado tu proyecto con laravel new, tus tests se ejecutan en SQLite en memoria, aunque en producción tengas MySQL. Y SQLite acepta cosas que MySQL rechaza: la suite puede estar en verde y el error aparecer cuando el código llega al servidor.

Es lo que le pasó a Pinkary, la aplicación de código abierto de Nuno Maduro, al pasar de SQLite a MySQL. Como cuenta uno de sus mantenedores en el blog de Laravel el 30 de septiembre de 2026, llevaba más de un año guardando UUID en columnas de enteros con todos los tests en verde. En esta entrada vamos a reproducir esos fallos en un proyecto pequeño: la misma suite de Pest, en verde en SQLite y en rojo en MySQL, con el error real de cada diferencia, su solución y cuánto más tarda la suite en MySQL. Todo está probado, en el momento de crear esta entrada, con Laravel 13.36, PHP 8.5, Pest 5, MySQL 8.4 y SQLite 3.45, con Sail.

Por qué tus tests van a SQLite

El phpunit.xml que crea laravel new lleva estas 2 líneas en su bloque <php>:

<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>

Con ellas, la suite trabaja con una base de datos SQLite en memoria, sin nada que instalar. Si instalas Sail con MySQL, sail:install borra la primera línea y cambia la segunda por testing, una base de datos que Sail crea al levantar su contenedor de MySQL. Con Sail, tus tests ya van contra MySQL; sin él, lo normal es que sigan en SQLite.

Para ejecutar la misma suite contra MySQL sin tocar el archivo, basta con pasar las 2 variables al lanzar los tests. Necesitas un MySQL con una base de datos testing, y con Sail el comando se lanza desde el contenedor de la aplicación (./vendor/bin/sail shell):

DB_CONNECTION=mysql DB_DATABASE=testing php artisan test

PHPUnit solo aplica las variables de phpunit.xml que no están ya definidas en el entorno, salvo que la línea lleve force="true", así que mandan las de la línea de comandos.

El proyecto de prueba

Vamos a usar un blog mínimo con 3 modelos: Post, con clave primaria UUID gracias al trait HasUuids y un índice en created_at para listar por fecha; Comment, que pertenece a un post, y Tag, con un nombre único. Cada test prueba una de las diferencias entre los 2 motores. Este es tests/Feature/BlogTest.php, con RefreshDatabase activado en tests/Pest.php para la carpeta Feature:

<?php

use App\Models\Comment;
use App\Models\Post;
use App\Models\Tag;

test('a comment belongs to its post', function () {
    $comment = Comment::factory()->create();

    expect($comment->post)->toBeInstanceOf(Post::class);
});

test('shows the latest comment of each post', function () {
    $post = Post::factory()->create();
    Comment::factory()->for($post)->create(['body' => 'Primero', 'created_at' => now()->subHour()]);
    Comment::factory()->for($post)->create(['body' => 'Último']);

    expect(Comment::latestPerPost()->first()->body)->toBe('Último');
});

test('the newest post comes first', function () {
    Post::factory()->create(['title' => 'Antiguo']);
    Post::factory()->create(['title' => 'Nuevo']);

    expect(Post::latest()->pluck('title')->all())->toBe(['Nuevo', 'Antiguo']);
});

test('tags are case sensitive', function () {
    Tag::create(['name' => 'Laravel']);
    Tag::create(['name' => 'laravel']);

    expect(Tag::count())->toBe(2);
});

En SQLite pasan los 4, más los 2 tests de ejemplo de Laravel. En MySQL, con el comando de antes, fallan los 4. Vamos a verlos uno a uno.

Un UUID en una columna de foreignId()

La migración de comments declara la relación con $table->foreignId('post_id')->constrained(), que crea una columna de enteros, pero los posts usan UUID. En MySQL la migración no llega a terminar, y por eso fallan todos los tests del archivo:

   FAILED  Tests\Feature\BlogTest > a comment belongs to its…  QueryException   
  SQLSTATE[HY000]: General error: 3780 Referencing column 'post_id' and referenced column 'id' in foreign key constraint 'comments_post_id_foreign' are incompatible. (Connection: mysql, Host: mysql, Port: 3306, Database: testing, SQL: alter table `comments` add constraint `comments_post_id_foreign` foreign key (`post_id`) references `posts` (`id`) on delete cascade)

MySQL no deja crear una clave foránea entre un bigint unsigned y el char(36) donde Laravel guarda el UUID. SQLite sí, porque sus tipos son flexibles: la columna es integer, pero si le llega un texto que no es un número, lo guarda como texto (typeof(post_id) devuelve text). Como la clave foránea compara un UUID con otro UUID, la relación funciona y el test pasa.

La solución es declarar la columna con el tipo de la clave a la que apunta. En la migración de comments, cambia la línea de post_id por esta:

$table->foreignUuid('post_id')->constrained()->cascadeOnDelete();

También sirve foreignIdFor(Post::class), que lee el modelo y, si usa HasUuids, crea la columna como UUID. Es el mismo fallo que tenía Pinkary.

Consultas agrupadas y ONLY_FULL_GROUP_BY

Con la migración corregida quedan 3 tests en rojo. El del último comentario usa este método de app/Models/Comment.php, que busca el último comentario de cada post:

    /** @return Collection<int, Comment> */
    public static function latestPerPost(): Collection
    {
        return static::query()
            ->select('post_id', 'body', DB::raw('max(created_at) as commented_at'))
            ->groupBy('post_id')
            ->get();
    }

MySQL lo rechaza:

   FAILED  Tests\Feature\BlogTest > shows the latest comment…  QueryException   
  SQLSTATE[42000]: Syntax error or access violation: 1055 Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'testing.comments.body' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by (Connection: mysql, Host: mysql, Port: 3306, Database: testing, SQL: select `post_id`, `body`, max(created_at) as commented_at from `comments` group by `post_id`)

La consulta pide body, que no está en el GROUP BY ni dentro de una función de agregado, y MySQL no sabe de qué fila del grupo sacarlo. Laravel activa el modo ONLY_FULL_GROUP_BY en cada conexión de MySQL porque config/database.php trae 'strict' => true. SQLite, en cambio, tiene una regla propia: cuando la consulta lleva un único max(), las columnas sueltas salen de la fila que tiene el máximo, como explica su documentación. Por eso en SQLite el test pasa.

La solución que aparece en muchas respuestas es poner 'strict' => false. Con ese cambio el error desaparece, pero la consulta devuelve Primero como último comentario, junto a la fecha de Último. Sin ese modo, MySQL toma el body de cualquier fila del grupo.

En Eloquent, la forma de pedir el último de cada grupo es una relación con latestOfMany(). Abre app/Models/Post.php y añade la siguiente relación:

    public function latestComment(): HasOne
    {
        return $this->hasOne(Comment::class)->latestOfMany();
    }

En el test, cambia la línea del expect() por esta:

expect(Post::with('latestComment')->first()->latestComment->body)->toBe('Último');

Si te fijas en el SQL que genera, la subconsulta solo pide MAX(id) y el post_id por el que agrupa, y después une cada post con esa fila completa. Es SQL válido en los 2 motores. Si prefieres el último por fecha, latestOfMany('created_at') desempata además por el id.

Empates en created_at: el orden lo decide el plan

El tercer test crea 2 posts seguidos y espera el más nuevo primero. En MySQL salen al revés:

   FAILED  Tests\Feature\BlogTest > the newest post comes first                 
  Failed asserting that two arrays are identical.
   Array &0 [
  -    0 => 'Nuevo',
  -    1 => 'Antiguo',
  +    0 => 'Antiguo',
  +    1 => 'Nuevo',
   ]

Los 2 posts se crean en el mismo segundo y created_at no guarda fracciones de segundo, por lo que para la base de datos tienen la misma fecha. Ningún motor garantiza el orden de un empate: depende del plan de ejecución, es decir, de cómo decide el motor recorrer las filas. Con EXPLAIN se ve el plan de cada uno:

motor: sqlite
orden: Nuevo, Antiguo
plan: SCAN posts USING INDEX posts_created_at_index
motor: mysql
orden: Antiguo, Nuevo
plan: type=ALL key=NULL extra=Using filesort

SQLite recorre el índice de created_at de atrás hacia delante y, en el empate, devuelve primero la fila que se insertó después. MySQL, con 2 filas, prefiere leer la tabla entera y ordenarla en memoria (Using filesort), y en esta prueba deja los empates en el orden en que se insertaron. El test pasaba en SQLite por casualidad, y con otro número de filas el plan puede cambiar en cualquiera de los 2.

Lo que el test quiere comprobar es el orden por fecha, y para eso tiene que crear los posts con fechas distintas. En el test, crea el primero un minuto antes:

Post::factory()->create(['title' => 'Antiguo', 'created_at' => now()->subMinute()]);

Y si en tu aplicación 2 filas pueden compartir fecha, añade a la consulta un segundo criterio que no se repita, como ->orderByDesc('id'), para que el orden sea siempre el mismo. Es una de las causas de los flaky tests, los que unas veces pasan y otras no.

Mayúsculas y minúsculas: la colación

El último test crea las etiquetas Laravel y laravel, y MySQL no deja guardar la segunda:

   FAILED  Tests\Feature\BlogTest > tags…  UniqueConstraintViolationException   
  SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'laravel' for key 'tags.tags_name_unique' (Connection: mysql, Host: mysql, Port: 3306, Database: testing, SQL: insert into `tags` (`name`, `updated_at`, `created_at`) values (laravel, 2026-10-13 20:13:47, 2026-10-13 20:13:47))

La colación es la regla con la que la base de datos compara textos. Laravel crea las tablas de MySQL con utf8mb4_unicode_ci, que no distingue mayúsculas ni tildes: para el índice único, Laravel y laravel son el mismo valor, y Café y Cafe también. Para SQLite, que compara byte a byte por defecto, son 2 etiquetas distintas.

Aquí ninguno de los 2 motores tiene razón: tienes que decidir si 2 etiquetas que solo cambian en las mayúsculas son la misma. Si lo son, el test está mal, y lo que hay que probar es que la aplicación las trata como una sola. Si no lo son, como los hashtags de Pinkary, declara la columna con una colación binaria en la migración de tags:

$table->string('name')->collation('utf8mb4_bin')->unique();

Con ese cambio, la suite queda en verde en MySQL. Y en SQLite deja de funcionar, porque no conoce esa colación:

   FAILED  Tests\Feature\BlogTest > a comment belongs to its…  QueryException   
  SQLSTATE[HY000]: General error: 1 no such collation sequence: utf8mb4_bin (Connection: sqlite, Database: :memory:, SQL: create table "tags" ("id" integer primary key autoincrement not null, "name" varchar not null collate 'utf8mb4_bin', "created_at" datetime, "updated_at" datetime))

A partir de aquí la suite solo funciona en el motor de producción, que es donde tendría que ejecutarse desde el principio.

Cuánto más tarda la suite en MySQL

Hemos medido la suite en verde: la original en SQLite y la corregida en MySQL, con el MySQL de Sail tal como viene. También la hemos medido con 200 tests más, que crean un post con 3 comentarios y una etiqueta cada uno, para ver cómo crece:

  • Los 6 tests: 0,44 s en SQLite y 1,22 s en MySQL.
  • Con 206 tests: 2,29 s en SQLite y 4,40 s en MySQL.

En MySQL, el primer test tarda unos 0,8 s, porque antes RefreshDatabase borra y crea todas las tablas (en SQLite, 0,13 s). Después son unos 16 ms por test, frente a los 9 de SQLite. A ese ritmo, 1.000 tests como estos tardarían unos 7 s más en MySQL. Merece la pena pagar esos segundos: es lo que cuesta probar contra el mismo motor que usas en producción.

Qué hacer en tu proyecto

Abre tu phpunit.xml y mira si todavía tiene las 2 líneas de SQLite. Si producción va en MySQL, quítalas y apunta los tests a una base de datos MySQL propia, en local y en tu integración continua, con la misma versión que en producción. Con Sail ya la tienes: es testing.

Espero que te sirva. Si quieres trabajar con Sail y MySQL desde el primer día, no te pierdas el curso Docker y Laravel Sail desde cero.

school Curso completo

Curso Laravel 12
Completo 2026

El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.

access_time 8 horas de contenido
layers 4 tecnologías en 1
update 100% actualizado
code Proyectos prácticos
Ver Curso Laravel 12 arrow_forward

star Incluido en cualquier suscripción

Rutas de aprendizaje