La comprobación que se queda ciega justo donde usted la necesita
Ya hemos escrito antes sobre pruebas que no pueden fallar — aserciones satisfechas por simple aritmética, fixtures que borraban precisamente la variación que existían para detectar. Esas están muertas de manera uniforme. Dan verde con código correcto y verde con código roto y, en cuanto detecta usted una, sabe exactamente cuánto valía: nada, de forma constante.
Este artículo trata de una forma peor. Una comprobación que falla selectivamente — que funciona con los defectos corrientes y enmudece ante el defecto extremo. Informa en verde, y ese verde no carece de sentido; es activamente engañoso, porque se correlaciona con la gravedad del problema en la dirección equivocada.
Construimos una de estas tres veces seguidas mientras escribíamos una sola comprobación, y cada versión estaba rota de una manera distinta. Después, dos veces en el mismo día, dimos con el mismo modo de fallo en un sitio sin prueba alguna: una búsqueda de texto que devolvía «no encontrado» para una cadena que estaba en el fichero.
Tres versiones de una misma comprobación, todas equivocadas
Queríamos verificar que las traducciones de la interfaz estaban completas. Las fichas del generador llevan valores que son ellos mismos datos — signos del zodiaco, colores de ojos, tipos de pelo, deportes — y esos valores hay que traducirlos para cada idioma. Son 84 y residen en ficheros JSON, uno por idioma.
La comprobación es set/ng_check_i18n.php. Pasó por tres versiones antes de hacer nada útil.
Versión 1: nunca miraba el fichero
La primera versión recorría el código fuente PHP en busca de las claves dinámicas mediante una expresión regular, leyendo phpadd/ng_extra.php.
Ese fichero no existe. La ruta real es phpadd/name_gen/ng_extra.php.
Las funciones de fichero de PHP no lanzan una excepción ante una ruta inexistente de forma predeterminada; emiten una advertencia y devuelven false. La expresión regular se ejecutó entonces sobre un contenido vacío, no encontró nada y la comprobación concluyó que había cero claves dinámicas que verificar. Cero claves, ninguna de ellas ausente, 100 % completo. Verde.
Fíjese en qué se convirtió el fallo. No lo tragó un try/catch ni una @. Se convirtió, en silencio y por la semántica ordinaria del lenguaje, de «no encuentro el fichero» en «el fichero no contiene nada de interés» — y esos dos estados son indistinguibles en la salida. La comprobación no podría haber fallado con ninguna entrada, porque no estaba leyendo entrada alguna.
Hay aquí enterrada una segunda lección que solo vimos más tarde. La ruta no era meramente errónea; era todo el enfoque el que lo era. Las claves no son literales en el código fuente en absoluto — se construyen dentro de los cuerpos de las funciones, en ng_extra_sports(), en ng_extra_zodiac(), en arrays ensamblados en tiempo de ejecución. Ninguna expresión regular sobre ese fichero las habría encontrado, ni siquiera con la ruta correcta. Corregir la ruta habría producido una comprobación que seguiría siendo casi ciega, pero ahora ciega por una razón más sutil, y habría parecido arreglada. La versión definitiva deriva en cambio la lista de claves de forma empírica, a partir de los ficheros JSON.
Una comprobación que informa de un éxito sin leer su entrada no es una comprobación débil. Es un resultado fabricado.
Versión 2: se quedaba ciega justo en el defecto máximo
La segunda versión tenía la lista de claves correcta. Para evitar el ruido de las claves que legítimamente solo aparecen en un idioma, exigía que una clave estuviera presente en al menos dos idiomas antes de reclamar que lo estuviera en todos.
El sitio tiene tres idiomas activos: en como referencia, más uk y de. La situación en el disco era que uk tenía las 84 traducciones de valores y de ninguna.
Así que, para cada una de las 84 claves, el número de idiomas que la contenían era uno. El umbral de dos no se alcanzó nunca. No se comprobó ni una sola clave. Verde.
Vuelva a leer el mecanismo, porque lo que importa es su forma. La comprobación habría funcionado perfectamente si a de le hubieran faltado, pongamos, cuarenta claves — entonces sesenta habrían aparecido en dos idiomas, se habrían comprobado y se habrían notificado cuarenta fallos. Enmudeció por completo porque el agujero era total. La condición no llegó a activarse exactamente por la razón que hacía urgente que se activara.
Esta es la propiedad que hace que la ceguera selectiva sea peor que la muerte uniforme. Una comprobación que no funciona nunca no le enseña nada falso; descubre usted que está muerta la primera vez que la prueba. Una comprobación cuya sensibilidad es inversamente proporcional al tamaño del defecto da su verde más rotundo en el peor momento. Es un detector de humo que se apaga por encima de cierta temperatura.
El umbral es ahora 1, con un comentario encima que deja constancia del porqué:
El umbral es 1, no 2. La primera versión exigía «la clave existe en al menos dos idiomas» — y se quedó callada precisamente en el peor caso: solo
uktenía traducciones de valores,deno tenía ninguna. La condición «en dos idiomas» no llegó a cumplirse porque el agujero era completo. Una comprobación que se queda ciega en el defecto máximo es peor que ninguna comprobación: suministra una falsa tranquilidad.
Versión 3: daba falsas alarmas contra la propia referencia
Una vez corregido el umbral, la comprobación empezó a informar. Su primera salida fue una advertencia contra en.
Las claves dinámicas son traducciones de valores de datos ingleses — Sagittarius, Very curly, Ski Jumping. En el locale inglés, el valor es la traducción. No hay nada que hacer. Pero la comprobación aplicaba una regla uniforme a cada idioma, y en no la superaba, porque en no contenía una traducción del inglés al inglés.
Ese es el fallo opuesto y no es inofensivo. Una comprobación que advierte sobre un estado correcto entrena a su lector para saltarse las advertencias. Toda advertencia real futura tiene ahora que disputarle la atención a una que se sabe falsa, y la falsa está siempre ahí. El estado final es una comprobación que técnicamente se ejecuta y funcionalmente se ignora — el mismo valor que la versión 1, alcanzado a través del lector en lugar del código.
La corrección exime explícitamente al locale de referencia, con el razonamiento consignado:
enqueda excluido de la comprobación de claves dinámicas deliberadamente: son traducciones de valores de datos (Sagittarius, Very curly, Ski Jumping), y los valores ya están en inglés — no hay nada que traducir. Sin esta exención, la comprobación advertía contra su propia referencia, y el ruido le entrena a ignorarla.
Lo que las tres tienen en común
| Versión | Comportamiento en un sistema correcto | Comportamiento ante el peor defecto | Por qué parecía correcta |
|---|---|---|---|
| 1 | Verde | Verde | Fichero ausente leído como fichero vacío |
| 2 | Verde | Verde | Umbral inalcanzable cuando la laguna es total |
| 3 | Advertencia | Advertencia | Regla uniforme aplicada a la referencia |
Cada una de ellas se ejecutó hasta el final, imprimió un resultado plausible y terminó con código cero. Ninguna anunció un problema consigo misma. La única forma en que alguna de las tres quedó al descubierto fue que alguien sabía por su cuenta cuál debía ser la respuesta y notó que la comprobación decía lo contrario — que es precisamente el conocimiento cuya necesidad se supone que una comprobación elimina.
El defecto que se había construido para encontrar era, por cierto, real: de tenía 0 de 84 traducciones de valores, de modo que un visitante alemán veía Sagittarius, Hazel, Very curly y Ski Jumping en medio de páginas por lo demás en alemán. Esa laguna se ha cerrado desde entonces; de lleva ahora las 84. Tres versiones rotas de una comprobación se interponían entre un defecto real, visible y expuesto al usuario, y que alguien llegara a enterarse de él.
El mismo fallo sin código de por medio: los diacríticos
Dos veces en el mismo día, la búsqueda de una cadena informó de que la cadena no estaba en un fichero que sí la contenía.
Vietnamita. Estábamos retirando una fila del corpus vietnamita de nombres de pila. Thị no es un nombre de pila — es el marcador tradicional de segundo nombre femenino, y se había ingerido como si fuera un nombre. Al script de eliminación se le dio la cadena que había que borrar, tomada de un informe y no del fichero.
El informe la había escrito Thi. El fichero contenía Thị, donde ị es U+1ECB, latin small letter i with dot below. En bytes:
Thi -> 54 68 69
Thị -> 54 68 E1 BB 8B
Tres bytes frente a cinco. Ninguna coincidencia, nada borrado — y el script informó de un éxito, porque desde su punto de vista había completado una búsqueda y eliminación con cero errores. La fila incorrecta sobrevivió a una corrección escrita explícitamente para retirarla, y quedó consignada como corregida.
Francés. La dirección opuesta, el mismo mecanismo. Estábamos comprobando si el corpus francés de apellidos contenía la capa migratoria — los apellidos malienses, senegaleses, portugueses y vietnamitas que todo conjunto de datos francés honesto debe tener. Buscamos TRAORE.
No encontrado. La conclusión que se sacó fue que faltaba en el corpus una capa demográfica entera, lo cual es un defecto de datos grave y habría justificado una reconstrucción considerable.
El corpus contiene Traoré, con peso 6.674. Junto a Da Silva con 29.515, Dos Santos con 17.661, Gonçalves con 16.448 y Nguyen con 11.922. La capa estaba enteramente presente. La búsqueda era de la forma sin tilde, y é no es e.
Merece la pena poner los dos incidentes uno al lado del otro, porque tienen signos opuestos:
| Incidente | Qué se buscó | Qué había en el fichero | Resultado | Daño |
|---|---|---|---|---|
| Vietnamita | Thi | Thị (U+1ECB) | No encontrado | Se ocultó un defecto real y se informó de él como corregido |
| Francés | TRAORE | Traoré (U+00E9) | No encontrado | Se inventó un defecto que no existía |
Una búsqueda ocultó un fallo. La otra fabricó uno. Ninguna de las dos lanzó un error, ninguna devolvió un conjunto de resultados vacío de una manera que pareciera anómala, y ambas produjeron la salida más plausible que puede producir una búsqueda: nada encontrado.
Por qué «nada encontrado» es el resultado más peligroso en informática
Una excepción es un buen resultado. Una caída es un buen resultado. Los dos son inequívocos, los dos le detienen, los dos se nombran a sí mismos.
«Nada encontrado» no es nada de eso. Es la salida correcta para al menos cuatro situaciones completamente distintas:
- La cadena realmente no está.
- La cadena está, en una normalización diferente — tildes, mayúsculas y minúsculas, composición Unicode, caracteres invisibles bidi o de anchura.
- La búsqueda nunca leyó la entrada: ruta equivocada, codificación equivocada, búfer vacío, permiso denegado.
- La entrada se leyó pero es inservible — un PDF que se extrajo como binario, un fichero UTF-16 leído como UTF-8, un fichero con una marca de orden de bytes pegada al primer campo.
Solo la primera es información. Las otras tres son fallos de la búsqueda, y las cuatro se presentan de forma idéntica: un resultado vacío y un código de salida cero.
Nos topamos con el caso 4 esa misma semana, en una investigación sin relación. Al comprobar si el regulador sudafricano reserva algún número de teléfono para uso ficticio, buscamos «reserved», «drama», «film» y «test» en una copia extraída del Government Gazette correspondiente. Los cuatro devolvieron cero. Eso quedó consignado brevemente como un hallazgo confirmado: que Sudáfrica no reserva nada.
El extracto era binario ilegible. También habría devuelto cero coincidencias para «the». Habíamos dado por buena una conclusión basada en una búsqueda estructuralmente incapaz de tener éxito — el error idéntico al de la versión 1 de la comprobación de traducciones, en otro soporte, cinco días después. Esa historia, y los rangos reservados que sí eran confirmables, se cuenta en Números de teléfono falsos que no pueden existir.
La regla que lo habría evitado todo
Hay un hábito que responde a cada uno de los incidentes anteriores, y es de una sencillez casi insultante:
Copie del fichero la cadena que está buscando. Nunca de un informe, de un mensaje de chat, de un mensaje de commit ni de su memoria.
Cada uno de estos fallos venía de una cadena que había pasado por un intermediario legible para humanos. Thi había perdido su diacrítico en algún punto entre el fichero y el informe. TRAORE se tecleó de memoria, con la forma que aceptaría un motor de búsqueda. Ambas parecían completamente correctas en pantalla. Los diacríticos son la trampa ideal para esto porque la forma corrompida no es basura — es una palabra legible, plausible, de aspecto profesional, que da la casualidad de ser una cadena distinta.
De ahí se derivan tres prácticas, en orden creciente de lo mucho que le molestarán y de lo mucho que valen:
Demuestre que la búsqueda puede tener éxito antes de creer que ha fracasado. Haga un grep de algo que sepa que está en el fichero. Si grep . no devuelve nada, el fichero no es lo que usted cree. Es una sola orden y distingue por completo el caso 1 de los casos 3 y 4.
Después de un borrado, verifique con un volcado hexadecimal. No «el script dijo OK» — el script dijo OK en el incidente vietnamita. Imprima los bytes de las filas que quedan y mírelos. Cinco bytes donde esperaba tres se ve en hexadecimal y es invisible en un terminal.
Busque en forma normalizada cuando quiera decir normalizada. Si quiere saber si un nombre está presente al margen de las tildes, quite las tildes de ambos lados antes de comparar, deliberadamente, como un paso documentado. Lo que salió mal con TRAORE no fue que la búsqueda insensible a las tildes sea mala — fue que la búsqueda era accidentalmente sensible a las tildes mientras la persona que la ejecutaba creía que no lo era.
Diseñar comprobaciones que fallen con estrépito en los extremos
Del lado del código, la comprobación de traducciones arroja un conjunto de propiedades que merece la pena exigir a cualquier comprobación antes de fiarse de su verde.
Pruebe la comprobación en los dos límites, no en el medio. Un defecto parcial es el caso fácil; cualquier versión de nuestra comprobación habría detectado que a de le faltaban cuarenta claves. Dele el caso vacío y el caso total. La versión 2 sobrevivió a un año de salidas de aspecto plausible y murió en el momento en que alguien preguntó qué hace cuando un idioma tiene cero traducciones.
Haga que «sin entrada» sea un fallo, no un aprobado. Si una comprobación lee un fichero, no encuentra nada e informa de un éxito, ese éxito es indistinguible de una fabricación. Afirme que la entrada no está vacía como una comprobación separada y previa. count($keys) === 0 debería ser un resultado rojo, no una puntuación del 100 %.
Desconfíe de los umbrales que dependen de la salud de los datos. «Presente en al menos dos idiomas», «al menos diez filas», «si el fichero es mayor que N» — cada uno de ellos es un interruptor que el propio defecto puede apagar. Si existe una salvaguarda para reducir el ruido, pregunte explícitamente qué le hace una entrada catastrófica. La nuestra apagaba la comprobación por completo.
Exima explícitamente los casos que se sabe correctos, y escriba por qué. La advertencia de la versión 3 contra en no se equivocaba sobre los datos; se equivocaba sobre la regla. La corrección es una exención acompañada de un comentario, no una regla relajada, porque una regla relajada deja de detectar también lo real.
Trate una comprobación ruidosa como una comprobación rota. Una advertencia siempre presente es una advertencia que nunca se lee. Solo hay dos estados aceptables para una advertencia recurrente: corregir la condición o eximirla explícitamente. Dejarla ahí para filtrarla mentalmente es el tercer estado, y es la forma en que una comprobación que se ejecuta se vuelve decorativa.
Prefiera la derivación empírica al reconocimiento de patrones sobre el código fuente. La versión 1 intentaba recuperar una lista de claves por expresión regular a partir de PHP que construye las claves en tiempo de ejecución. Leerlas del artefacto en el que las claves aterrizan realmente no puede quedarse corta en silencio de la misma manera; si el artefacto falta, obtiene usted cero claves y — según la regla anterior — eso debe ser rojo.
Dos tipos de comprobación muerta
Merece la pena explicitar en qué se diferencia esto del artículo sobre las pruebas de mutación, porque los remedios son distintos.
| Qué comparamos | Uniformemente muerta | Selectivamente ciega |
|---|---|---|
| Comportamiento con código correcto | Verde | Verde |
| Comportamiento ante un defecto pequeño | Verde | Rojo — correctamente |
| Comportamiento ante un defecto total | Verde | Verde |
| Cómo se descubre | Pruebas de mutación | Dándole el caso extremo |
| Qué le cuesta | Nada; nunca ayudó | La confianza, en el peor momento |
Las pruebas de mutación encuentran el primer tipo de forma fiable, porque una mutación es un defecto pequeño y deliberado y una prueba uniformemente muerta sigue en verde ante ella. No encontrarán de forma fiable el segundo tipo — una mutación modesta es exactamente la entrada que una comprobación selectivamente ciega trata correctamente. Nuestra versión 2 habría superado una prueba de mutación con nota. Borrar cuarenta traducciones la pone en rojo, en el momento previsto, con aspecto de estar sana.
La práctica adicional, entonces, no son más mutaciones, sino mutaciones más extremas: no «quitar una traducción», sino «quitar todas las traducciones de un idioma, luego quitar el idioma, luego vaciar el fichero, luego borrar el fichero». Una comprobación debería volverse más ruidosa a medida que la entrada empeora, de forma monótona. Si existe alguna entrada lo bastante grave como para acallarla, esa es justo la entrada que llegará.
La lista de comprobación
- Copie las cadenas de búsqueda del fichero, nunca de un informe, de un chat o de la memoria. Los diacríticos sobreviven al trayecto visualmente, pero no a nivel de bytes.
- Antes de creer un resultado negativo, demuestre que la búsqueda podría haber encontrado algo. Haga un grep de una cadena que sepa que está presente.
- Después de un borrado, verifique en hexadecimal. «El script informó de un éxito» es lo que dijo el script cuando no borró nada.
- Haga que una entrada vacía sea un resultado rojo. Cero claves encontradas no debe redondearse nunca a 100 % completo.
- Dele a cada comprobación el caso de fallo total, no uno parcial. Los defectos parciales son la entrada fácil; la ceguera vive en el extremo.
- Audite cada umbral y cada salvaguarda para ver si el defecto puede desactivarlos. «En al menos dos idiomas» quedó desactivado por el hecho de que la laguna era completa.
- Exima el caso de referencia explícitamente y con una razón escrita. No relaje la regla para acallar una advertencia sobre un estado que es correcto.
- Trate una advertencia permanentemente presente como un defecto de la comprobación. El ruido en el caso normal es la forma en que una comprobación que se ejecuta se convierte en una comprobación ignorada.
- Distinga «ausencia confirmada» de «no encontrado». Son la misma salida y afirmaciones completamente distintas.
Ninguna de las tres versiones de aquella comprobación anunció jamás un problema. Tampoco lo hizo el script que no borró nada, ni la búsqueda que inventó una capa migratoria ausente, ni el grep lanzado contra una gaceta corrupta. Todos y cada uno de ellos tuvieron éxito, imprimieron un resultado de aspecto razonable y terminaron con código cero.
Ahí reside toda la dificultad. El código roto se anuncia a sí mismo; la verificación rota, no, porque el modo de fallo de una verificación consiste en darle a usted la razón.
Para la otra mitad de este asunto — aserciones satisfechas por el álgebra, y cómo establecimos cuáles de nuestras pruebas eran capaces de fallar — véase Una prueba que no puede fallar. Para la auditoría de números de teléfono, donde el mismo negativo ciego apareció en un documento de un regulador, véase Números de teléfono falsos que no pueden existir. Para los defectos de datos que estas búsquedas perseguían, véase Lo que hicimos mal y Trampas en los datos de nombres (artículo complementario, aún no publicado).
Datos actualizados a 2026-07-18
Todas las cifras se verificaron el 18 de julio de 2026 contra los ficheros presentes en el disco.
Fuentes y observaciones:
- La comprobación de traducciones —
set/ng_check_i18n.php, 125 líneas, un script autónomo enset/. Las tres versiones rotas quedan consignadas como comentarios de advertencia en el sitio exacto de cada corrección: el error de ruta en las líneas 52–53, el umbral de dos idiomas en las líneas 70–76, la exención del locale de referencia en las líneas 100–107. Las versiones anteriores en sí no se conservan — el directorio no está bajo control de versiones, de modo que la implementación que barría con expresión regular solo está atestiguada por el comentario retrospectivo que la describe. - ⚠ No está en la suite de regresión. Esta comprobación es un script autónomo y no forma parte de
ng_regression_tests.php, que no contiene código i18n alguno. Tampoco está aún conectada a la comprobación posterior al despliegue; eso sigue siendo una recomendación. Una comprobación que hay que recordar y lanzar a mano es una cuarta forma de estar ciego, y es el estado actual. - El número 84 — verificado de dos maneras independientes. Por composición de
phpadd/name_gen/ng_extra.php: 56 deportes + 12 signos del zodiaco + 6 colores de ojos + 5 tipos de pelo + 6 colores de pelo = 85, menos un duplicado (Brownaparece a la vez como color de ojos y como color de pelo) = 84 únicos. Por diferencia de claves contra el JSON en producción enphpadd/i18n/:entiene 86 claves con 0 fuera del conjunto de referencia;ukydetienen 170 cada uno, de las cuales 84 quedan fuera de él. - ⚠ Una cifra contradictoria en el código fuente. El comentario en
ng_check_i18n.php:72afirma queuktenía 69 traducciones de valores. Tanto el acta de auditoría como los ficheros actuales dan 84. El 69 no lo corrobora nada y no se pudo reconstruir; 84 es la cifra medida y es la que se usa aquí. - «0 de 84» es histórico. La laguna de
deera real en el momento de la auditoría y quedó consignada comode: 0/84frente auk: 84/84. Se ha cerrado desde entonces —demide ahora 84 de 84 en el disco. Los tres idiomas implicados sonen(referencia),ukyde, que constituyen el conjunto activo completo para las claves de valores dinámicos. - El incidente vietnamita — consignado en
name_dev/NEVER-DO.mden las líneas 409–417. La cadena se tomó de un informe donde aparecía comoThi; el fichero conteníaThị, conịen U+1ECB. Secuencias de bytes54 68 69frente a54 68 E1 BB 8B— tres bytes frente a cinco. La eliminación no coincidió con nada e informó de un éxito. La fila ya no está; el corpus conserva únicamenteThịnh(peso 18) yĐức Thịnh(peso 5). - El incidente francés — consignado en
NEVER-DO.mden las líneas 488–492, y planteado allí como el mismo mecanismo que el caso vietnamita con el signo opuesto: uno ocultó un defecto, el otro inventó uno.Traoréestá presente tanto ennames/fr_FR/lastname_male.tsvcomo enlastname_female.tsv, en la línea 1006, peso 6.674, exactamente una vez por fichero. La capa migratoria circundante que la búsqueda debía detectar:Da Silva29.515,Dos Santos17.661,Gonçalves16.448,Nguyen11.922. - La gaceta sudafricana — un Government Gazette de ICASA extraído a texto, que resultó ser binario en gran parte ilegible y devolvió cero coincidencias para «reserved», «drama», «film» y «test». Consignado aquí como una instancia del caso 4, no como un hallazgo sobre la numeración sudafricana.