Escreva testes que importam: resolva o código mais complexo primeiro

Publicados: 2022-03-11

Existem muitas discussões, artigos e blogs sobre o tema qualidade de código. As pessoas dizem - use técnicas de Test Driven! Os testes são “obrigatórios” para iniciar qualquer refatoração! Isso é tudo legal, mas estamos em 2016 e há um volume enorme de produtos e bases de código ainda em produção que foram criados dez, quinze ou até vinte anos atrás. Não é segredo que muitos deles têm código legado com baixa cobertura de teste.

Embora eu gostaria de estar sempre na vanguarda, ou até mesmo sangrenta, do mundo da tecnologia - envolvido com novos projetos e tecnologias interessantes - infelizmente nem sempre é possível e muitas vezes tenho que lidar com sistemas antigos. Eu gosto de dizer que quando você desenvolve do zero, você age como um criador, dominando a nova matéria. Mas quando você está trabalhando em código legado, você é mais como um cirurgião – você sabe como o sistema funciona em geral, mas nunca sabe ao certo se o paciente sobreviverá à sua “operação”. E como é um código legado, não há muitos testes atualizados nos quais você possa confiar. Isso significa que, muito frequentemente, um dos primeiros passos é cobri-lo com testes. Mais precisamente, não apenas para fornecer cobertura, mas para desenvolver uma estratégia de cobertura de teste.

Acoplamento e complexidade ciclomática: métricas para uma cobertura de teste mais inteligente

Esqueça 100% de cobertura. Teste de forma mais inteligente, identificando classes com maior probabilidade de quebrar.
Tweet

Basicamente, o que eu precisava determinar era quais partes (classes/pacotes) do sistema que precisávamos cobrir com testes em primeiro lugar, onde precisávamos de testes de unidade, onde testes de integração seriam mais úteis etc. abordar esse tipo de análise e a que usei pode não ser a melhor, mas é meio que uma abordagem automática. Uma vez que minha abordagem é implementada, leva um tempo mínimo para realmente fazer a análise em si e, o que é mais importante, traz um pouco de diversão para a análise de código legado.

A ideia principal aqui é analisar duas métricas – acoplamento (ou seja, acoplamento aferente, ou CA) e complexidade (ou seja, complexidade ciclomática).

O primeiro mede quantas classes usam nossa classe, então basicamente nos diz o quão perto uma determinada classe está do coração do sistema; quanto mais classes existem que usam nossa classe, mais importante é cobri-la com testes.

Por outro lado, se uma classe é muito simples (por exemplo, contém apenas constantes), então mesmo que seja usada por muitas outras partes do sistema, não é tão importante criar um teste para ela. Aqui é onde a segunda métrica pode ajudar. Se uma classe contiver muita lógica, a complexidade ciclomática será alta.

A mesma lógica também pode ser aplicada ao contrário; ou seja, mesmo que uma classe não seja usada por muitas classes e represente apenas um caso de uso específico, ainda faz sentido cobri-la com testes se sua lógica interna for complexa.

Porém, há uma ressalva: digamos que temos duas classes – uma com CA 100 e complexidade 2 e outra com CA 60 e complexidade 20. Mesmo que a soma das métricas seja maior para a primeira, definitivamente devemos cobrir o segundo primeiro. Isso ocorre porque a primeira classe está sendo usada por muitas outras classes, mas não é muito complexa. Por outro lado, a segunda classe também está sendo usada por muitas outras classes, mas é relativamente mais complexa que a primeira classe.

Para resumir: precisamos identificar classes com alta complexidade CA e Ciclomática. Em termos matemáticos, é necessária uma função de aptidão que possa ser usada como uma classificação - f(CA,Complexidade) - cujos valores aumentam junto com CA e Complexidade.

De um modo geral, as classes com as menores diferenças entre as duas métricas devem receber a prioridade mais alta para cobertura de teste.

Encontrar ferramentas para calcular CA e Complexidade para toda a base de código e fornecer uma maneira simples de extrair essas informações no formato CSV provou ser um desafio. Durante minha busca, me deparei com duas ferramentas que são gratuitas, então seria injusto não mencioná-las:

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

Um pouco de matemática

O principal problema aqui é que temos dois critérios – CA e complexidade ciclomática – então precisamos combiná-los e convertê-los em um valor escalar. Se tivéssemos uma tarefa um pouco diferente – por exemplo, encontrar uma classe com a pior combinação de nossos critérios – teríamos um problema clássico de otimização multiobjetivo:

Precisaríamos encontrar um ponto na chamada frente de Pareto (vermelho na figura acima). O que é interessante sobre o conjunto de Pareto é que cada ponto do conjunto é uma solução para a tarefa de otimização. Sempre que nos movemos ao longo da linha vermelha, precisamos fazer um compromisso entre nossos critérios – se um melhora, o outro piora. Isso é chamado de Escalarização e o resultado final depende de como fazemos isso.

Existem muitas técnicas que podemos usar aqui. Cada um tem seus prós e contras. No entanto, os mais populares são a escalarização linear e a baseada em um ponto de referência. Linear é o mais fácil. Nossa função de aptidão se parecerá com uma combinação linear de CA e Complexidade:

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

onde A e B são alguns coeficientes.

O ponto que representa uma solução para o nosso problema de otimização estará na linha (azul na figura abaixo). Mais precisamente, será na interseção da linha azul e da frente vermelha de Pareto. Nosso problema original não é exatamente um problema de otimização. Em vez disso, precisamos criar uma função de classificação. Vamos considerar dois valores de nossa função de classificação, basicamente dois valores em nossa coluna Rank:

