wandres.dev
CI/CD · automatizar el ciclo

Calidad automatizada y el trinquete: lint, detekt, cobertura y bloquear cuando algo empeora

Un umbral absoluto de calidad sobre un proyecto vivo solo tiene dos destinos: se fija tan bajo que no detecta nada o tan alto que se desactiva la primera semana. Esta lección propone sustituirlo por un criterio diferencial, el trinquete, que permite cualquier estado presente pero prohíbe empeorarlo. Desarrolla el analizador de la plataforma y sus líneas base, el análisis estático con resolución de tipos, la diferencia entre cobertura global y cobertura del cambio, la publicación de hallazgos como anotaciones revisables y el problema de gobernar una métrica que se convierte en objetivo.

⏱ 22 min

Toda conversación sobre calidad automatizada acaba tarde o temprano en la misma discusión estéril: qué porcentaje de cobertura hay que exigir. Es una discusión estéril porque la pregunta está mal planteada. Un proyecto real de tres años tiene la cobertura que tiene, y ese número es un hecho histórico, no una decisión. Exigir ochenta cuando se está en cuarenta y uno significa bloquear todo el trabajo del equipo hasta que alguien escriba miles de pruebas que nadie ha priorizado; exigir cuarenta significa establecer por escrito que empeorar hasta ese punto es aceptable. Ninguna de las dos opciones describe lo que el equipo quiere de verdad, que es mucho más simple de enunciar y mucho más difícil de medir: que cada cambio deje el proyecto un poco mejor que como lo encontró, o al menos no peor.

🎯 Al terminar esta lección sabrás
  • Configurar el analizador de la plataforma y el análisis estático de Kotlin con líneas base que no oculten lo nuevo.
  • Distinguir la cobertura global de la cobertura del cambio y saber cuál merece bloquear una fusión.
  • Implementar un criterio de trinquete que permita el estado actual y prohíba la regresión.
  • Publicar los hallazgos como anotaciones sobre las líneas afectadas en lugar de como registros que nadie lee.

Dos analizadores con propósitos distintos

El analizador incluido en el plugin de Android y el analizador estático de Kotlin se solapan en apariencia y no se solapan en absoluto en lo que detectan. El primero conoce la plataforma: sabe qué llamadas exigen un nivel mínimo de API, qué recursos han quedado sin usar, qué permisos se declaran y no se comprueban, qué cadenas carecen de traducción, qué contextos se filtran en campos estáticos y qué elementos de interfaz carecen de descripción para las tecnologías de asistencia. Ese conocimiento del dominio es irremplazable y ninguna herramienta genérica lo tiene.

El segundo conoce el lenguaje: complejidad ciclomática, funciones excesivamente largas, anidamiento profundo, cadenas de llamadas seguras encadenadas hasta lo ilegible, excepciones capturadas y descartadas, mutabilidad innecesaria y las convenciones de estilo que el equipo acuerda. Su valor crece de forma desproporcionada cuando se ejecuta con resolución de tipos, es decir, con el classpath compilado disponible, porque solo entonces puede razonar sobre qué es realmente cada expresión en lugar de sobre cómo está escrita. La contrapartida es que esa modalidad exige compilar antes y por tanto no es gratuita.

Hay una tercera pieza que conviene separar de las dos anteriores aunque a menudo se agrupe con ellas: el formateador. Su función no es detectar defectos sino eliminar de la revisión humana toda una categoría de comentarios que no aportan información. Un formateador automático que se aplica sin negociación convierte el estilo en un no problema, y esa es exactamente la razón por la que debe ser una puerta bloqueante trivial de satisfacer, con un comando local que lo arregla todo, y nunca una lista de avisos que alguien tenga que atender uno por uno.

Ambos necesitan una decisión explícita sobre qué se considera error y qué se considera aviso, y esa decisión debe vivir en el repositorio en forma de configuración versionada, no en argumentos de línea de comandos del flujo. La razón es que el desarrollador debe poder reproducir en su máquina exactamente el mismo veredicto que produce la máquina de integración, y cualquier regla que solo exista en el flujo rompe esa propiedad y genera el peor de los ciclos de retroalimentación: descubrir el fallo después de haber empujado.

