Escriba pruebas que importen: aborde primero el código más complejo

Publicado: 2022-03-11

Hay muchas discusiones, artículos y blogs sobre el tema de la calidad del código. La gente dice: ¡utilice técnicas basadas en pruebas! ¡Las pruebas son "imprescindibles" para comenzar cualquier refactorización! Todo eso está bien, pero estamos en 2016 y todavía hay un volumen masivo de productos y bases de código en producción que se crearon hace diez, quince o incluso veinte años. No es ningún secreto que muchos de ellos tienen código heredado con poca cobertura de prueba.

Si bien me gustaría estar siempre a la vanguardia, o incluso a la vanguardia del mundo de la tecnología, comprometido con nuevos proyectos y tecnologías interesantes, desafortunadamente no siempre es posible y, a menudo, tengo que lidiar con sistemas antiguos. Me gusta decir que cuando desarrollas desde cero, actúas como un creador, dominando materia nueva. Pero cuando trabaja en código heredado, es más como un cirujano: sabe cómo funciona el sistema en general, pero nunca sabe con certeza si el paciente sobrevivirá a su "operación". Y dado que es un código heredado, no hay muchas pruebas actualizadas en las que pueda confiar. Esto significa que, con mucha frecuencia, uno de los primeros pasos es cubrirlo con pruebas. Más precisamente, no solo para proporcionar cobertura, sino para desarrollar una estrategia de cobertura de prueba.

Acoplamiento y complejidad ciclomática: métricas para una cobertura de prueba más inteligente

Olvídese del 100% de cobertura. Pruebe de manera más inteligente al identificar las clases que tienen más probabilidades de fallar.
Pío

Básicamente, lo que necesitaba determinar era qué partes (clases/paquetes) del sistema necesitábamos cubrir con pruebas en primer lugar, dónde necesitábamos pruebas unitarias, dónde serían más útiles las pruebas de integración, etc. Es cierto que hay muchas maneras de enfoque este tipo de análisis y el que he usado puede no ser el mejor, pero es una especie de enfoque automático. Una vez que se implementa mi enfoque, lleva un tiempo mínimo realizar el análisis en sí mismo y, lo que es más importante, aporta algo de diversión al análisis del código heredado.

La idea principal aquí es analizar dos métricas: acoplamiento (es decir, acoplamiento aferente o CA) y complejidad (es decir, complejidad ciclomática).

El primero mide cuántas clases usan nuestra clase, por lo que básicamente nos dice qué tan cerca está una clase en particular del corazón del sistema; Cuantas más clases haya que usen nuestra clase, más importante es cubrirla con pruebas.

Por otro lado, si una clase es muy simple (por ejemplo, contiene solo constantes), incluso si es utilizada por muchas otras partes del sistema, no es tan importante para crear una prueba. Aquí es donde la segunda métrica puede ayudar. Si una clase contiene mucha lógica, la complejidad ciclomática será alta.

La misma lógica también se puede aplicar a la inversa; es decir, incluso si una clase no es utilizada por muchas clases y representa solo un caso de uso particular, todavía tiene sentido cubrirla con pruebas si su lógica interna es compleja.

Sin embargo, hay una advertencia: digamos que tenemos dos clases: una con CA 100 y complejidad 2 y la otra con CA 60 y complejidad 20. Aunque la suma de las métricas es mayor para la primera, definitivamente deberíamos cubrir el segundo primero. Esto se debe a que la primera clase está siendo utilizada por muchas otras clases, pero no es muy compleja. Por otro lado, la segunda clase también está siendo utilizada por muchas otras clases, pero es relativamente más compleja que la primera clase.

Para resumir: necesitamos identificar clases con alta CA y complejidad ciclomática. En términos matemáticos, se necesita una función de aptitud que pueda usarse como calificación - f(CA, Complejidad) - cuyos valores aumentan junto con CA y Complejidad.

En términos generales, las clases con las diferencias más pequeñas entre las dos métricas deben recibir la prioridad más alta para la cobertura de la prueba.