R1 = A∗CA + B∗Complexidade e R2 = A∗CA + B∗Complexidade

Ambas as fórmulas escritas acima são equações de linhas, além disso, essas linhas são paralelas. Levando em consideração mais valores de classificação, obteremos mais linhas e, portanto, mais pontos onde a linha de Pareto se cruza com as linhas azuis (pontilhadas). Esses pontos serão classes correspondentes a um valor de classificação específico.

Infelizmente, há um problema com essa abordagem. Para qualquer linha (valor de Rank), teremos pontos com CA muito pequena e Complexidade muito grande (e vice-versa) sobre ela. Isso imediatamente coloca os pontos com uma grande diferença entre os valores das métricas no topo da lista, exatamente o que queríamos evitar.

A outra maneira de fazer a escalarização é baseada no ponto de referência. Ponto de referência é um ponto com os valores máximos de ambos os critérios:

(max(CA), max(Complexidade))

A função fitness será a distância entre o ponto de referência e os pontos de dados:

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

Podemos pensar nessa função de aptidão como um círculo com o centro no ponto de referência. O raio neste caso é o valor do Rank. A solução do problema de otimização será o ponto onde o círculo toca a frente de Pareto. A solução para o problema original serão conjuntos de pontos correspondentes aos diferentes raios do círculo, conforme mostrado na figura a seguir (partes dos círculos para diferentes classificações são mostradas como curvas azuis pontilhadas):

Essa abordagem lida melhor com valores extremos, mas ainda há dois problemas: Primeiro – gostaria de ter mais pontos próximos aos pontos de referência para superar melhor o problema que enfrentamos com a combinação linear. Segundo – a complexidade CA e ciclomática são inerentemente diferentes e têm valores diferentes definidos, então precisamos normalizá-los (por exemplo, para que todos os valores de ambas as métricas sejam de 1 a 100).

Aqui está um pequeno truque que podemos aplicar para resolver o primeiro problema – em vez de olhar para o CA e a Complexidade Ciclomática, podemos olhar para seus valores invertidos. O ponto de referência neste caso será (0,0). Para resolver o segundo problema, podemos apenas normalizar as métricas usando o valor mínimo. Aqui está como parece:

Complexidade invertida e normalizada – NormComplexity:

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

CA invertida e normalizada – NormCA:

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

Observação: adicionei 1 para garantir que não haja divisão por 0.

A figura a seguir mostra um gráfico com os valores invertidos:

Classificação final

Agora estamos chegando ao último passo - calcular a classificação. Como mencionado, estou usando o método do ponto de referência, então a única coisa que precisamos fazer é calcular o comprimento do vetor, normalizá-lo e fazê-lo ascender com a importância da criação de um teste unitário para uma classe. Aqui está a fórmula final:

Rank(NormComplexidade , NormCA) = 100 − √(NormComplexidade 2 + NormCA 2 ) / √2

Mais estatísticas

Há mais um pensamento que eu gostaria de acrescentar, mas primeiro vamos dar uma olhada em algumas estatísticas. Aqui está um histograma das métricas de acoplamento:

O interessante desse quadro é o número de turmas com baixa CA (0-2). As classes com CA 0 não são usadas ou são serviços de nível superior. Eles representam endpoints de API, então é bom que tenhamos muitos deles. Mas as classes com CA 1 são as que são usadas diretamente pelos endpoints e temos mais dessas classes do que endpoints. O que isso significa do ponto de vista da arquitetura / design?

Em geral, isso significa que temos um tipo de abordagem orientada a scripts – criamos scripts de cada caso de negócios separadamente (não podemos realmente reutilizar o código, pois os casos de negócios são muito diversos). Se for esse o caso, então é definitivamente um cheiro de código e precisamos fazer refatoração. Caso contrário, isso significa que a coesão do nosso sistema é baixa e, nesse caso, também precisamos de refatoração, mas desta vez, refatoração arquitetural.

Informações úteis adicionais que podemos obter do histograma acima são que podemos filtrar completamente as classes com baixo acoplamento (CA em {0,1}) da lista de classes qualificadas para cobertura com testes de unidade. As mesmas classes, porém, são boas candidatas para os testes de integração/funcional.

Você pode encontrar todos os scripts e recursos que usei neste repositório do GitHub: ashalitkin/code-base-stats.

Funciona Sempre?

Não necessariamente. Em primeiro lugar, trata-se de análise estática, não de tempo de execução. Se uma classe estiver vinculada a muitas outras classes, pode ser um sinal de que é muito usada, mas nem sempre é verdade. Por exemplo, não sabemos se a funcionalidade é realmente muito usada pelos usuários finais. Em segundo lugar, se o design e a qualidade do sistema forem bons o suficiente, provavelmente diferentes partes / camadas dele serão desacopladas por meio de interfaces, de modo que a análise estática da CA não nos dará uma imagem verdadeira. Acho que é uma das principais razões pelas quais a CA não é tão popular em ferramentas como o Sonar. Felizmente, está tudo bem para nós, já que, se você se lembra, estamos interessados ​​em aplicar isso especificamente a bases de código antigas e feias.

Em geral, eu diria que a análise de tempo de execução daria resultados muito melhores, mas infelizmente é muito mais caro, demorado e complexo, então nossa abordagem é uma alternativa potencialmente útil e de menor custo.

Relacionado: Princípio da Responsabilidade Única: Uma Receita para o Grande Código