# El analizador de la plataforma sobre la variante que importa
./gradlew lintDebug

# Analisis estatico con resolucion de tipos: mas lento y mucho mas util
./gradlew detektMain

# Regenerar la linea base cuando se adopta una regla nueva
./gradlew lintDebug -Dlint.baselines.continue=true
./gradlew detektBaseline
🤖

Analizador de plataforma

Conoce niveles de API, recursos, permisos, fugas de contexto y accesibilidad. Insustituible.

🔬

Análisis estático de lenguaje

Complejidad, tamaño, anidamiento y convenciones. Multiplica su valor con resolución de tipos.

📊

Cobertura

Mide qué código ejecutan las pruebas. Nunca mide si esas pruebas comprueban algo.

🔒

Trinquete

Acepta el estado actual como línea base y prohíbe empeorarlo. Permite avanzar sin exigir milagros.

La línea base: amnistía sin amnesia

Adoptar una regla nueva sobre un proyecto existente produce siempre el mismo choque: cuatrocientas violaciones el primer día. La respuesta ingenua es desactivar la regla, y con ella toda posibilidad de que el problema deje de crecer. La respuesta correcta es la línea base, un fichero que registra las violaciones existentes en el momento de la adopción y las excluye del veredicto, de modo que la regla queda activa solo para el código nuevo.

Conviene entender bien la mecánica para no llevarse sorpresas. Ambas herramientas identifican cada violación por una combinación de ruta, regla y contexto, y esa identificación es sensible a los desplazamientos del fichero: mover una función de sitio puede hacer que una violación amnistiada reaparezca como nueva. No es un defecto sino un efecto secundario razonable, y la práctica correcta ante esa reaparición es arreglarla, no regenerar la línea base.

Y ahí está el peligro real de este mecanismo. La línea base es amnistía, no amnesia, y se degrada en amnesia en cuanto alguien regenera el fichero por comodidad. Una regeneración masiva convierte todas las violaciones nuevas en históricas de un plumazo y deja el fichero creciendo sin límite. Merece la pena tratar cualquier crecimiento de la línea base como un cambio que exige justificación explícita en la revisión, y añadir al pipeline una comprobación que compare el número de entradas con el de la rama principal y avise cuando aumente.

⚠️
Una línea base que crece es una regla desactivada en cámara lenta

El síntoma es fácil de detectar y muy fácil de ignorar: el fichero de línea base aparece modificado en pull requests cuyo asunto no tiene nada que ver con calidad. Cada una de esas modificaciones es una excepción concedida sin discusión. Al cabo de un año la herramienta sigue en el pipeline, sigue en verde y ya no protege nada. Contar las entradas y publicar el delta en cada pull request cuesta cinco líneas de script y convierte una degradación invisible en un dato incómodo que alguien tendrá que explicar.

Cobertura del cambio contra cobertura global

La cobertura global es una métrica de inventario: describe el estado acumulado del proyecto y se mueve con una lentitud desesperante, porque añadir mil líneas perfectamente probadas a un proyecto de cien mil apenas altera el porcentaje. Como criterio de bloqueo es doblemente mala: no reacciona a lo que acaba de ocurrir y castiga a quien toca un módulo mal cubierto aunque lo haya mejorado.

La cobertura del cambio mide otra cosa mucho más útil: de las líneas que este pull request añade o modifica, cuántas ejecuta alguna prueba. Es una métrica local, reacciona de inmediato, es atribuible a un autor concreto y admite un umbral absoluto razonable sin negociaciones épicas, porque exigir que el código nuevo esté cubierto es una demanda que casi nadie discute. Un proyecto puede tener cuarenta por ciento global y exigir ochenta por ciento sobre el cambio, y esas dos cifras conviven sin contradicción: la primera describe el pasado, la segunda gobierna el futuro.

