TDD e cobertura de teste completa, onde são necessários casos de teste exponenciais

17

Estou trabalhando em um comparador de lista para ajudar a classificar uma lista não ordenada de resultados de pesquisa por requisitos muito específicos de nosso cliente. Os requisitos exigem um algoritmo de relevância classificada com as seguintes regras em ordem de importância:

  1. Correspondência exata no nome
  2. Todas as palavras de consulta de pesquisa em nome ou sinônimo do resultado
  3. Algumas palavras de consulta de pesquisa em nome ou sinônimo do resultado (% decrescente)
  4. Todas as palavras da consulta de pesquisa na descrição
  5. Algumas palavras da consulta de pesquisa na descrição (% decrescente)
  6. Data da última modificação decrescente

A escolha do design natural para esse comparador parecia ser uma classificação pontuada com base em potências de 2. A soma de regras menos importantes nunca pode ser mais do que uma correspondência positiva em uma regra de maior importância. Isso é alcançado pela seguinte pontuação:

  1. 32.
  2. 16
  3. 8 (Pontuação secundária do desempate com base em% descendente)
  4. 4
  5. 2 (Pontuação do desempate secundário com base em% descendente)
  6. 1

No espírito do TDD, decidi começar com meus testes de unidade primeiro. Ter um caso de teste para cada cenário único seria no mínimo 63 casos de teste exclusivos, sem considerar casos de teste adicionais para a lógica secundária do desempatador nas regras 3 e 5. Isso parece arrogante.

Os testes reais serão realmente menores. Com base nas próprias regras reais, certas regras garantem que as regras mais baixas sempre sejam verdadeiras (por exemplo, quando 'Todas as palavras da consulta de pesquisa aparecerem na descrição', então regra 'Algumas palavras da consulta de pesquisa aparecerão na descrição' sempre será verdadeira). Ainda vale o nível de esforço em escrever cada um desses casos de teste? Esse é o nível de teste normalmente exigido quando se fala em 100% de cobertura de teste no TDD? Caso contrário, qual seria uma estratégia de teste alternativa aceitável?

maple_shaft
fonte
1
Esse cenário e outros semelhantes foram os motivos pelos quais desenvolvi um "TMatrixTestCase" e um enumerador para o qual você pode escrever o código de teste uma vez e alimentá-lo com duas ou mais matrizes contendo as entradas e o resultado esperado.
Marjan Venema

Respostas:

16

Sua pergunta implica que o TDD tem algo a ver com "escrever todos os casos de teste primeiro". IMHO que não está "no espírito do TDD", na verdade é contra . Lembre-se de que TDD significa " desenvolvimento orientado a teste "; portanto, você precisa apenas dos casos de teste que realmente "orientam" sua implementação, e não mais. E desde que sua implementação não seja projetada de maneira a aumentar exponencialmente o número de blocos de código a cada novo requisito, você também não precisará de um número exponencial de casos de teste. No seu exemplo, o ciclo TDD provavelmente terá a seguinte aparência:

  • comece com o primeiro requisito da sua lista: as palavras com "Correspondência exata no nome" devem obter uma pontuação mais alta do que todo o resto
  • agora você escreve um primeiro caso de teste para isso (por exemplo: uma palavra que corresponde a uma determinada consulta) e implementa a quantidade mínima de código de trabalho que faz o teste passar
  • adicione um segundo caso de teste para o primeiro requisito (por exemplo: uma palavra que não corresponda à consulta) e antes de adicionar um novo caso de teste , altere seu código existente até que o segundo teste seja aprovado
  • dependendo dos detalhes da sua implementação, fique à vontade para adicionar mais casos de teste, por exemplo, uma consulta vazia, uma palavra vazia etc. (lembre-se: TDD é uma abordagem de caixa branca , você pode usar o fato de conhecer sua implementação quando projetar seus casos de teste).