Encontrar herramientas para calcular CA y Complejidad para todo el código base, y proporcionar una forma sencilla de extraer esta información en formato CSV, resultó ser un desafío. Durante mi búsqueda, encontré dos herramientas que son gratuitas, por lo que sería injusto no mencionarlas:

  • Métricas de acoplamiento: www.spinellis.gr/sw/ckjm/
  • Complejidad: cyvis.sourceforge.net/

un poco de matemáticas

El problema principal aquí es que tenemos dos criterios: CA y complejidad ciclomática, por lo que debemos combinarlos y convertirlos en un valor escalar. Si tuviéramos una tarea ligeramente diferente, por ejemplo, encontrar una clase con la peor combinación de nuestros criterios, tendríamos un problema de optimización multiobjetivo clásico:

Tendríamos que encontrar un punto en el llamado frente de Pareto (rojo en la imagen de arriba). Lo interesante del conjunto de Pareto es que cada punto del conjunto es una solución a la tarea de optimización. Cada vez que nos movemos a lo largo de la línea roja, debemos llegar a un compromiso entre nuestros criterios: si uno mejora, el otro empeora. Esto se llama Escalarización y el resultado final depende de cómo lo hagamos.

Hay muchas técnicas que podemos usar aquí. Cada uno tiene sus pros y sus contras. Sin embargo, los más populares son el escalar lineal y el basado en un punto de referencia. Lineal es el más fácil. Nuestra función de aptitud se verá como una combinación lineal de CA y Complejidad:

f(CA, Complejidad) = A×CA + B×Complejidad

donde A y B son algunos coeficientes.

El punto que representa una solución a nuestro problema de optimización estará en la línea (azul en la imagen de abajo). Más precisamente, estará en la intersección de la línea azul y el frente rojo de Pareto. Nuestro problema original no es exactamente un problema de optimización. Más bien, necesitamos crear una función de clasificación. Consideremos dos valores de nuestra función de clasificación, básicamente dos valores en nuestra columna Clasificación:

R1 = A∗CA + B∗Complejidad y R2 = A∗CA + B∗Complejidad

Ambas fórmulas escritas arriba son ecuaciones de líneas, además estas líneas son paralelas. Teniendo en cuenta más valores de rango, obtendremos más líneas y, por lo tanto, más puntos donde la línea de Pareto se cruza con las líneas azules (punteadas). Estos puntos serán clases correspondientes a un valor de rango particular.

Desafortunadamente, hay un problema con este enfoque. Para cualquier línea (valor de rango), tendremos puntos con una CA muy pequeña y una complejidad muy grande (y viceversa) sobre ella. Esto coloca inmediatamente los puntos con una gran diferencia entre los valores de las métricas en la parte superior de la lista, que es exactamente lo que queríamos evitar.

La otra forma de escalar se basa en el punto de referencia. El punto de referencia es un punto con los valores máximos de ambos criterios:

(máx(CA), máx(Complejidad))

La función de aptitud será la distancia entre el punto de referencia y los puntos de datos:

f(CA,Complejidad) = √((CA−CA ) 2 + (Complejidad−Complejidad) 2 )

Podemos pensar en esta función de aptitud como un círculo con el centro en el punto de referencia. El radio en este caso es el valor del Rango. La solución al problema de optimización será el punto donde el círculo toca el frente de Pareto. La solución al problema original serán conjuntos de puntos correspondientes a los diferentes radios de los círculos, como se muestra en la siguiente imagen (las partes de los círculos para diferentes rangos se muestran como curvas azules punteadas):

Este enfoque trata mejor con los valores extremos, pero todavía hay dos problemas: primero, me gustaría tener más puntos cerca de los puntos de referencia para superar mejor el problema al que nos hemos enfrentado con la combinación lineal. En segundo lugar, la complejidad CA y ciclomática son inherentemente diferentes y tienen diferentes valores establecidos, por lo que debemos normalizarlos (por ejemplo, para que todos los valores de ambas métricas sean de 1 a 100).

