Como você deve TDD um jogo Yahtzee?

36

Digamos que você esteja escrevendo um estilo TDD de jogo Yahtzee. Você deseja testar a parte do código que determina se um conjunto de cinco rolagens é ou não uma casa cheia. Tanto quanto eu sei, ao fazer TDD, você segue estes princípios:

  • Escreva os testes primeiro
  • Escreva a coisa mais simples possível que funcione
  • Refinar e refatorar

Portanto, um teste inicial pode ser algo como isto:

public void Returns_true_when_roll_is_full_house()
{
    FullHouseTester sut = new FullHouseTester();
    var actual = sut.IsFullHouse(1, 1, 1, 2, 2);

    Assert.IsTrue(actual);
}

Ao seguir a "Escreva a coisa mais simples possível que funcione", você deve agora escrever o IsFullHousemétodo assim:

public bool IsFullHouse(int roll1, int roll2, int roll3, int roll4, int roll5)
{
    if (roll1 == 1 && roll2 == 1 && roll3 == 1 && roll4 == 2 && roll5 == 2)
    {
        return true;
    }

    return false;
}

Isso resulta em um teste verde, mas a implementação está incompleta.

Você deve testar todas as combinações válidas possíveis (valores e posições) para uma casa cheia? Essa é a única maneira de ter certeza absoluta de que seu IsFullHousecódigo está completamente testado e correto, mas também parece bastante insano fazer isso.

Como você testaria unitariamente algo assim?

Atualizar

Erik e Kilian apontam que o uso de literais na implementação inicial para obter um teste verde pode não ser a melhor idéia. Eu gostaria de explicar por que fiz isso e essa explicação não se encaixa em um comentário.

Minha experiência prática com testes de unidade (especialmente usando uma abordagem TDD) é muito limitada. Lembro-me de assistir a uma gravação da TDD Masterclass de Roy Osherove no Tekpub. Em um dos episódios, ele constrói um estilo TDD da String Calculator. A especificação completa da Calculadora de cordas pode ser encontrada aqui: http://osherove.com/tdd-kata-1/

Ele começa com um teste como este:

public void Add_with_empty_string_should_return_zero()
{
    StringCalculator sut = new StringCalculator();
    int result = sut.Add("");

    Assert.AreEqual(0, result);
}

Isso resulta nesta primeira implementação do Addmétodo:

public int Add(string input)
{
    return 0;
}

Então este teste é adicionado:

public void Add_with_one_number_string_should_return_number()
{
    StringCalculator sut = new StringCalculator();
    int result = sut.Add("1");

    Assert.AreEqual(1, result);
}

E o Addmétodo é refatorado:

public int Add(string input)
{
    if (input.Length == 0)
    {
        return 0;
    }

    return 1;
}

Depois de cada passo, Roy diz "Escreva a coisa mais simples que funcionará".

Então, pensei em experimentar essa abordagem ao tentar fazer um jogo Yahtzee no estilo TDD.

Kristof Claes
fonte
8
"Escreva a coisa mais simples possível que funcione" é na verdade uma abreviação; o conselho correto é "Escreva a coisa mais simples possível que não seja completamente irracional e obviamente incorreta que funcione". Então, não, você não deve escreverif (roll1 == 1 && roll2 == 1 && roll3 == 1 && roll4 == 2 && roll5 == 2)
Carson63000
3
Obrigado por resumir a resposta de Erik, seja de forma menos argumentativa ou civilizada.
Kristof Claes
1
"Escreva a coisa mais simples que funciona", como @ Carson63000, é realmente uma simplificação. É realmente perigoso pensar assim; isso leva ao infame desastre do Sudoku TDD (pesquise no google). Quando seguido cegamente, o TDD é realmente tolo: você não pode generalizar um algoritmo não trivial fazendo cegamente "a coisa mais simples que funciona" ... você precisa realmente pensar! Infelizmente, mesmo supostos mestres do XP e TDD, por vezes, segui-lo cegamente ...
Andres F.
1
@AndresF. Observe que seu comentário apareceu mais alto nas pesquisas do Google do que grande parte dos comentários sobre o "desastre do Soduko TDD" depois de menos de três dias. No entanto, como não resolver um Sudoku resumiu: TDD é para qualidade, não para correção. Você precisa resolver o algoritmo antes de iniciar a codificação, especialmente com TDD. (Não que eu não sou um código primeiro programador também.)
Mark Hurd

