Seis bugs que mis 615 aserciones en verde no detectaron

Una suite en verde no dice que tu código funciona. Dice que lo que verificaste sigue funcionando. No es lo mismo, y la diferencia se paga en producción.
Durante las últimas semanas construí un plugin comercial de WordPress: unas 8.000 líneas de PHP, cuatro bloques de Gutenberg, siete catálogos de traducción y 615 aserciones de test. Al terminar, la suite estaba entera en verde y wp plugin check no reportaba nada.
Ninguno de los seis defectos que más importaron apareció ahí.
Los seis comparten lo que los vuelve peligrosos: no producían ningún error. Ni excepción, ni warning, ni test rojo. Cada uno degradaba hacia algo que se veía plausible.
| # | Bug | Qué lo hacía invisible | Lo que sí lo encontró |
|---|---|---|---|
| 1 | Custom properties que no resuelven | Los tests comparaban HTML, no estilos computados | Renderizar la página |
| 2 | Un ajuste guardado en dos lugares | Los dos valores coinciden mientras solo se toque el formulario | El reporte de un cliente |
| 3 | Umbral global sobre contenido corto | El umbral no se revisa contenido por contenido | Un valor realista en la demo |
| 4 | La limpieza va después del punto de falla | No falla, solo deja basura silenciosa | Contar registros |
| 5 | Tests que leen configuración del sitio | Pasan siempre en tu máquina | Cambiar un ajuste ajeno |
| 6 | El bloque se registra pero no aparece | Cada capa devuelve éxito | Abrir el editor |
1. Las custom properties de CSS no fallan hacia lo razonable: fallan hacia cero
Tenía la escala de espaciado y de formas declarada en las clases raíz del plugin:
.mi-tarjeta,
.mi-formulario {
--espacio: 1rem;
--radio: 5px;
}
.mi-boton {
padding: var(--espacio);
border-radius: var(--radio);
background: var(--lavado);
}Funciona perfecto mientras el botón viva dentro de una de esas dos clases. El día que agregas un shortcode que renderiza un botón suelto dentro del markup del tema, la variable no resuelve, y aquí está lo que no es obvio:
Cuando una custom property no resuelve, el navegador no ignora ese var() ni cae a un valor por defecto. Descarta la declaración completa. El término del spec es invalid at computed-value time, y el resultado es que la propiedad toma su valor heredado si es heredable, y su valor inicial si no lo es.
padding no es heredable. Su valor inicial es 0. border-radius tampoco: 0. background-color tampoco: transparent.
Así que mi botón salía sin padding, cuadrado y sin fondo, en cada página donde no estuviera dentro de uno de esos dos contenedores. Y leyendo el CSS no se ve: la regla está ahí, es correcta, tiene el padding escrito. Los tests pasaban porque yo verificaba el HTML generado, no los estilos computados.
La lección: si tus tokens viven en una lista de clases raíz mantenida a mano, esa lista se va a quedar atrás. Decláralos en todo lo que renderizas:
:where([class*="mi-plugin-"]) {
--espacio: 1rem;
--radio: 5px;
}:where() mantiene la especificidad en cero, así que el tema siempre puede ganar, y el selector cubre cada elemento que exista hoy y los que agregues mañana.
2. Un ajuste guardado en dos lugares es un bug con la mecha encendida
Tenía un interruptor de "registrar actividad" dentro del array de ajustes, y además una option separada que lo espejaba para poder leerla rápido. La función que escribía el registro leía el espejo.
El espejo solo se actualizaba al guardar el formulario. Cualquier otro camino que tocara el ajuste dejaba los dos valores en desacuerdo. Y así llegó a producción: el checkbox en 0, el espejo en '1', y el plugin escribiendo 200 entradas de registro con el interruptor apagado.
Lo reportó el cliente: "¿por qué hay información en el panel si el checkbox no está marcado?".
La lección: cualquier valor guardado dos veces es un bug esperando su turno, por más obvia que parezca la sincronización el día que lo escribes. Si necesitas una copia por rendimiento, que sea un caché derivado con invalidación explícita, no una segunda fuente de verdad. Y cuando elimines el espejo, bórralo al arrancar, sin esperar a que alguien guarde el formulario: las instalaciones que ya están mal se quedan mal.
3. Un umbral global se rompe en el contenido más corto
Había un ajuste que decide cuántas palabras de un contenido largo se muestran antes de cortarlo. Lo puse en 40 para armar una demo realista.
Un test se puso rojo: un contenido de 30 palabras se mostraba entero. Obvio en retrospectiva, porque las primeras 40 palabras de un texto de 30 palabras son el texto de 30 palabras.
Lo que lo hacía invisible: el umbral es global y nadie lo revisa contenido por contenido. Desde el editor no se nota nada. Y el contenido corto es justo el que menos sospechas.
La lección: un umbral global aplicado a contenido de tamaño variable necesita un límite relativo, no solo absoluto.
$limite = min($configurado, (int) floor($total_palabras / 2));Y por debajo de un mínimo, no mostrar nada: un adelanto de dos palabras no informa a nadie y sí puede filtrar todo.
4. Tu limpieza no corre justo cuando más la necesitas
Tres veces en este proyecto encontré basura en la base de datos: registros de prueba que un script mío había creado y nunca borró.
Las tres veces la causa fue la misma. Mis scripts tenían esta forma:
// 1. crear fixtures
// 2. hacer las aserciones
// 3. borrar los fixturesSi algo muere en el paso 2 (un error de tipeo, un método que llamé antes de escribirlo, un fatal de otro plugin), el paso 3 nunca corre. Y a diferencia de un test que falla, esto no te avisa: te enteras semanas después, cuando un dato huérfano hace fallar otra cosa.
La tercera vez me pasó dentro de la misma sesión: un script de prueba manual murió porque llamé un método que no había escrito todavía, dejó un registro huérfano, y ese registro rompió un test de un área completamente distinta. Perdí un rato buscando un bug del plugin que era mío.
La lección: en cualquier script de seed, migración o prueba, la limpieza va en un finally, o el script se escribe idempotente para que volver a correrlo arregle el estado.
try {
$ids = crear_fixtures();
// aserciones
} finally {
borrar_fixtures($ids ?? []);
}Y agrégale una verificación al final: cuenta los registros antes y después. Si no coincide, grita.
5. Un test que se rompe cuando cambias un ajuste ajeno estaba probando el ajuste
Al configurar la demo cambié un mensaje personalizado en los ajustes del sitio. Dos tests se pusieron rojos.
Ninguno de los dos tenía nada que ver con ese mensaje. Simplemente asumían el texto por defecto, porque en su ambiente ese era el valor. Estaban leyendo la configuración del sitio y llamándolo comportamiento.
Son los tests más traicioneros que hay: pasan siempre en tu máquina, se rompen en la de otra persona, y cuando se rompen te hacen dudar del código en vez del test.
La lección: un test fija sus propias entradas y las restaura al terminar. Si se rompe cuando cambias un ajuste que no está en su nombre, no estaba probando lo que dice.
$guardado = get_option(MI_OPTION);
update_option(MI_OPTION, $entradas_del_test);
// ... aserciones ...
update_option(MI_OPTION, $guardado);Es la misma disciplina que al testear modelos en Laravel: un test que depende del estado que encontró no está probando tu código, está probando tu máquina.
6. Registrar sin errores no significa que el bloque aparezca
Registré un bloque de Gutenberg. register_block_type() devolvía el objeto, el block.json era válido, el script se encolaba. Cero errores en cualquier capa.
El bloque no estaba en el insertador.
La causa: cuando block.json apunta a un script con "editorScript": "file:./editor.js", WordPress busca un archivo hermano editor.asset.php para saber las dependencias y la versión de ese script. Si no existe, el script se encola con dependencias vacías, el navegador lo ejecuta antes de que exista wp.blocks, el registerBlockType del lado del cliente nunca corre, y el bloque no existe para el editor. El servidor lo tiene registrado. Nadie se queja.
Sin build tool ese archivo lo mantienes a mano:
<?php
// editor.asset.php
return array(
'dependencies' => array(
'wp-blocks',
'wp-block-editor',
'wp-components',
'wp-element',
'wp-i18n',
),
'version' => '1.0.0',
);Lo descubrí porque el cliente escribió "sigo sin acceder al bloque". Dos veces: la primera busqué la causa en el lugar equivocado.
La lección: verifica en la superficie que toca el usuario, no en la capa que escribiste. "La función devolvió sin error" y "el usuario lo ve" son afirmaciones distintas.
Lo que tienen en común
Releyendo los seis, el patrón es incómodo de admitir: ninguno era difícil. Todos son triviales de arreglar y cuatro de los seis los introduje yo, en código que había escrito con cuidado y comentado con confianza.
Lo que los unía no era la dificultad, era el silencio. Ninguno produjo una señal. Y una suite de tests solo detecta lo que alguien pensó en verificar; por definición no cubre la clase de fallo que no se te ocurrió que era posible.
Lo que sí los encontró, en los seis casos, fue mirar el resultado: renderizar la página, poner un valor de verdad, contar los registros, o que una persona abriera la pantalla y dijera "esto no está".
Eso no es un argumento contra los tests. Las 615 aserciones me dejaron refactorizar tres veces sin miedo, y agarraron docenas de regresiones. Es un argumento contra tratar el verde como evidencia de que algo funciona.
Una cosa más: los límites que no puedes garantizar, dilos
El plugin oculta archivos adjuntos de contenido restringido: les saca las URL de las páginas, de los srcset, de los metadatos. Lo que no puede hacer es volver el archivo inalcanzable. Quien ya tenga la URL directa lo descarga igual, porque el servidor web entrega ese archivo sin cargar WordPress. Impedirlo requiere una regla en la configuración del servidor.
Leí el código del competidor más maduro del rubro, unos 769 archivos PHP. Tiene la misma limitación. En ningún lado la menciona.
Elegí escribirla en el ajuste, en el readme y en el docblock. Cuesta una frase incómoda y compra algo que no tiene reemplazo: que cuando el plugin dice que algo está protegido, se le pueda creer.
Preguntas frecuentes
¿Por qué un elemento pierde el padding cuando uso variables CSS?
Porque la custom property no resuelve en ese contexto. Cuando var() falla, el navegador descarta la declaración completa (invalid at computed-value time) y la propiedad toma su valor heredado si es heredable, o su valor inicial si no lo es. padding y border-radius no son heredables y su valor inicial es 0, así que el elemento sale sin padding y con esquinas rectas. La regla en el CSS se ve perfectamente correcta.
¿Dónde debo declarar las custom properties de un plugin o librería?
En un selector que cubra todo lo que renderizas, no en una lista de clases raíz mantenida a mano. Un patrón robusto es :where([class*="mi-prefijo-"]), que mantiene la especificidad en cero (el tema siempre puede sobreescribir) y alcanza los elementos que agregues en el futuro sin tocar la declaración.
¿Por qué mi bloque de Gutenberg se registra sin errores pero no aparece en el insertador?
Lo más probable es que falte el archivo *.asset.php junto al script del editor. WordPress lo usa para conocer las dependencias del script; sin él, el script se encola con dependencias vacías y se ejecuta antes de que exista wp.blocks, así que el registerBlockType del cliente nunca corre. El registro del lado del servidor es exitoso y no aparece ningún error.
¿Por qué un test pasa en mi máquina y falla en otra?
Casi siempre porque el test lee estado que no fijó: una option del sitio, un valor de configuración, un registro que dejó otro test. Un test debe fijar sus propias entradas y restaurarlas al terminar. Si se rompe al cambiar un ajuste que no aparece en su nombre, estaba probando la configuración del ambiente, no el comportamiento del código.
¿Cómo evito que un script de prueba deje basura en la base de datos?
Poniendo la limpieza en un bloque finally, o escribiendo el script de forma idempotente para que volver a correrlo arregle el estado. Si la limpieza está después de las aserciones, cualquier fallo intermedio la saltea y no genera ninguna señal. Conviene además contar los registros antes y después, y fallar si no coinciden.
¿Sirve tener una suite de tests en verde?
Sí, pero por lo que realmente ofrece: te deja refactorizar sin miedo y atrapa regresiones sobre lo que ya verificaste. Lo que no hace es demostrar que el software funciona, porque solo cubre los fallos que alguien imaginó. Los defectos silenciosos (los que degradan hacia algo plausible en lugar de lanzar un error) se encuentran mirando el resultado real: renderizar, usar valores realistas y revisar la interfaz.