Cuando pensamos en escribir pruebas automatizadas, lo primero que suele venir a la mente son las reglas de negocio: que un cálculo dé el resultado correcto, que una validación rechace lo que debe rechazar, que un flujo termine en el estado esperado. Todo eso es importante. Pero hay una capa más silenciosa, que rara vez recibe atención y que, cuando falla, produce los errores más difíciles de diagnosticar: las relaciones entre modelos.
En este artículo vamos a ver por qué vale la pena dedicarles pruebas propias, qué conviene verificar exactamente, y cómo escribirlas y ejecutarlas paso a paso. Los ejemplos usan Eloquent, el ORM de Laravel, pero las ideas se trasladan a cualquier ORM con relaciones.
El problema: las relaciones fallan en silencio
Una relación mal definida no suele lanzar un error inmediato. El código compila, la aplicación arranca, y todo parece funcionar hasta que, en algún punto, una consulta devuelve una colección vacía donde debería haber datos, o carga el registro equivocado.
El caso más común aparece cuando el nombre de la clave foránea no sigue la convención que el ORM espera. Eloquent asume un patrón de nombres por defecto: para una relación hacia una tabla documentos, busca una columna documento_id. Si tu base de datos usa otra convención, el ORM buscará una columna que no existe y la relación devolverá vacío, sin quejarse.
Este tipo de fallo es especialmente traicionero porque no se manifiesta donde está el error, sino mucho más adelante, en una vista que no muestra datos o en un reporte con totales incompletos. Una prueba de relación lo atrapa en el acto.
Dónde vive la prueba
En Laravel, las pruebas viven en el directorio tests/, dividido en dos carpetas:
tests/Unit/para pruebas que no tocan la base de datos.tests/Feature/para las que sí.
Las pruebas de relaciones van en tests/Feature/, porque necesitan crear registros y consultarlos.
Para crear el archivo:
php artisan make:test DocumentoRelacionesTest
Eso genera tests/Feature/DocumentoRelacionesTest.php.
El esqueleto de la clase
<?php
namespace Tests\Feature;
use App\Models\Documento;
use App\Models\Impuesto;
use App\Models\LineaDeDetalle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DocumentoRelacionesTest extends TestCase
{
use RefreshDatabase;
// Aquí van los casos
}
RefreshDatabase es la pieza clave: ejecuta las migraciones antes de la suite y envuelve cada prueba en una transacción que se revierte al terminar. Así cada caso arranca con una base limpia y no puedes ensuciar datos reales por accidente.
Cada caso es un método público cuyo nombre empieza con test, o que lleva el atributo #[Test] si prefieres nombres sin prefijo.
Los cuatro casos que conviene cubrir
1. Que la relación carga en ambos sentidos
Las relaciones tienen dos extremos y ambos pueden estar mal definidos de forma independiente. Si un padre tiene muchos hijos, verifica que desde el padre llegas a todos sus hijos, y que desde un hijo llegas a su padre.
Aquí es donde las factories hacen el trabajo pesado. Una factory es una receta para crear registros de prueba con valores realistas y, bien configurada, encadena automáticamente la creación de los registros de los que depende:
public function test_la_linea_llega_a_su_documento()
{
// Crea la línea y, de forma automática, su documento padre
$linea = LineaDeDetalle::factory()->create();
$this->assertNotNull($linea->documento);
$this->assertSame($linea->id_documento, $linea->documento->id);
}
public function test_el_documento_ve_todas_sus_lineas()
{
$documento = Documento::factory()->create();
LineaDeDetalle::factory()->count(3)->create([
'id_documento' => $documento->id,
]);
$this->assertCount(3, $documento->lineas);
}
Nota el fresh() que no aparece aquí y sí en otros casos: $documento->lineas se carga por primera vez en el momento de la aserción, así que todavía no hay nada en caché. Si hubieras accedido a la relación antes de crear las líneas, Eloquent devolvería la colección vacía que ya tenía guardada.
2. Que la carga anticipada funciona
La carga anticipada, o eager loading, evita el problema de las N+1 consultas: en lugar de una consulta por cada registro relacionado, se hacen dos en total. Es fácil creer que la tienes y que no esté surtiendo efecto.
La forma de comprobarlo es contar las consultas que realmente se ejecutan:
use Illuminate\Support\Facades\DB;
public function test_la_carga_anticipada_evita_consultas_por_cada_linea()
{
Documento::factory()->count(5)->hasLineas(3)->create();
DB::enableQueryLog();
Documento::with('lineas')->get()->each(
fn ($documento) => $documento->lineas->count()
);
// Una consulta para documentos, una para todas las líneas
$this->assertCount(2, DB::getQueryLog());
}
Si quitas el with('lineas'), esta prueba falla con seis consultas en vez de dos. Esa es la señal de que la aserción está midiendo lo que crees.
3. Que el borrado se comporta como esperas
Si configuraste que al borrar un padre se borren sus hijos en cascada, pruébalo:
public function test_al_borrar_el_documento_se_borran_sus_lineas()
{
$documento = Documento::factory()->hasLineas(2)->create();
$idsLineas = $documento->lineas->pluck('id');
$documento->delete();
foreach ($idsLineas as $id) {
$this->assertDatabaseMissing('lineas_de_detalle', ['id' => $id]);
}
}
Aquí hay un detalle que conviene conocer: si la cascada está definida en la base de datos con onDelete('cascade'), el comportamiento depende del motor. Las pruebas de Laravel suelen correr sobre SQLite en memoria por velocidad, y SQLite no aplica claves foráneas a menos que se activen explícitamente. Una prueba que pasa en tu entorno podría no reflejar lo que ocurre en producción si los motores difieren.
Puedes verificar contra qué motor estás corriendo:
php artisan test --filter=test_al_borrar_el_documento -v
Y si quieres que las pruebas usen el mismo motor que producción, configúralo en phpunit.xml:
<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value="almcns_testing"/>
Es más lento, pero prueba lo que de verdad va a ocurrir.
4. Que los datos de captura histórica se conservan
Este es sutil y muy valioso. A veces guardas una copia de un dato en el momento de una transacción, por ejemplo el nombre y el precio de un producto tal como estaban ese día, en lugar de depender siempre del registro vivo. Una prueba puede confirmar que si el registro original cambia después, la copia guardada conserva su valor:
public function test_la_linea_conserva_el_precio_del_momento_de_captura()
{
$producto = Producto::factory()->create(['precio' => 100.00]);
$linea = LineaDeDetalle::factory()->create([
'id_producto' => $producto->id,
'precio' => $producto->precio,
]);
$producto->update(['precio' => 250.00]);
$this->assertSame('100.00', $linea->fresh()->precio);
$this->assertSame('250.00', $linea->producto->fresh()->precio);
}
Las dos aserciones juntas son las que dan el valor: confirman que la copia no se movió y que la relación hacia el registro vivo sigue funcionando. Es la diferencia entre un histórico confiable y uno que se reescribe solo.
El fresh() aquí no es decorativo. Sin él estarías consultando el objeto que ya tienes en memoria, no lo que quedó guardado, y la prueba pasaría incluso si la base de datos dijera otra cosa.
Cómo ejecutarlas
Las pruebas se ejecutan en la terminal, desde la raíz del proyecto. No hay nada que abrir en el navegador.
Toda la suite:
php artisan test
Solo tu archivo:
php artisan test tests/Feature/DocumentoRelacionesTest.php
Un solo caso, por nombre:
php artisan test --filter=test_la_linea_conserva_el_precio_del_momento_de_captura
Una salida exitosa se ve así:
PASS Tests\Feature\DocumentoRelacionesTest
✓ la linea llega a su documento 0.34s
✓ el documento ve todas sus lineas 0.08s
✓ la carga anticipada evita consultas por cada linea 0.11s
✓ al borrar el documento se borran sus lineas 0.09s
✓ la linea conserva el precio del momento de captura 0.10s
Tests: 5 passed (12 assertions)
Duration: 0.72s
Cuando algo falla, la salida te dice qué esperaba y qué obtuvo:
⨯ el documento ve todas sus lineas
Failed asserting that actual size 0 matches expected size 3.
at tests/Feature/DocumentoRelacionesTest.php:31
Ese “size 0” es exactamente el síntoma del que hablábamos al principio: la relación devuelve vacío sin lanzar ningún error. La diferencia es que ahora aparece en tu terminal en menos de un segundo, y no en un reporte de producción dentro de tres semanas.
Si prefieres no escribir el comando cada vez, puedes dejarlas corriendo en modo observador:
php artisan test --watch
Un consejo que ahorra dolores de cabeza
Si tu base de datos no usa la convención de nombres por defecto del ORM, declara la clave foránea de forma explícita en cada relación. No dependas de que el framework la adivine:
public function lineas()
{
return $this->hasMany(LineaDeDetalle::class, 'id_documento', 'id');
}
Es un argumento más por relación y elimina de raíz toda una categoría de errores silenciosos.
Una red de seguridad para refactorizaciones
El mayor valor de estas pruebas aparece cuando tienes que modificar la estructura de datos. Agregar tablas, normalizar información que antes estaba mezclada, cambiar cómo se conectan las entidades: son cambios que tocan muchas piezas a la vez y donde es fácil romper una relación sin darte cuenta.
Con un conjunto de pruebas de relaciones en su lugar, cada cambio se valida solo. Si una modificación rompe la conexión entre dos modelos, lo sabes en segundos, no cuando un usuario reporta que un reporte sale vacío. Esa retroalimentación inmediata es lo que convierte una refactorización arriesgada en una rutina controlada.
En resumen
Las relaciones entre modelos son la fontanería de una aplicación: invisibles cuando funcionan, catastróficas cuando fallan. Dedicarles pruebas propias no lleva mucho tiempo, sobre todo apoyándote en factories que preparen los datos, y a cambio obtienes una red de seguridad que atrapa una clase de errores que de otro modo llegarían hasta el usuario final.
La próxima vez que definas una relación, considera escribir junto a ella una prueba breve que confirme que carga bien, en ambos sentidos. Tu yo del futuro, en medio de una refactorización, te lo agradecerá.







Deja un comentario