{"id":314,"date":"2026-08-03T17:08:04","date_gmt":"2026-08-04T00:08:04","guid":{"rendered":"https:\/\/www.mariatech.com.mx\/blog\/?p=314"},"modified":"2026-09-15T18:35:22","modified_gmt":"2026-09-16T01:35:22","slug":"por-que-deberias-probar-las-relaciones-entre-tus-modelos-y-no-solo-la-logica-de-negocio","status":"publish","type":"post","link":"https:\/\/www.mariatech.com.mx\/blog\/laravel-php\/por-que-deberias-probar-las-relaciones-entre-tus-modelos-y-no-solo-la-logica-de-negocio\/","title":{"rendered":"Por qu\u00e9 deber\u00edas probar las relaciones entre tus modelos (y no solo la l\u00f3gica de negocio)"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Cuando pensamos en escribir pruebas automatizadas, lo primero que suele venir a la mente son las reglas de negocio: que un c\u00e1lculo d\u00e9 el resultado correcto, que una validaci\u00f3n rechace lo que debe rechazar, que un flujo termine en el estado esperado. Todo eso es importante. Pero hay una capa m\u00e1s silenciosa, que rara vez recibe atenci\u00f3n y que, cuando falla, produce los errores m\u00e1s dif\u00edciles de diagnosticar: las relaciones entre modelos.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">En este art\u00edculo vamos a ver por qu\u00e9 vale la pena dedicarles pruebas propias, qu\u00e9 conviene verificar exactamente, y c\u00f3mo escribirlas y ejecutarlas paso a paso. Los ejemplos usan Eloquent, el ORM de Laravel, pero las ideas se trasladan a cualquier ORM con relaciones.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">El problema: las relaciones fallan en silencio<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Una relaci\u00f3n mal definida no suele lanzar un error inmediato. El c\u00f3digo compila, la aplicaci\u00f3n arranca, y todo parece funcionar hasta que, en alg\u00fan punto, una consulta devuelve una colecci\u00f3n vac\u00eda donde deber\u00eda haber datos, o carga el registro equivocado.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El caso m\u00e1s com\u00fan aparece cuando el nombre de la clave for\u00e1nea no sigue la convenci\u00f3n que el ORM espera. Eloquent asume un patr\u00f3n de nombres por defecto: para una relaci\u00f3n hacia una tabla <code>documentos<\/code>, busca una columna <code>documento_id<\/code>. Si tu base de datos usa otra convenci\u00f3n, el ORM buscar\u00e1 una columna que no existe y la relaci\u00f3n devolver\u00e1 vac\u00edo, sin quejarse.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Este tipo de fallo es especialmente traicionero porque no se manifiesta donde est\u00e1 el error, sino mucho m\u00e1s adelante, en una vista que no muestra datos o en un reporte con totales incompletos. Una prueba de relaci\u00f3n lo atrapa en el acto.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">D\u00f3nde vive la prueba<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">En Laravel, las pruebas viven en el directorio <code>tests\/<\/code>, dividido en dos carpetas:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>tests\/Unit\/<\/code> para pruebas que no tocan la base de datos.<\/li>\n\n\n\n<li><code>tests\/Feature\/<\/code> para las que s\u00ed.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Las pruebas de relaciones van en <code>tests\/Feature\/<\/code>, porque necesitan crear registros y consultarlos.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Para crear el archivo:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>php artisan make:test DocumentoRelacionesTest\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Eso genera <code>tests\/Feature\/DocumentoRelacionesTest.php<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">El esqueleto de la clase<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\Documento;\nuse App\\Models\\Impuesto;\nuse App\\Models\\LineaDeDetalle;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass DocumentoRelacionesTest extends TestCase\n{\n    use RefreshDatabase;\n\n    \/\/ Aqu\u00ed van los casos\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>RefreshDatabase<\/code> es la pieza clave: ejecuta las migraciones antes de la suite y envuelve cada prueba en una transacci\u00f3n que se revierte al terminar. As\u00ed cada caso arranca con una base limpia y no puedes ensuciar datos reales por accidente.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Cada caso es un m\u00e9todo p\u00fablico cuyo nombre empieza con <code>test<\/code>, o que lleva el atributo <code>#[Test]<\/code> si prefieres nombres sin prefijo.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Los cuatro casos que conviene cubrir<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">1. Que la relaci\u00f3n carga en ambos sentidos<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Aqu\u00ed 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\u00e1ticamente la creaci\u00f3n de los registros de los que depende:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public function test_la_linea_llega_a_su_documento()\n{\n    \/\/ Crea la l\u00ednea y, de forma autom\u00e1tica, su documento padre\n    $linea = LineaDeDetalle::factory()-&gt;create();\n\n    $this-&gt;assertNotNull($linea-&gt;documento);\n    $this-&gt;assertSame($linea-&gt;id_documento, $linea-&gt;documento-&gt;id);\n}\n\npublic function test_el_documento_ve_todas_sus_lineas()\n{\n    $documento = Documento::factory()-&gt;create();\n\n    LineaDeDetalle::factory()-&gt;count(3)-&gt;create(&#091;\n        'id_documento' =&gt; $documento-&gt;id,\n    ]);\n\n    $this-&gt;assertCount(3, $documento-&gt;lineas);\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Nota el <code>fresh()<\/code> que no aparece aqu\u00ed y s\u00ed en otros casos: <code>$documento-&gt;lineas<\/code> se carga por primera vez en el momento de la aserci\u00f3n, as\u00ed que todav\u00eda no hay nada en cach\u00e9. Si hubieras accedido a la relaci\u00f3n <em>antes<\/em> de crear las l\u00edneas, Eloquent devolver\u00eda la colecci\u00f3n vac\u00eda que ya ten\u00eda guardada.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. Que la carga anticipada funciona<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">La carga anticipada, o <em>eager loading<\/em>, evita el problema de las N+1 consultas: en lugar de una consulta por cada registro relacionado, se hacen dos en total. Es f\u00e1cil creer que la tienes y que no est\u00e9 surtiendo efecto.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">La forma de comprobarlo es contar las consultas que realmente se ejecutan:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>use Illuminate\\Support\\Facades\\DB;\n\npublic function test_la_carga_anticipada_evita_consultas_por_cada_linea()\n{\n    Documento::factory()-&gt;count(5)-&gt;hasLineas(3)-&gt;create();\n\n    DB::enableQueryLog();\n\n    Documento::with('lineas')-&gt;get()-&gt;each(\n        fn ($documento) =&gt; $documento-&gt;lineas-&gt;count()\n    );\n\n    \/\/ Una consulta para documentos, una para todas las l\u00edneas\n    $this-&gt;assertCount(2, DB::getQueryLog());\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Si quitas el <code>with('lineas')<\/code>, esta prueba falla con seis consultas en vez de dos. Esa es la se\u00f1al de que la aserci\u00f3n est\u00e1 midiendo lo que crees.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. Que el borrado se comporta como esperas<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Si configuraste que al borrar un padre se borren sus hijos en cascada, pru\u00e9balo:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public function test_al_borrar_el_documento_se_borran_sus_lineas()\n{\n    $documento = Documento::factory()-&gt;hasLineas(2)-&gt;create();\n    $idsLineas = $documento-&gt;lineas-&gt;pluck('id');\n\n    $documento-&gt;delete();\n\n    foreach ($idsLineas as $id) {\n        $this-&gt;assertDatabaseMissing('lineas_de_detalle', &#091;'id' =&gt; $id]);\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Aqu\u00ed hay un detalle que conviene conocer: si la cascada est\u00e1 definida en la base de datos con <code>onDelete('cascade')<\/code>, el comportamiento depende del motor. Las pruebas de Laravel suelen correr sobre SQLite en memoria por velocidad, y SQLite no aplica claves for\u00e1neas a menos que se activen expl\u00edcitamente. Una prueba que pasa en tu entorno podr\u00eda no reflejar lo que ocurre en producci\u00f3n si los motores difieren.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Puedes verificar contra qu\u00e9 motor est\u00e1s corriendo:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>php artisan test --filter=test_al_borrar_el_documento -v\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Y si quieres que las pruebas usen el mismo motor que producci\u00f3n, config\u00faralo en <code>phpunit.xml<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;env name=\"DB_CONNECTION\" value=\"mysql\"\/&gt;\n&lt;env name=\"DB_DATABASE\" value=\"almcns_testing\"\/&gt;\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Es m\u00e1s lento, pero prueba lo que de verdad va a ocurrir.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">4. Que los datos de captura hist\u00f3rica se conservan<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Este es sutil y muy valioso. A veces guardas una copia de un dato en el momento de una transacci\u00f3n, por ejemplo el nombre y el precio de un producto tal como estaban ese d\u00eda, en lugar de depender siempre del registro vivo. Una prueba puede confirmar que si el registro original cambia despu\u00e9s, la copia guardada conserva su valor:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public function test_la_linea_conserva_el_precio_del_momento_de_captura()\n{\n    $producto = Producto::factory()-&gt;create(&#091;'precio' =&gt; 100.00]);\n\n    $linea = LineaDeDetalle::factory()-&gt;create(&#091;\n        'id_producto' =&gt; $producto-&gt;id,\n        'precio'      =&gt; $producto-&gt;precio,\n    ]);\n\n    $producto-&gt;update(&#091;'precio' =&gt; 250.00]);\n\n    $this-&gt;assertSame('100.00', $linea-&gt;fresh()-&gt;precio);\n    $this-&gt;assertSame('250.00', $linea-&gt;producto-&gt;fresh()-&gt;precio);\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Las dos aserciones juntas son las que dan el valor: confirman que la copia no se movi\u00f3 <em>y<\/em> que la relaci\u00f3n hacia el registro vivo sigue funcionando. Es la diferencia entre un hist\u00f3rico confiable y uno que se reescribe solo.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El <code>fresh()<\/code> aqu\u00ed no es decorativo. Sin \u00e9l estar\u00edas consultando el objeto que ya tienes en memoria, no lo que qued\u00f3 guardado, y la prueba pasar\u00eda incluso si la base de datos dijera otra cosa.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">C\u00f3mo ejecutarlas<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Las pruebas se ejecutan en la terminal, desde la ra\u00edz del proyecto. No hay nada que abrir en el navegador.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Toda la suite:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>php artisan test\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Solo tu archivo:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>php artisan test tests\/Feature\/DocumentoRelacionesTest.php\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Un solo caso, por nombre:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>php artisan test --filter=test_la_linea_conserva_el_precio_del_momento_de_captura\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Una salida exitosa se ve as\u00ed:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"> <code>  PASS  Tests\\Feature\\DocumentoRelacionesTest\n  \u2713 la linea llega a su documento                        0.34s\n  \u2713 el documento ve todas sus lineas                     0.08s\n  \u2713 la carga anticipada evita consultas por cada linea   0.11s\n  \u2713 al borrar el documento se borran sus lineas          0.09s\n  \u2713 la linea conserva el precio del momento de captura   0.10s\n\n  Tests:    5 passed (12 assertions)\n  Duration: 0.72s\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Cuando algo falla, la salida te dice qu\u00e9 esperaba y qu\u00e9 obtuvo:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"> <code> \u2a2f el documento ve todas sus lineas\n\n  Failed asserting that actual size 0 matches expected size 3.\n\n  at tests\/Feature\/DocumentoRelacionesTest.php:31\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Ese &#8220;size 0&#8221; es exactamente el s\u00edntoma del que habl\u00e1bamos al principio: la relaci\u00f3n devuelve vac\u00edo sin lanzar ning\u00fan error. La diferencia es que ahora aparece en tu terminal en menos de un segundo, y no en un reporte de producci\u00f3n dentro de tres semanas.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Si prefieres no escribir el comando cada vez, puedes dejarlas corriendo en modo observador:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>php artisan test --watch\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Un consejo que ahorra dolores de cabeza<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Si tu base de datos no usa la convenci\u00f3n de nombres por defecto del ORM, declara la clave for\u00e1nea de forma expl\u00edcita en cada relaci\u00f3n. No dependas de que el framework la adivine:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public function lineas()\n{\n    return $this-&gt;hasMany(LineaDeDetalle::class, 'id_documento', 'id');\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Es un argumento m\u00e1s por relaci\u00f3n y elimina de ra\u00edz toda una categor\u00eda de errores silenciosos.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Una red de seguridad para refactorizaciones<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">El mayor valor de estas pruebas aparece cuando tienes que modificar la estructura de datos. Agregar tablas, normalizar informaci\u00f3n que antes estaba mezclada, cambiar c\u00f3mo se conectan las entidades: son cambios que tocan muchas piezas a la vez y donde es f\u00e1cil romper una relaci\u00f3n sin darte cuenta.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Con un conjunto de pruebas de relaciones en su lugar, cada cambio se valida solo. Si una modificaci\u00f3n rompe la conexi\u00f3n entre dos modelos, lo sabes en segundos, no cuando un usuario reporta que un reporte sale vac\u00edo. Esa retroalimentaci\u00f3n inmediata es lo que convierte una refactorizaci\u00f3n arriesgada en una rutina controlada.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">En resumen<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Las relaciones entre modelos son la fontaner\u00eda de una aplicaci\u00f3n: invisibles cuando funcionan, catastr\u00f3ficas cuando fallan. Dedicarles pruebas propias no lleva mucho tiempo, sobre todo apoy\u00e1ndote 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\u00edan hasta el usuario final.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">La pr\u00f3xima vez que definas una relaci\u00f3n, 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\u00f3n, te lo agradecer\u00e1.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Cuando pensamos en escribir pruebas automatizadas, lo primero que suele venir a la mente son las reglas de negocio: que un c\u00e1lculo d\u00e9 el resultado correcto, que una validaci\u00f3n rechace lo que debe rechazar, que un flujo termine en el estado esperado. Todo eso es importante. Pero hay una capa m\u00e1s silenciosa, que rara vez [&hellip;]<\/p>\n","protected":false},"author":2,"featured_media":315,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[6,15],"tags":[],"class_list":["post-314","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-laravel-php","category-mysql"],"_links":{"self":[{"href":"https:\/\/www.mariatech.com.mx\/blog\/wp-json\/wp\/v2\/posts\/314","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.mariatech.com.mx\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.mariatech.com.mx\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.mariatech.com.mx\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.mariatech.com.mx\/blog\/wp-json\/wp\/v2\/comments?post=314"}],"version-history":[{"count":0,"href":"https:\/\/www.mariatech.com.mx\/blog\/wp-json\/wp\/v2\/posts\/314\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.mariatech.com.mx\/blog\/wp-json\/wp\/v2\/media\/315"}],"wp:attachment":[{"href":"https:\/\/www.mariatech.com.mx\/blog\/wp-json\/wp\/v2\/media?parent=314"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.mariatech.com.mx\/blog\/wp-json\/wp\/v2\/categories?post=314"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.mariatech.com.mx\/blog\/wp-json\/wp\/v2\/tags?post=314"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}