Escrevendo o código mínimo para passar em um teste de unidade - sem trapaça!

36

Ao fazer TDD e escrever um teste de unidade, como resistir ao desejo de "trapacear" ao escrever a primeira iteração do código de "implementação" que você está testando?

Por exemplo:
Vamos precisar calcular o fatorial de um número. Começo com um teste de unidade (usando o MSTest), algo como:

[TestClass]
public class CalculateFactorialTests
{
    [TestMethod]
    public void CalculateFactorial_5_input_returns_120()
    {
        // Arrange
        var myMath = new MyMath();
        // Act
        long output = myMath.CalculateFactorial(5);
        // Assert
        Assert.AreEqual(120, output);
    }
}

Eu executo esse código e ele falha, pois o CalculateFactorialmétodo nem existe. Portanto, agora escrevo a primeira iteração do código para implementar o método em teste, escrevendo o código mínimo necessário para passar no teste.

O problema é que sou continuamente tentado a escrever o seguinte:

public class MyMath
{
    public long CalculateFactorial(long input)
    {
        return 120;
    }
}

Tecnicamente, isso é correto, na medida em que realmente é o código mínimo necessário para fazer esse teste específico passar (verde), embora seja claramente uma "fraude", pois nem sequer tenta executar a função de calcular um fatorial. Obviamente, agora a parte da refatoração se torna um exercício para "escrever a funcionalidade correta" em vez de uma refatoração verdadeira da implementação. Obviamente, a adição de testes adicionais com parâmetros diferentes falhará e forçará uma refatoração, mas você deve começar com esse teste.

Então, minha pergunta é: como você consegue esse equilíbrio entre "escrever o código mínimo para passar no teste" enquanto ainda o mantém funcional e no espírito do que você está realmente tentando alcançar?

CraigTP
fonte
4
É uma coisa humana: você precisa resistir ao desejo de trapacear. Não há mais nada. Você pode adicionar mais testes e escrever mais código do que o código a ser testado, mas se você não tiver esse luxo, precisará resistir. Existem MUITOS lugares na codificação em que temos que resistir ao desejo de invadir ou trapacear, porque sabemos que, embora possa funcionar hoje, não funcionará mais tarde.
Dan Rosenstark
7
Certamente, no TDD, fazer o contrário é trapaça - ou seja, o retorno 120 é o caminho correto. Acho muito difícil fazer isso, e não correr à frente e começar a escrever o cálculo fatorial.
Paul Butcher
2
Eu consideraria isso uma trapaça, apenas porque ele pode passar no teste, mas não adiciona nenhuma funcionalidade verdadeira ou aproxima você de uma solução final para o problema em questão.
GrumpyMonkey 25/11
3
Se acontecer que o código de código do cliente só passa em 5, o retorno de 120 não é apenas um truque, mas na verdade é uma solução legítima.
Kramii Restabelecer Monica
Concordo com o @PaulButcher - na verdade, muitos exemplos de testes de unidade em textos e artigos adotariam essa abordagem.
HorusKol

Respostas:

45

É perfeitamente legítimo. Vermelho, Verde, Refatorador.

O primeiro teste passa.

Adicione o segundo teste, com uma nova entrada.

Agora, quando estiver verde rapidamente, você poderá adicionar um if-else, que funciona bem. Passa, mas você ainda não terminou.

A terceira parte do Red, Green, Refactor é a mais importante. Refatorar para remover a duplicação . Você terá duplicação no seu código agora. Duas instruções retornando números inteiros. E a única maneira de remover essa duplicação é codificar a função corretamente.

Não estou dizendo que não escreva corretamente da primeira vez. Só estou dizendo que não é trapaça, se não o fizer.