Em seguida, comece com o segundo requisito:

  • "Todas as palavras da consulta de pesquisa no nome ou sinônimo do resultado" devem ter uma pontuação mais baixa que "Correspondência exata no nome", mas uma pontuação mais alta que tudo o resto.
  • agora crie casos de teste para esse novo requisito, assim como acima, um após o outro e implemente a próxima parte do seu código após cada novo teste. Não se esqueça de refatorar o código e os casos de teste.

Aqui está o problema : quando você adiciona casos de teste para o número de requisito / categoria "n", você só precisará adicionar testes para garantir que a pontuação da categoria "n-1" seja maior que a pontuação da categoria "n" . Você não precisará adicionar nenhum caso de teste para todas as outras combinações das categorias 1, ..., n-1, pois os testes que você escreveu anteriormente garantirão que as pontuações dessas categorias ainda estejam na ordem correta.

Portanto, isso fornecerá vários casos de teste que crescem aproximadamente lineares com o número de requisitos, e não exponencialmente.

Doc Brown
fonte
Eu realmente gosto desta resposta. Ele fornece uma estratégia de teste de unidade clara e concisa para abordar esse problema, mantendo o TDD em mente. Você o divide bastante bem.
Maple_shaft
@ maple_shaft: obrigado, e eu realmente gosto da sua pergunta. Eu gostaria de acrescentar que acho que, mesmo com a sua abordagem de projetar todos os casos de teste primeiro, a técnica clássica de criar classes de equivalência para testes pode ser suficiente para reduzir o crescimento exponencial (mas eu não resolvi isso até agora).
Doc Brown
13

Considere escrever uma classe que passa por uma lista predefinida de condições e multiplica uma pontuação atual por 2 para cada verificação bem-sucedida.

Isso pode ser testado com muita facilidade, usando apenas alguns testes simulados.

Em seguida, você pode escrever uma classe para cada condição e existem apenas 2 testes para cada caso.

Não estou realmente entendendo seu caso de uso, mas espero que este exemplo ajude.

public class ScoreBuilder
{
    private ISingleScorableCondition[] _conditions;
    public ScoreBuilder (ISingleScorableCondition[] conditions)
    {
        _conditions = conditions;
    }

    public int GetScore(string toBeScored)
    {
        foreach (var condition in _conditions)
        {
            if (_conditions.Test(toBeScored))
            {
                // score this somehow
            }
        }
    }
}

public class ExactMatchOnNameCondition : ISingleScorableCondition
{
    private IDataSource _dataSource;
    public ExactMatchOnNameCondition(IDataSource dataSource)
    {
        _dataSource = dataSource;
    }

    public bool Test(string toBeTested)
    {
        return _dataSource.Contains(toBeTested);
    }
}

// etc

Você notará que seus testes de 2 ^ condições se reduzem rapidamente a 4+ (2 * condições). 20 é muito menos arrogante que 64. E se você adicionar outra mais tarde, não precisará alterar QUALQUER das classes existentes (princípio aberto-fechado), para que não precise escrever 64 novos testes, apenas terá para adicionar outra classe com 2 novos testes e injetar isso na sua classe ScoreBuilder.

pdr
fonte
Abordagem interessante. O tempo todo minha mente nunca considerou uma abordagem OOP, pois eu estava presa na mente de um único componente comparador. Eu realmente não estava procurando conselhos sobre algoritmos, mas isso é muito útil, independentemente disso.
Maple_shaft
4
@ maple_shaft: Não, mas você estava procurando conselhos sobre TDD e esse tipo de algoritmo é perfeito para remover a questão de saber se vale a pena o esforço, reduzindo bastante o esforço. Reduzir a complexidade é essencial para o TDD.
PDR
+1, ótima resposta. Embora eu acredite, mesmo sem uma solução tão sofisticada, o número de casos de teste não precisa crescer exponencialmente (veja minha resposta abaixo).
Doc Brown
Não aceitei a sua resposta porque senti que outra resposta tratava melhor a questão real, mas gostei tanto da sua abordagem de design que estou implementando como você sugeriu. Isso reduz a complexidade e a torna mais extensível a longo prazo.
maple_shaft
4