Conviene además recordar qué mide realmente el instrumento, porque el nombre engaña. La cobertura de líneas registra que una línea se ejecutó durante la prueba, no que su resultado se comprobara. Una suite que invoca todo el código y no afirma nada puede alcanzar el cien por cien. La cobertura de ramas es estrictamente más informativa porque exige recorrer ambos lados de cada bifurcación, y su porcentaje siempre es menor y siempre es más honesto. Cuando haya que elegir un solo número para gobernar, es preferible el de ramas aunque duela más.

Sobre la implementación, la herramienta moderna para proyectos de Kotlin integra mejor con las particularidades del lenguaje que la clásica del ecosistema Java, especialmente en lo relativo a funciones en línea y clases generadas, que la segunda contabiliza de formas que producen porcentajes engañosos. En cualquiera de los dos casos, el informe debe emitirse en un formato estándar para que la herramienta que calcula el delta sobre el cambio pueda consumirlo sin acoplarse al proyecto.

- name: Pruebas con informe de cobertura
  run: ./gradlew testDebugUnitTest koverXmlReport

- name: Comparar contra la rama base y comentar el delta
  uses: madrapps/jacoco-report@v1.7.1
  with:
    paths: ${{ github.workspace }}/app/build/reports/kover/report.xml
    token: ${{ secrets.GITHUB_TOKEN }}
    min-coverage-changed-files: 80
    min-coverage-overall: 0
flowchart TD
A[Pull request] --> B[Analizador de plataforma y estatico]
B --> C[Informe en formato estandar]
C --> D[Anotaciones sobre las lineas afectadas]
A --> E[Pruebas con instrumentacion de cobertura]
E --> F[Informe de cobertura]
F --> G[Delta contra la rama principal]
G --> H{El cambio empeora la metrica}
H -- Si --> I[Bloqueo con el motivo concreto]
H -- No --> J[Fusion permitida]
D --> J
style I fill:#f38ba8,color:#11111b
style J fill:#a6e3a1,color:#11111b

El trinquete y la publicación de hallazgos

El criterio de trinquete se enuncia en una frase: se acepta cualquier estado actual y se prohíbe empeorarlo. Operativamente significa que el pipeline calcula la métrica sobre la rama del cambio y sobre la rama base, compara, y bloquea solo cuando la comparación va en la dirección equivocada. Aplicado al número de avisos del analizador, al recuento de entradas de la línea base, al tamaño del artefacto o al porcentaje de cobertura, produce un sistema que nunca exige un esfuerzo desproporcionado y que sin embargo garantiza monotonía.

Conviene aplicarlo con una banda de tolerancia en lugar de con una comparación estricta, porque algunas métricas tienen ruido propio. El porcentaje de cobertura oscila unas décimas por razones que no dependen del cambio, como el orden de ejecución de las pruebas o la generación de código condicionada al entorno, y un trinquete sin holgura convierte ese ruido en bloqueos aleatorios que erosionan la confianza en el mecanismo. Media décima de margen basta para absorber la variabilidad sin abrir la puerta a una degradación sostenida, que seguiría siendo visible en la tendencia mensual aunque cada paso individual quedara dentro de la banda.

Tiene dos ventajas sobre el umbral absoluto que merecen enunciarse. La primera es política: nadie puede argumentar que el criterio es injusto, porque lo único que pide es no dejar el proyecto peor de como estaba. La segunda es técnica: el valor de referencia se recalcula solo, de modo que cada mejora que alguien aporta se convierte automáticamente en el nuevo suelo y no hay que actualizar ningún número a mano en ningún fichero.

Y tiene una trampa que hay que conocer: exige poder calcular la métrica sobre la rama base de forma barata, porque hacerlo dos veces duplica el coste. La solución habitual es publicar la métrica de la rama principal como artefacto en cada fusión y que los pull requests la lean en lugar de recalcularla, lo que reduce el trinquete a una descarga y una comparación.

El trinquete admite además una válvula de escape que conviene diseñar antes de necesitarla, porque tarde o temprano habrá un cambio legítimo que empeora una métrica: una migración grande, la incorporación de un módulo heredado, una refactorización que reduce la cobertura al eliminar pruebas redundantes. La válvula correcta no es desactivar la puerta sino permitir una anulación explícita, registrada con un motivo y con el nombre de quien la concede, que quede visible en el historial. Una excepción documentada es información; una puerta desactivada en silencio es deuda.