Respostas:

40

Já existem muitas boas respostas para essa pergunta, e eu comentei e votei em várias delas. Ainda assim, gostaria de acrescentar alguns pensamentos.

Flexibilidade não é para iniciantes

O OP afirma claramente que ele não tem experiência com TDD, e acho que uma boa resposta deve levar isso em conta. Na terminologia do modelo Dreyfus de aquisição de habilidades , ele provavelmente é um novato . Não há nada errado em ser um novato - somos todos novatos quando começamos a aprender algo novo. No entanto, o que o modelo Dreyfus explica é que os novatos são caracterizados por

  • adesão rígida às regras ou planos ensinados
  • nenhum exercício de julgamento discricionário

Isso não é uma descrição de uma deficiência de personalidade, então não há razão para ter vergonha disso - é um estágio pelo qual todos precisamos passar para aprender algo novo.

Isso também se aplica ao TDD.

Embora eu concorde com muitas das outras respostas aqui de que o TDD não precisa ser dogmático e que às vezes pode ser mais benéfico trabalhar de uma maneira alternativa, isso não ajuda ninguém a começar. Como você pode exercer julgamento discricionário quando não tem experiência?

Se um novato aceita o conselho de que às vezes não há problema em fazer o TDD, como ele pode determinar quando não há problema em ignorar o TDD?

Sem experiência ou orientação, a única coisa que um iniciante pode fazer é pular o TDD toda vez que se tornar muito difícil. Essa é a natureza humana, mas não é uma boa maneira de aprender.

Ouça os testes

Ignorar o TDD sempre que se tornar difícil é perder um dos benefícios mais importantes do TDD. Os testes fornecem feedback antecipado sobre a API do SUT. Se o teste for difícil de escrever, é um sinal importante de que o SUT é difícil de usar.

Esta é a razão pela qual uma das mensagens mais importantes do GOOS é: ouça seus testes!

No caso desta pergunta, minha primeira reação ao ver a API proposta para o jogo Yahtzee e a discussão sobre combinatória que pode ser encontrada nesta página, foi que esse é um feedback importante sobre a API.

A API precisa representar rolos de dados como uma sequência ordenada de números inteiros? Para mim, aquele cheiro de obsessão primitiva . Foi por isso que fiquei feliz ao ver a resposta de tallseth sugerindo a introdução de uma Rollaula. Eu acho que é uma excelente sugestão.

No entanto, acho que alguns dos comentários a essa resposta estão errados. O que o TDD sugere é que, uma vez que você tenha a ideia de que uma Rollclasse seria uma boa ideia, suspenda o trabalho no SUT original e comece a trabalhar no TDD da Rollclasse.

Embora eu concorde que o TDD seja mais voltado para o 'caminho feliz' do que para testes abrangentes, ainda ajuda a dividir o sistema em unidades gerenciáveis. Uma Rollaula soa como algo que você pode concluir com TDD com muito mais facilidade.

Então, uma vez que a Rollclasse tenha evoluído o suficiente, você retornaria ao SUT original e o detalharia em termos de Rollentradas.

A sugestão de um auxiliar de teste não implica necessariamente aleatoriedade - é apenas uma maneira de tornar o teste mais legível.

Outra maneira de abordar e modelar a entrada em termos de Rollinstâncias seria a introdução de um Test Data Builder .