CaffGeek
fonte
12
Isso apenas levanta a questão: por que não escrever a função corretamente em primeiro lugar?
Robert Harvey
8
@ Robert, os números fatoriais são trivialmente simples. A vantagem real do TDD é quando você escreve bibliotecas não triviais e, ao escrever o teste, primeiro é necessário projetar a API antes da implementação, o que - na minha experiência - leva a um melhor código.
1
@ Robert, é você quem está preocupado em resolver o problema em vez de passar no teste. Estou lhe dizendo que, para problemas não triviais, simplesmente funciona melhor adiar o design rígido até que você tenha testes.
1
@ Thorbjørn Ravn Andersen, não, não estou dizendo que você só pode ter um retorno. Existem razões válidas para várias (ou seja, declarações de guarda). O problema é que ambas as declarações de retorno eram "iguais". Eles fizeram a mesma coisa. Acontece que eles têm valores diferentes. O TDD não tem a ver com rigidez e aderência a um tamanho específico da relação teste / código. Trata-se de criar um nível de conforto em sua base de código. Se você pode escrever um teste com falha, uma função que funcionará para testes futuros dessa função, ótimo. Faça isso e, em seguida, escreva seus testes de caso de borda, garantindo que sua função ainda funcione.
CaffGeek
3
o ponto de não escrever a implementação completa (embora simples) de uma só vez é que você não tem garantia de que seus testes podem até falhar. o ponto de ver um teste falhar antes de fazê-lo passar é que você tem uma prova real de que sua alteração no código é o que satisfez a afirmação que você fez nele. essa é a única razão pela qual o TDD é tão bom para construir um conjunto de testes de regressão e limpa completamente o chão com a abordagem "teste após" nesse sentido.
Sara
25

Claramente, é necessária uma compreensão do objetivo final e a obtenção de um algoritmo que atenda esse objetivo.

TDD não é uma bala mágica para design; você ainda precisa saber como resolver problemas usando o código e ainda precisa fazer isso em um nível superior a algumas linhas de código para fazer um teste ser aprovado.

Eu gosto da ideia do TDD, porque incentiva um bom design; isso faz você pensar em como escrever seu código para que ele possa ser testado e, geralmente, essa filosofia empurrará o código para um design melhor em geral. Mas você ainda precisa saber como arquitetar uma solução.

Não sou a favor de filosofias reducionistas de TDD que afirmam que você pode aumentar um aplicativo simplesmente escrevendo a menor quantidade de código para passar no teste. Sem pensar em arquitetura, isso não funcionará, e seu exemplo prova isso.

Tio Bob Martin diz o seguinte:

Se você não está desenvolvendo o desenvolvimento orientado a testes, é muito difícil se considerar um profissional. Jim Coplin me chamou no tapete para este. Ele não gostou que eu disse isso. De fato, sua posição agora é que o Desenvolvimento Orientado a Testes está destruindo arquiteturas porque as pessoas estão escrevendo testes para abandonar qualquer outro tipo de pensamento e separando suas arquiteturas na corrida louca para fazer os testes passarem e ele tem um ponto interessante: essa é uma maneira interessante de abusar do ritual e perder a intenção por trás da disciplina.

se você não está pensando na arquitetura, se o que está fazendo é ignorar a arquitetura e reunir testes e fazê-los passar, você está destruindo o que permitirá que o edifício permaneça ativo, porque é a concentração no estrutura do sistema e decisões sólidas de projeto que ajudam o sistema a manter sua integridade estrutural.

Você não pode simplesmente juntar um monte de testes juntos e fazê-los passar década após década após década e assumir que seu sistema sobreviverá. Não queremos evoluir para o inferno. Portanto, um bom desenvolvedor orientado a testes está sempre consciente de tomar decisões arquitetônicas, sempre pensando no cenário geral.

Robert Harvey
fonte
Não é realmente uma resposta para a pergunta, mas 1+
Ninguém
2
@rmx: Hum, a pergunta é: como você consegue esse equilíbrio entre "escrever o código mínimo para passar no teste" enquanto ainda o mantém funcional e no espírito do que você realmente está tentando alcançar? Estamos lendo a mesma pergunta?
Robert Harvey
A solução ideal é um algoritmo e não tem nada a ver com arquitetura. Fazer TDD não fará você inventar algoritmos. Em algum momento, você precisa executar etapas em termos de um algoritmo / solução.
Joppe
Eu concordo com @rmx. Isso realmente não responde à minha pergunta específica, por si só, mas gera uma reflexão sobre como o TDD em geral se encaixa no quadro geral do processo geral de desenvolvimento de software. Então, por esse motivo, +1.
CraigTP
Eu acho que você poderia substituir "algoritmos" - e outros termos - por "arquitetura" e o argumento ainda é válido; é tudo sobre ser incapaz de ver a madeira para as árvores. A menos que você escreva um teste separado para cada entrada inteira, o TDD não será capaz de distinguir entre uma implementação fatorial adequada e alguma codificação perversa que funciona para todos os casos testados, mas não para outros. O problema com o TDD é a facilidade com que "todos os testes são aprovados" e "o código é bom" são combinados. Em algum momento, uma pesada medida de bom senso precisa ser aplicada.
Julia Hayward
16