Ainda vale o nível de esforço em escrever cada um desses casos de teste?

Você precisará definir "vale a pena". O problema com esse tipo de cenário é que os testes terão um retorno decrescente da utilidade. Certamente o primeiro teste que você escrever valerá totalmente a pena. Ele pode encontrar erros óbvios na prioridade e até coisas como analisar erros ao tentar dividir as palavras.

O segundo teste valerá a pena porque cobre um caminho diferente através do código, provavelmente verificando outra relação de prioridade.

O 63º teste provavelmente não valerá a pena, porque é algo que você tem 99,99% de confiança, coberto pela lógica do seu código ou de outro teste.

Esse é o nível de teste normalmente exigido quando se fala em 100% de cobertura de teste no TDD?

Meu entendimento é de 100% de cobertura significa que todos os caminhos de código são exercidos. Isso não significa que você faça todas as combinações de suas regras, mas todos os diferentes caminhos que seu código pode seguir (como você indica, algumas combinações não podem existir no código). Mas como você está fazendo TDD, ainda não há "código" para verificar os caminhos. A letra do processo diria make all 63+.

Pessoalmente, acho que 100% de cobertura é um sonho. Além disso, não é prático. Existem testes de unidade para atendê-lo, não vice-versa. À medida que você faz mais testes, você obtém retornos decrescentes do benefício (a probabilidade de o teste impedir um bug + a confiança de que o código está correto). Dependendo do que o seu código faz, define onde, nessa escala móvel, você para de fazer testes. Se o seu código estiver executando um reator nuclear, talvez todos os 63 ou mais testes valham a pena. Se o seu código estiver organizando seu arquivo de música, provavelmente você poderá se safar muito menos.

Telastyn
fonte
"cobertura" geralmente se refere à cobertura do código (toda linha de código é executada) ou cobertura da ramificação (toda ramificação é executada pelo menos uma vez em qualquer direção possível). Para os dois tipos de cobertura, não há necessidade de 64 casos de teste diferentes. Pelo menos, não com uma implementação séria que não contenha partes de código individuais para cada um dos 64 casos. Portanto, 100% de cobertura é totalmente possível.
Doc Brown
@DocBrown - claro, neste caso - outras coisas são mais difíceis / impossíveis de testar; considere caminhos de exceção de falta de memória. Todos os 64 não seriam obrigados no TDD 'pela letra' a impor o comportamento que é testado ignorando a implementação?
precisa saber é o seguinte
bem, meu comentário foi relacionado à pergunta e sua resposta dá a impressão de que pode ser difícil obter 100% de cobertura no caso do OP . Eu duvido disso. E eu concordo que você pode construir casos em que 100% de cobertura é mais difícil de alcançar, mas isso não foi solicitado.
Doc Brown
4

Eu diria que este é um caso perfeito para TDD.

Você tem um conjunto conhecido de critérios para testar, com uma análise lógica desses casos. Supondo que você irá testá-los agora ou mais tarde, parece fazer sentido pegar o resultado conhecido e construí-lo, assegurando que você esteja, de fato, cobrindo cada uma das regras de forma independente.

Além disso, você descobrirá se adicionar uma nova regra de pesquisa quebra uma regra existente. Se você fizer tudo isso no final da codificação, você provavelmente corre um risco maior de alterar um para corrigir um, que quebra outro, que quebra outro ... E você aprende ao implementar as regras se seu design é válido ou precisa de ajustes.

Wonko, o são
fonte
1

Não sou fã de interpretar estritamente 100% da cobertura de teste como escrever especificações contra cada método único ou testar todas as permutações do código. Fazer isso fanaticamente tende a levar a um design de teste de suas classes que não encapsule adequadamente a lógica de negócios e produz testes / especificações que geralmente não fazem sentido em termos de descrição da lógica de negócios suportada. Em vez disso, concentro-me na estruturação dos testes, da mesma forma que as regras de negócios, e me esforço para exercitar cada ramo condicional do código com testes com a expectativa explícita de que esses testes são facilmente entendidos pelo testador como geralmente seriam casos de uso e, na verdade, descrevem o regras de negócios que foram implementadas.