Vermelho / Verde / Refatorador é um processo de três etapas

Embora eu concorde com o sentimento geral de que (se você é suficientemente experiente em TDD), não precisa seguir rigorosamente o TDD, acho que é um péssimo conselho no caso de um exercício de Yahtzee. Embora eu não conheça os detalhes das regras do Yahtzee, não vejo aqui um argumento convincente de que você não pode seguir rigorosamente o processo Vermelho / Verde / Refatorar e ainda assim obter um resultado adequado.

O que a maioria das pessoas aqui parece esquecer é o terceiro estágio do processo Vermelho / Verde / Refatorador. Primeiro você escreve o teste. Então você escreve a implementação mais simples que passa em todos os testes. Então você refatora.

É aqui, neste terceiro estado, que você pode levar todas as suas habilidades profissionais. É aqui que você pode refletir sobre o código.

No entanto, acho que é um absurdo afirmar que você deve apenas "Escrever a coisa mais simples possível que não seja completamente irracional e obviamente incorreta que funcione". Se você (pensa que conhece) o suficiente sobre a implementação de antemão, tudo o que estiver abaixo da solução completa ficará obviamente incorreto . No que diz respeito aos conselhos, então, isso é bastante inútil para um iniciante.

O que realmente deve acontecer é que, se você puder fazer todos os testes passarem com uma implementação obviamente incorreta , isso significa que você deve escrever outro teste .

É surpreendente a frequência com que isso o leva a uma implementação totalmente diferente daquela que você tinha em mente primeiro. Às vezes, a alternativa que cresce assim pode ser melhor do que o seu plano original.

Rigor é uma ferramenta de aprendizado

Faz muito sentido seguir processos rigorosos como Vermelho / Verde / Refatorar, desde que se esteja aprendendo. Isso força o aluno a ganhar experiência com TDD não apenas quando é fácil, mas também quando é difícil.

Somente quando você dominou todas as partes difíceis, você está em posição de tomar uma decisão informada sobre quando se desviar do caminho 'verdadeiro'. É quando você começa a formar seu próprio caminho.

Mark Seemann
fonte
'aqui no TDD novato, com todas as dúvidas habituais sobre tentar. Opinião interessante, se você pode fazer todos os testes passarem com uma implementação obviamente incorreta, é o feedback de que você deve escrever outro teste. Parece uma boa maneira de lidar com a percepção de que testar as implementações "braindead" é ​​desnecessário.
shambulator
1
Uau, obrigado. Estou realmente assustado com a tendência das pessoas de dizer aos iniciantes no TDD (ou qualquer outra disciplina) que "não se preocupem com as regras, apenas façam o que achar melhor". Como você pode saber o que é melhor quando não tem conhecimento ou experiência? Também gostaria de mencionar o princípio da prioridade de transformação, ou esse código deve se tornar mais genérico à medida que os testes se tornam mais específicos. os apoiadores mais obstinados do TDD, como o tio bob, não apoiariam a noção de "basta adicionar uma nova declaração if para cada teste".
Sara
41

Como um aviso, este é o TDD como eu o pratico e, como Kilian apropriadamente aponta, eu seria cauteloso com qualquer um que sugerisse que existe uma maneira correta de praticá-lo. Mas talvez isso ajude você ...

Primeiro de tudo, a coisa mais simples que você poderia fazer para passar no teste seria:

public bool IsFullHouse(int roll1, int roll2, int roll3, int roll4, int roll5)
{
    return true;
}

Isso é significativo porque não por causa de alguma prática de TDD, mas porque a codificação em todos esses literais não é realmente uma boa ideia. Uma das coisas mais difíceis de entender com o TDD é que não é uma estratégia de teste abrangente - é uma maneira de se proteger contra regressões e marcar o progresso, mantendo o código simples. É uma estratégia de desenvolvimento e não uma estratégia de teste.