La otra mitad del trabajo es cómo se comunican los hallazgos. Un informe en un registro de mil líneas que hay que descargar y abrir tiene una tasa de lectura cercana a cero. Un hallazgo publicado como anotación sobre la línea exacta del cambio, visible en la vista de diferencias donde la persona ya está mirando, tiene una tasa de lectura cercana a uno. Ambos analizadores pueden emitir su salida en el formato estándar de intercambio de resultados de análisis estático, y la plataforma lo consume para pintar exactamente esas anotaciones.

- name: Emitir hallazgos en formato estandar
  run: ./gradlew lintDebug detektMain
  continue-on-error: true

- name: Publicar como anotaciones revisables
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: app/build/reports/lint-results-debug.sarif
    category: android-lint
💡
Bloquear con el motivo, no con el resultado

Un fallo que dice que la comprobación de calidad no pasó obliga a la persona a investigar. Un fallo que dice que la cobertura del cambio es del cincuenta y dos por ciento frente al ochenta exigido, y que enumera las tres funciones nuevas sin ninguna prueba que las ejecute, resuelve el problema en el mismo mensaje. La diferencia en tiempo agregado a lo largo de un año es enorme, y el coste de conseguirla es escribir el mensaje de error con el mismo cuidado con el que se escribiría un mensaje de error de la aplicación.

Toda métrica que gobierna deja de medir

Existe una regularidad conocida en economía y en ciencias sociales, atribuida a Goodhart, según la cual una medida deja de ser una buena medida en cuanto se convierte en objetivo. La ingeniería de software la reproduce con una fidelidad casi cómica, y la cobertura de pruebas es su ejemplo canónico. Mientras la cobertura solo se observa, es un indicador honesto y bastante informativo del cuidado con que se trata el código. En cuanto se le asigna un umbral que bloquea fusiones, empieza a aparecer una clase de prueba que nadie habría escrito voluntariamente: la que invoca una función, no comprueba nada de lo que devuelve, y existe únicamente porque la línea tenía que quedar marcada como ejecutada. Esa prueba aumenta la métrica y disminuye la calidad, porque añade código que hay que mantener, que se rompe con cualquier refactorización y que produce la ilusión de estar protegido. Lo interesante no es constatar el fenómeno sino entender que no se puede evitar, solo administrar. Cualquier proxy automatizable de calidad es, por definición, más simple que la calidad que pretende representar, y esa brecha es exactamente el espacio en el que la optimización perversa opera. La respuesta madura tiene tres partes. La primera es preferir siempre criterios diferenciales sobre absolutos, porque un trinquete premia la dirección del movimiento y es mucho más difícil de falsear que un número. La segunda es usar varias métricas ortogonales a la vez, de modo que satisfacer una degradando las demás sea visible en lugar de invisible. La tercera, y la única que de verdad sostiene el sistema, es no confundir nunca la puerta automática con el juicio: la máquina existe para que ningún ser humano tenga que gastar atención en detectar lo mecánico, precisamente para que toda esa atención quede disponible para lo que ninguna máquina puede evaluar, que es si el código dice lo que quería decir. Un pipeline de calidad que hace que la revisión humana sea más superficial ha fracasado aunque todas sus métricas mejoren.

⚔️ Instala el trinquete en tu proyecto
  1. Adopta una regla nueva del analizador de plataforma con línea base y añade una comprobación que avise cuando el número de entradas crezca.
  2. Migra tu análisis estático a la modalidad con resolución de tipos y mide cuánto tiempo añade al camino crítico.
  3. Sustituye tu umbral global de cobertura por un umbral sobre las líneas modificadas y observa qué pull requests habría bloqueado el último mes.
  4. Publica la métrica de la rama principal como artefacto en cada fusión y haz que los pull requests la lean en lugar de recalcularla.
  5. Convierte los informes en anotaciones sobre las líneas del cambio y compara cuántos hallazgos se resuelven antes de la fusión frente al mes anterior.