Com essa ideia em mente, eu testaria exaustivamente os 6 fatores de classificação que você listou isoladamente, seguidos por 2 ou 3 testes de estilo de integração que garantem que você esteja agregando seus resultados aos valores esperados de classificação geral. Por exemplo, no caso 1, Correspondência exata do nome, eu teria pelo menos dois testes de unidade para testar quando é exato e quando não é e se os dois cenários retornam a pontuação esperada. Se for sensível a maiúsculas e minúsculas, também um caso para testar "Correspondência exata" vs. "correspondência exata" e possivelmente outras variações de entrada, como pontuação, espaços extras, etc. também retornam as pontuações esperadas.

Depois de trabalhar com todos os fatores individuais que contribuem para as pontuações no ranking, suponho que eles funcionem corretamente no nível de integração e me concentro em garantir que seus fatores combinados contribuam corretamente para a pontuação final esperada.

Supondo que os casos # 2 / # 3 e # 4 / # 5 sejam generalizados para os mesmos métodos subjacentes, mas, ao passar campos diferentes, você só precisará escrever um conjunto de testes de unidade para os métodos subjacentes e escrever testes de unidade adicionais simples para testar os requisitos específicos. campos (título, nome, descrição etc.) e pontuação no fatorial designado, portanto, isso reduz ainda mais a redundância do seu esforço geral de teste.

Com essa abordagem, a abordagem descrita acima provavelmente produziria 3 ou 4 testes de unidade no caso 1, talvez 10 especificações sobre alguns / todos os sinônimos / responsáveis ​​- mais 4 especificações sobre a pontuação correta dos casos 2 - 5 e 2 até 3 especificações na classificação final ordenada por data e, em seguida, 3 a 4 testes de nível de integração que medem todos os 6 casos combinados de maneiras prováveis ​​(esqueça os casos de borda obscuros por enquanto, a menos que você veja claramente um problema no seu código que precise ser exercido para garantir essa condição é tratada) ou garantir que não seja violado / quebrado por revisões posteriores. Isso gera cerca de 25 especificações para exercitar 100% do código escrito (mesmo que você não tenha chamado diretamente 100% dos métodos escritos).

Michael Lang
fonte
1

Eu nunca fui fã de 100% de cobertura de teste. Na minha experiência, se algo é simples o suficiente para testar com apenas um ou dois casos de teste, é simples o suficiente para raramente falhar. Quando falha, geralmente é devido a alterações na arquitetura que exigiriam alterações de teste de qualquer maneira.

Dito isto, para requisitos como o seu, eu sempre teste minuciosamente, mesmo em projetos pessoais onde ninguém está me fazendo, porque esses são os casos em que o teste unitário economiza tempo e agravamento. Quanto mais testes de unidade forem necessários para testar algo, mais tempo os testes de unidade economizarão.

Isso porque você só pode segurar tantas coisas na cabeça de uma só vez. Se você está tentando escrever um código que funcione para 63 combinações diferentes, geralmente é difícil corrigir uma combinação sem quebrar outra. Você acaba testando manualmente outras combinações repetidamente. O teste manual é muito mais lento, o que faz com que você não queira executar novamente todas as combinações possíveis toda vez que fizer uma alteração. Com isso, é mais provável que você perca alguma coisa e perca mais tempo buscando caminhos que não funcionam em todos os casos.

Além do tempo economizado comparado ao teste manual, há muito menos tensão mental, o que facilita o foco no problema em questão, sem se preocupar em introduzir regressões acidentalmente. Isso permite que você trabalhe mais rápido e mais tempo sem queimar. Na minha opinião, os benefícios de saúde mental por si só valem o custo do teste de código complexo, mesmo que não tenha poupado tempo.

Karl Bielefeldt
fonte