A razão de eu mencionar essa distinção é que ela ajuda a orientar os testes que você deve escrever. A resposta para "que testes devo escrever?" é "quaisquer testes necessários para obter o código da maneira que você deseja". Pense no TDD como uma maneira de ajudá-lo a descobrir algoritmos e raciocinar sobre seu código. Portanto, considerando seu teste e minha implementação "simples verde", que teste vem a seguir? Bem, você estabeleceu algo que é uma casa cheia, então quando não é uma casa cheia?

public void Returns_true_when_roll_is_full_house()
{
    FullHouseTester sut = new FullHouseTester();
    var actual = sut.IsFullHouse(1, 2, 3, 4, 5);

    Assert.IsFalse(actual);
}

Agora você precisa descobrir uma maneira de diferenciar os dois casos de teste que são significativos . Pessoalmente, eu acrescentaria um pouco de informações esclarecedoras para "fazer a coisa mais simples para fazer o teste passar" e dizer "fazer a coisa mais simples para fazer o teste passar que favorece sua implementação". Escrever testes com falha é o seu pretexto para alterar o código; portanto, quando você escreve cada teste, você deve se perguntar "o que meu código não faz e que eu quero que faça e como posso expor essa deficiência?" Também pode ajudá-lo a tornar seu código robusto e a lidar com casos extremos. O que você faz se um chamador insere disparates?

public void Returns_true_when_roll_is_full_house()
{
    FullHouseTester sut = new FullHouseTester();
    var actual = sut.IsFullHouse(-1, -2, -3, -4, -5);

    //I dunno - throw exception, return false, etc, whatever you think it should do....
}

Resumindo, se você estiver testando todas as combinações de valores, certamente estará fazendo algo errado (e provavelmente acabará com uma explosão combinatória de condicionais). Quando se trata de TDD, você deve escrever a quantidade mínima de casos de teste necessários para obter o algoritmo desejado. Quaisquer outros testes que você escrever começarão a ser ecológicos e, portanto, tornar-se-ão documentação, em essência, e não estritamente parte do processo TDD. Você só escreverá outros casos de teste TDD se os requisitos forem alterados ou se um bug for exposto; nesse caso, você documentará a deficiência com um teste e depois passará.

Atualizar:

Comecei isso como um comentário em resposta à sua atualização, mas ela começou a ficar bastante longa ...

Eu diria que o problema não está na existência de literais, ponto final, mas com a coisa 'mais simples' sendo uma condicional em 5 partes. Quando você pensa sobre isso, uma condicional de 5 partes é realmente bastante complicada. Será comum usar literais durante a etapa de vermelho para verde e depois abstraí-los para constantes na etapa de refatoração ou generalizá-las em um teste posterior.

Durante minha própria jornada com o TDD, percebi que há uma distinção importante a ser feita - não é bom confundir "simples" e "obtuso". Ou seja, quando eu comecei, vi as pessoas fazendo TDD e pensei "eles estão fazendo a coisa mais burra possível para fazer os testes passarem" e eu imitei isso por um tempo, até perceber que "simples" era sutilmente diferente do que "obtuso". Às vezes eles se sobrepõem, mas muitas vezes não.

Então, desculpas se eu desse a impressão de que a existência de literais era o problema - não é. Eu diria que a complexidade do condicional com as 5 cláusulas é o problema. Seu primeiro vermelho para verde pode ser apenas "retorno verdadeiro", porque isso é realmente simples (e obtuso, por coincidência). O próximo caso de teste, com o (1, 2, 3, 4, 5), terá que retornar falso, e é aqui que você começa a deixar "obtuso" para trás. Você deve se perguntar "por que (1, 1, 1, 2, 2) é uma casa cheia e (1, 2, 3, 4, 5) não é?" A coisa mais simples que você poderia sugerir seria que um tivesse o último elemento de sequência 5 ou o segundo elemento de sequência 2 e o outro não. Essas são simples, mas também são (desnecessariamente) obtusas. O que você realmente quer dirigir é "quantos do mesmo número eles têm?" Portanto, você pode obter a aprovação do segundo teste, verificando se há ou não uma repetição. No que se repete, você tem uma casa cheia e no outro, não. Agora o teste passa e você escreve outro caso de teste que tem uma repetição, mas não é uma casa completa para refinar ainda mais seu algoritmo.