Uma pergunta muito boa ... e eu tenho que discordar de quase todo mundo, exceto o @Robert.

Escrita

return 120;

para uma função fatorial fazer uma prova passar é uma perda de tempo . Não é "trapaça", nem segue literalmente o refator vermelho-verde. Está errado .

Aqui está o porquê:

  • Calcular fatorial é o recurso, não "retornar uma constante". "return 120" não é um cálculo.
  • os argumentos de 'refatoração' são equivocados; se você tem dois casos de teste para 5 e 6, este código ainda é errado, porque você não está calculando um fatorial em tudo :

    if (input == 5) { return 120; } //input=5 case
    else { return 720; }   //input=6 case
    
  • se seguirmos literalmente o argumento 'refatorar' , quando tivermos 5 casos de teste, chamaremos o YAGNI e implementaremos a função usando uma tabela de pesquisa:

    if (factorialDictionary.Contains(input)) {
        return factorialDictionary[input]; 
    }
    throw new Exception("Input failure");
    

Nenhum destes são realmente calcular nada, você é . E essa não é a tarefa!

Steven A. Lowe
fonte
1
@rmx: não, não perdeu; "refatorar para remover a duplicação" pode ser satisfeito com uma tabela de pesquisa. BTW, o princípio de que testes de unidade codificam requisitos não é específico do BDD, é um princípio geral do Agile / XP. Se o requisito for "Responda à pergunta 'qual é o fatorial de 5'", então 'retorne 120;' seria ;-) legítimo
Steven A. Lowe
2
@Chad tudo o que é trabalho desnecessário - apenas escrever a função pela primeira vez ;-)
Steven A. Lowe
2
@ Steven A.Lowe, por essa lógica, por que escrever algum teste ?! "Basta escrever o aplicativo pela primeira vez!" O ponto de TDD, é pequenas, seguras, mudanças incrementais.
CaffGeek #
1
@ Chade: Strawman.
Steven A. Lowe
2
o ponto de não escrever a implementação completa (embora simples) de uma só vez é que você não tem garantia de que seus testes podem até falhar. o ponto de ver um teste falhar antes de fazê-lo passar é que você tem uma prova real de que sua alteração no código é o que satisfez a afirmação que você fez nele. essa é a única razão pela qual o TDD é tão bom para construir um conjunto de testes de regressão e limpa completamente o chão com a abordagem "teste após" nesse sentido. você nunca acidentalmente escreve um teste que não pode falhar. Além disso, dê uma olhada no kata do fator principal do tio bobs.
sara
10

Quando você escreve apenas um teste de unidade, a implementação de uma linha ( return 120;) é legítima. Escrever um loop calculando o valor de 120 - isso seria trapaça!

Tais testes iniciais simples são uma boa maneira de capturar casos extremos e evitar erros pontuais. Cinco realmente não é o valor de entrada com o qual eu começaria.

Uma regra prática que pode ser útil aqui é: zero, um, muitos, lotes . Zero e um são casos importantes para o fatorial. Eles podem ser implementados com one-liners. O "muitos" casos de teste (por exemplo, 5!) Forçariam você a escrever um loop. O caso de teste "lotes" (1000 !?) pode forçar você a implementar um algoritmo alternativo para lidar com números muito grandes.

azheglov
fonte
2
O caso "-1" seria interessante. Como não está bem definido, tanto o cara que escreve o teste quanto o código precisam concordar primeiro com o que deve acontecer.
gnasher729
2
+1 por realmente indicar que esse factorial(5)é um primeiro teste ruim. começamos com os casos mais simples possíveis e, em cada iteração, tornamos os testes um pouco mais específicos, exigindo que o código se torne um pouco mais genérico. é isso que o tio bob chama de premissa de prioridade de transformação ( blog.8thlight.com/uncle-bob/2013/05/27/… )
sara
5

Contanto que você tenha apenas um único teste, o código mínimo necessário para passar no teste é realmente verdadeiro return 120;, e você pode mantê-lo facilmente enquanto não tiver mais testes.

Isso permite que você adie o design adicional até escrever os testes que exercem OUTROS valores de retorno desse método.

Lembre-se de que o teste é a versão executável da sua especificação e, se tudo o que essa especificação diz é que f (6) = 120, isso se encaixa perfeitamente.