Aquí hay un pequeño truco que podemos aplicar para resolver el primer problema: en lugar de mirar la CA y la Complejidad Ciclomática, podemos mirar sus valores invertidos. El punto de referencia en este caso será (0,0). Para resolver el segundo problema, podemos simplemente normalizar las métricas utilizando el valor mínimo. Así es como se ve:

Complejidad invertida y normalizada – NormComplexity:

(1 + min(Complejidad)) / (1 + Complejidad)∗100

CA invertida y normalizada – NormCA:

(1 + min(CA)) / (1+CA)∗100

Nota: agregué 1 para asegurarme de que no haya división por 0.

La siguiente imagen muestra un gráfico con los valores invertidos:

Clasificación final

Ahora estamos llegando al último paso: calcular el rango. Como se mencionó, estoy usando el método del punto de referencia, por lo que lo único que debemos hacer es calcular la longitud del vector, normalizarlo y hacerlo ascender con la importancia de la creación de una prueba unitaria para una clase. Aquí está la fórmula final:

Rango(ComplejidadNorma, CANorma) = 100 − √(ComplejidadNorma 2 + CANorma 2 ) / √2

Más estadísticas

Hay un pensamiento más que me gustaría agregar, pero primero echemos un vistazo a algunas estadísticas. Aquí hay un histograma de las métricas de acoplamiento:

Lo interesante de esta imagen es el número de clases con bajo CA (0-2). Las clases con CA 0 no se utilizan en absoluto o son servicios de nivel superior. Estos representan puntos finales de API, por lo que está bien que tengamos muchos de ellos. Pero las clases con CA 1 son las que usan directamente los puntos finales y tenemos más de estas clases que de puntos finales. ¿Qué significa esto desde la perspectiva de la arquitectura/diseño?

En general, significa que tenemos una especie de enfoque orientado a secuencias de comandos: elaboramos secuencias de comandos de cada caso comercial por separado (realmente no podemos reutilizar el código ya que los casos comerciales son demasiado diversos). Si ese es el caso, entonces definitivamente es un olor a código y necesitamos hacer una refactorización. De lo contrario, significa que la cohesión de nuestro sistema es baja, en cuyo caso también necesitamos una refactorización, pero esta vez una refactorización arquitectónica.

La información útil adicional que podemos obtener del histograma anterior es que podemos filtrar por completo las clases con bajo acoplamiento (CA en {0,1}) de la lista de clases elegibles para cobertura con pruebas unitarias. Sin embargo, las mismas clases son buenas candidatas para las pruebas funcionales/de integración.

Puedes encontrar todos los scripts y recursos que he usado en este repositorio de GitHub: ashalitkin/code-base-stats.

¿Siempre funciona?

No necesariamente. En primer lugar, se trata de análisis estático, no de tiempo de ejecución. Si una clase está vinculada desde muchas otras clases, puede ser una señal de que se usa mucho, pero no siempre es cierto. Por ejemplo, no sabemos si la funcionalidad es muy utilizada por los usuarios finales. En segundo lugar, si el diseño y la calidad del sistema son lo suficientemente buenos, lo más probable es que las diferentes partes/capas del mismo se desacoplen a través de interfaces, por lo que el análisis estático de la CA no nos dará una imagen real. Supongo que es una de las principales razones por las que CA no es tan popular en herramientas como Sonar. Afortunadamente, está totalmente bien para nosotros ya que, si recuerdas, estamos interesados ​​​​en aplicar esto específicamente a las viejas bases de códigos feos.

En general, diría que el análisis del tiempo de ejecución daría resultados mucho mejores, pero desafortunadamente es mucho más costoso, requiere más tiempo y es más complejo, por lo que nuestro enfoque es una alternativa potencialmente útil y de menor costo.

Relacionado: Principio de responsabilidad única: una receta para un gran código