Você pode ou não fazer isso com literais à medida que avança, e tudo bem se você fizer. Mas a idéia geral é aumentar seu algoritmo 'organicamente' à medida que você adiciona mais casos.

Erik Dietrich
fonte
Atualizei minha pergunta para adicionar mais informações sobre por que comecei com a abordagem literal.
Kristof Claes
9
Esta é uma ótima resposta.
tallseth
1
Muito obrigado pela sua resposta atenciosa e bem explicada. Na verdade, faz muito sentido agora que penso nisso.
Kristof Claes
1
Testes completos não significam testar todas as combinações ... Isso é bobagem. Nesse caso em particular, faça uma casa cheia em particular ou duas e duas casas não cheias. Além disso, qualquer combinação especial que possa causar problemas (ou seja, 5 do mesmo tipo).
21413 Schleis
3
+1 Os princípios por trás dessa resposta são descritos por Transformação Prioridade Premise de Robert C. Martin cleancoder.posterous.com/the-transformation-priority-premise
Mark Seemann
5

Testar cinco valores literais específicos em uma combinação específica não é "mais simples" para o meu cérebro febril. Se a solução para um problema é realmente óbvia (conte se você tem exatamente três e exatamente dois de qualquer valor), siga em frente e codifique essa solução e escreva alguns testes que seriam muito, muito improváveis ​​de satisfazer acidentalmente com a quantidade de código que você escreveu (isto é, literais diferentes e ordens diferentes dos triplos e duplos).

As máximas do TDD são realmente ferramentas, não crenças religiosas. O objetivo deles é fazer com que você escreva códigos corretos e bem fatorados rapidamente. Se uma máxima obviamente estiver no caminho disso, pule para a frente e vá para o próximo passo. Haverá muitos bits não óbvios em seu projeto, onde você poderá aplicá-lo.

Kilian Foth
fonte
5

A resposta de Erik é ótima, mas achei que poderia compartilhar um truque na escrita de testes.

Comece com este teste:

[Test]
public void FullHouseReturnsTrue()
{
    var pairNum = AnyDiceValue();
    var trioNum = AnyDiceValue();

    Assert.That(sut.IsFullHouse(trioNum, pairNum, trioNum, pairNum, trioNum));
}

Esse teste fica ainda melhor se você criar uma Rollclasse em vez de passar 5 parâmetros:

[Test]
public void FullHouseReturnsTrue()
{
    var roll = AnyFullHouse();

    Assert.That(sut.IsFullHouse(roll));
}

Isso fornece essa implementação:

public bool IsFullHouse(Roll toCheck)
{
    return true;
}

Em seguida, escreva este teste:

[Test]
public void StraightReturnsFalse()
{
    var roll = AnyStraight();

    Assert.That(sut.IsFullHouse(roll), Is.False);
}

Quando isso passar, escreva este:

[Test]
public void ThreeOfAKindReturnsFalse()
{
    var roll = AnyStraight();

    Assert.That(sut.IsFullHouse(roll), Is.False);
}

Depois disso, aposto que você não precisa mais escrever (talvez dois pares, ou talvez yahtzee, se você acha que não é uma casa cheia).

Obviamente, implemente seus métodos Any para retornar Rolls aleatórios que atendam às suas críticas.

Existem alguns benefícios nessa abordagem:

  • Você não precisa escrever um teste cujo único objetivo é impedir que você fique preso a valores específicos
  • Os testes comunicam sua intenção muito bem (o código do primeiro teste grita "qualquer casa cheia retorna verdadeira")
  • leva você rapidamente ao ponto de trabalhar na carne do problema
  • às vezes notará casos em que você não pensou