fonte
A sério? Por essa lógica, você terá que reescrever o código toda vez que alguém criar uma nova entrada.
Robert Harvey
6
@ Robert, em ALGUM MOMENTO, adicionar um novo caso não resultará no código mais simples possível, quando você escrever uma nova implementação. Como você já possui os testes, sabe exatamente quando sua nova implementação faz o mesmo que a antiga.
1
@ Thorbjørn Ravn Andersen, exatamente, a parte mais importante do Refator Vermelho-Verde, é a refatoração.
CaffGeek
+1: Essa também é a ideia geral do meu conhecimento, mas é preciso dizer algo sobre o cumprimento do contrato implícito (ou seja, o nome do método fatorial ). Se você apenas especificar (ou seja, teste) f (6) = 120, precisará apenas 'retornar 120'. Depois de começar a adicionar testes para garantir que f (x) == x * x-1 ... * xx-1: limite superior> = x> = 0, você chegará a uma função que satisfaz a equação fatorial.
precisa
1
@SnOrfus, o local para "contratos implícitos" é nos casos de teste. Se você contratar é para fatoriais, TESTE se os fatoriais conhecidos forem e se os não-fatoriais conhecidos não forem. Muitos deles. Não demora muito para converter a lista dos dez primeiros fatoriais em um loop for testando todos os números até o décimo fatorial.
4

Se você conseguir "trapacear" dessa maneira, isso sugere que seus testes de unidade são defeituosos.

Em vez de testar o método fatorial com um único valor, teste-o como um intervalo de valores. O teste orientado a dados pode ajudar aqui.

Veja seus testes de unidade como uma manifestação dos requisitos - eles devem definir coletivamente o comportamento do método que eles testam. (Isso é conhecido como desenvolvimento orientado ao comportamento - é o futuro ;-))

Então, pergunte a si mesmo - se alguém mudasse a implementação para algo incorreto, seus testes ainda passariam ou eles diriam "espere um minuto!"?

Tendo isso em mente, se o seu único teste foi o da sua pergunta, tecnicamente, a implementação correspondente está correta. O problema é então visto como requisitos mal definidos.

Ninguém
fonte
Como nanda apontou, você sempre pode adicionar uma série interminável de caseinstruções a switche não pode escrever um teste para todas as entradas e saídas possíveis para o exemplo do OP.
Robert Harvey
Tecnicamente, você pode testar valores de Int64.MinValuepara Int64.MaxValue. Levaria muito tempo para ser executado, mas definiria explicitamente o requisito, sem espaço para erros. Com a tecnologia atual, isso é inviável (suspeito que possa se tornar mais comum no futuro) e concordo que você poderia trapacear, mas acho que a questão dos OPs não era prática (ninguém realmente trapacearia de tal maneira na prática), mas teórico.
Ninguém
@rmx: Se você pudesse fazer isso, os testes seriam o algoritmo, e você não precisaria mais escrever o algoritmo.
Robert Harvey
É verdade. Minha tese universitária, na verdade, envolve a geração automática da implementação usando os testes unitários como um guia com um algoritmo genético como auxílio ao TDD - e isso só é possível com testes sólidos. A diferença é que vincular seus requisitos ao seu código normalmente é muito mais difícil de ler e compreender do que um único método que incorpora os testes de unidade. Então vem a pergunta: se sua implementação é uma manifestação de seus testes de unidade e seus testes de unidade são uma manifestação de seus requisitos, por que não pular os testes completamente? Eu não tenho resposta.
Ninguém
Além disso, como seres humanos, não somos tão propensos a cometer erros nos testes de unidade quanto no código de implementação? Então, por que o teste de unidade?
Ninguém
3

Basta escrever mais testes. Eventualmente, seria mais curto escrever

public long CalculateFactorial(long input)
{
    return input <= 1 ? 1 : CalculateFactorial(input-1)*input;
}

do que

public long CalculateFactorial(long input)
{
    switch (input) {
       case 0: return 1;
       case 1: return 1;
       case 2: return 2;
       case 3: return 6;
       case 4: return 24;
       case 5: return 120;
    }
}

:-)

P Shved
fonte
3
Wh não apenas escreve o algoritmo corretamente em primeiro lugar?
Robert Harvey
3
@ Robert, é o algoritmo correto para calcular o fatorial de um número de 0 a 5. Além disso, o que significa "corretamente"? Este é um exemplo muito simples, mas quando se torna mais complexo, ocorrem muitas gradações do que "correto" significa. Um programa que requer acesso root é "correto" o suficiente? O XML está "correto", em vez de usar o CSV? Você não pode responder isso. Qualquer algoritmo está correto desde que satisfaça alguns requisitos de negócios, que são formulados como testes no TDD.
usar o seguinte
3
Deve-se notar que, como o tipo de saída é longo, há apenas um pequeno número de valores de entrada (aproximadamente 20) que a função pode manipular corretamente; portanto, uma declaração de chave grande não é necessariamente a pior implementação - se a velocidade for maior importante que o tamanho do código, a instrução switch pode ser o caminho a seguir, dependendo de suas prioridades.
user281377
3

Escrever testes de "fraude" é bom, para valores suficientemente pequenos de "OK". Mas lembre-se: o teste de unidade só é concluído quando todos os testes passam e nenhum teste novo pode ser gravado que irá falhar . Se você realmente deseja ter um método CalculateFactorial que contém várias instruções if (ou melhor ainda, uma declaração switch / case grande :-), você pode fazer isso e, desde que esteja lidando com um número de precisão fixa, o código necessário implementar isso é finito (embora provavelmente seja grande e feio, e talvez limitado por limitações do compilador ou do sistema no tamanho máximo do código de um procedimento). Neste ponto, se você realmenteinsista para que todo o desenvolvimento seja conduzido por um teste de unidade, você pode escrever um teste que exija o código para calcular o resultado em um período de tempo menor que o que pode ser realizado seguindo todas as ramificações da instrução if .

Basicamente, o TDD pode ajudá-lo a escrever código que implementa os requisitos corretamente , mas não pode forçá-lo a escrever um bom código. Isso é contigo.

Compartilhe e curta.

Bob Jarvis - Restabelecer Monica
fonte
+1 para "o teste de unidade está completo apenas quando todos os testes forem aprovados e nenhum teste novo puder ser escrito com falha" Muitas pessoas estão dizendo que é legítimo retornar a constante, mas não siga com "a curto prazo" ou " se os requisitos gerais precisarem apenas desses casos específicos "
Timina
1

Eu concordo 100% com a sugestão de Robert Harveys aqui, não se trata apenas de fazer os testes passarem, é preciso manter o objetivo geral em mente também.

Como uma solução para o seu ponto de vista de "só é verificado para funcionar com um determinado conjunto de entradas", eu proporia o uso de testes orientados a dados, como a teoria da xunidade. O poder por trás desse conceito é que ele permite criar facilmente Especificações de entradas e saídas.

Para os fatoriais, um teste seria assim:

    [Theory]
    [InlineData(0, 1)]
    [InlineData( 1, 1 )]
    [InlineData( 2, 2 )]
    [InlineData( 3, 6 )]
    [InlineData( 4, 24 )]
    public void Test_Factorial(int input, int expected)
    {
        int result = Factorial( input );
        Assert.Equal( result, expected);
    }

Você pode até implementar um fornecimento de dados de teste (que retorna IEnumerable<Tuple<xxx>> ) e codificar uma invariante matemática, como dividir repetidamente por n resultará em n-1).

Acho que essa tp é uma maneira muito poderosa de testar.

Johannes Rudolph
fonte
1

Se você ainda conseguir trapacear, os testes não serão suficientes. Escreva mais testes! Para o seu exemplo, tentarei adicionar testes com as entradas 1, -1, -1000, 0, 10, 200.

No entanto, se você realmente se comprometer a trapacear, pode escrever um interminável "se-então". Nesse caso, nada poderia ajudar, exceto a revisão de código. Você logo seria pego no teste de aceitação ( escrito por outra pessoa! )

O problema dos testes de unidade é que algumas vezes os programadores os consideram um trabalho desnecessário. A maneira correta de vê-los é como uma ferramenta para você corrigir o resultado do seu trabalho. Portanto, se você criar um if-then, saberá inconscientemente que existem outros casos a serem considerados. Isso significa que você deve escrever outros testes. E assim sucessivamente até você perceber que a trapaça não está funcionando e é melhor codificar da maneira correta. Se você ainda sente que não terminou, não terminou.

Nanda
fonte
1
Parece que você está dizendo que apenas escrever código suficiente para a aprovação no teste (como defende o TDD) não é suficiente. Você também deve ter em mente os princípios sólidos de design de software. Eu concordo com você BTW.
Robert Harvey
0

Eu sugeriria que sua escolha de teste não é a melhor.

Eu começaria com:

fatorial (1) como o primeiro teste,

fatorial (0) como o segundo

fatorial (-ve) como o terceiro

e depois continue com casos não triviais

e termine com um estojo de transbordamento.

Chris Cudmore
fonte
O que é -ve??
Robert Harvey
um valor negativo.
precisa