Tallseth
fonte
Se você fizer essa abordagem, precisará melhorar suas mensagens de log nas instruções Assert.That. O desenvolvedor precisa ver qual entrada causou a falha.
Bringer128
Isso não cria um dilema de galinha ou ovo? Ao implementar o AnyFullHouse (usando o TDD também), você não precisaria do IsFullHouse para verificar sua correção? Especificamente, se o AnyFullHouse tiver um bug, esse bug poderá ser replicado no IsFullHouse.
quer
AnyFullHouse () é um método em um caso de teste. Você costuma TDD seus casos de teste? Não. Além disso, é muito mais simples criar um exemplo aleatório de um full house (ou qualquer outro teste) do que testar sua existência. Obviamente, se seu teste tiver um erro, ele poderá ser replicado no código de produção. Isso é verdade para todos os testes.
tallseth
AnyFullHouse é um método "auxiliar" em um caso de teste. Se eles forem gerais, os métodos auxiliares também serão testados!
Mark Hurd
IsFullHouseRealmente deveria retornar truese pairNum == trioNum ?
recursion.ninja
2

Posso pensar em duas maneiras principais que consideraria ao testar isso;

  1. Adicione "mais" alguns casos de teste (~ 5) de conjuntos válidos de casa cheia e a mesma quantidade de falsas esperadas ({1, 1, 2, 3, 3} é boa. Lembre-se de que 5 casos, por exemplo, podem ser reconhecido como "3 do mesmo mais um par" por uma implementação incorreta). Esse método pressupõe que o desenvolvedor não esteja apenas tentando passar nos testes, mas implementando-o corretamente.

  2. Teste todos os conjuntos possíveis de dados (existem apenas 252 diferentes). É claro que isso pressupõe que você tem alguma maneira de saber qual é a resposta esperada (ao testar isso é conhecido como um oracle.) Essa poderia ser uma implementação de referência da mesma função ou de um ser humano. Se você quer ser realmente rigoroso, pode valer a pena codificar manualmente cada resultado esperado.

Por acaso, escrevi uma IA do Yahtzee uma vez, que obviamente tinha que conhecer as regras. Você pode encontrar o código para a parte de avaliação de pontuação aqui . Observe que a implementação é para a versão escandinava (Yatzy) e nossa implementação supõe que os dados são dados em ordem classificada.

ansjob
fonte
A pergunta de um milhão de dólares é: você obteve a IA do Yahtzee usando TDD puro? Minha aposta é que você não pode; você tem de usar o conhecimento de domínio, que, por definição, não é cega :)
Andres F.
Sim, acho que você está certo. Esse é um problema geral do TDD, pois os casos de teste precisam de saídas esperadas, a menos que você queira apenas testar falhas inesperadas e exceções não tratadas.
precisa saber é
0

Este exemplo realmente erra o alvo. Estamos falando de uma única função direta aqui, não de um design de software. Isso é um pouco complicado? sim, então você divide. E você absolutamente não testa todas as entradas possíveis de 1, 1, 1, 1, 1 a 6, 6, 6, 6, 6, 6. A função em questão não requer ordem, apenas uma combinação, ou seja, AAABB.

Você não precisa de 200 testes lógicos separados. Você pode usar um conjunto, por exemplo. Quase qualquer linguagem de programação possui uma incorporada:

Set set;
set.add(a);
set.add(b);
set.add(c);
set.add(d);
set.add(e);

if(set.size() == 2) { // means we *must* be of the form AAAAB or AAABB.
    if(a==b==c==d) // eliminate AAAAB
        return false;
    else
        return true;
}
return false;

E se você receber uma entrada que não seja um rolo Yahtzee válido, jogue como se não houvesse amanhã.

Jay Mueller
fonte