¿Cómo deberías TDD un juego Yahtzee?

36

Digamos que estás escribiendo un estilo TDD de juego Yahtzee. Desea probar la parte del código que determina si un conjunto de cinco tiradas de dados es o no una casa completa. Hasta donde sé, cuando haces TDD, sigues estos principios:

  • Escribe las pruebas primero
  • Escribe lo más simple posible que funcione
  • Refinar y refactorizar

Entonces, una prueba inicial podría verse así:

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);
}

Al seguir "Escribe lo más simple posible que funcione", ahora debes escribir el IsFullHousemétodo de esta manera:

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;
}

Esto da como resultado una prueba verde pero la implementación está incompleta.

¿Debería realizar una prueba unitaria de todas las combinaciones válidas posibles (tanto de valores como de posiciones) para una casa completa? Esa parece ser la única forma de estar absolutamente seguro de que su IsFullHousecódigo está completamente probado y correcto, pero también parece bastante loco hacerlo.

¿Cómo probarías la unidad algo como esto?

Actualizar

Erik y Kilian señalan que el uso de literales en la implementación inicial para obtener una prueba verde podría no ser la mejor idea. Me gustaría explicar por qué hice eso y esa explicación no cabe en un comentario.

Mi experiencia práctica con las pruebas unitarias (especialmente usando un enfoque TDD) es muy limitada. Recuerdo haber visto una grabación de TDD Masterclass de Roy Osherove en Tekpub. En uno de los episodios construye un estilo TDD de Calculadora de Cuerdas. La especificación completa de la calculadora de cadenas se puede encontrar aquí: http://osherove.com/tdd-kata-1/

Comienza con una prueba como esta:

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

    Assert.AreEqual(0, result);
}

Esto da como resultado esta primera implementación del Addmétodo:

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

Luego se agrega esta prueba:

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

    Assert.AreEqual(1, result);
}

Y el Addmétodo se refactoriza:

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

    return 1;
}

Después de cada paso, Roy dice "Escribe lo más simple que funcionará".

Así que pensé en probar este enfoque al intentar hacer un juego Yahtzee estilo TDD.

Kristof Claes
fuente
8
"Escribir lo más simple posible que funcione" es en realidad una abreviatura; el consejo correcto es "Escribe lo más simple posible que no sea completamente mental y obviamente incorrecto que funcione". Entonces, no, no deberías escribirif (roll1 == 1 && roll2 == 1 && roll3 == 1 && roll4 == 2 && roll5 == 2)
Carson63000
3
Gracias por resumir la respuesta de Erik, ya sea de una manera menos argumentativa o civilizada.
Kristof Claes
1
"Escribir lo más simple que funciona", como @ Carson63000, es en realidad una simplificación. En realidad es peligroso pensar así; conduce a la infame debacle del TDD de Sudoku (google it). Cuando se sigue ciegamente, TDD está realmente loco: no se puede generalizar un algoritmo no trivial haciendo ciegamente "lo más simple que funciona" ... ¡realmente tiene que pensar! Desafortunadamente, incluso los supuestos maestros de XP y TDD a veces lo siguen a ciegas ...
Andres F.
1
@AndresF. Tenga en cuenta que su comentario ha aparecido más alto en las búsquedas de Google que gran parte del comentario sobre la "debacle de TDD Soduko" después de menos de tres días. Sin embargo, cómo no resolver un Sudoku lo resumió: TDD es por calidad, no por corrección. Debe resolver el algoritmo antes de comenzar la codificación, especialmente con TDD. (No es que yo tampoco sea el primer programador de código).
Mark Hurd

Respuestas:

40

Ya hay muchas buenas respuestas a esta pregunta, y he comentado y votado varias de ellas. Aún así, me gustaría agregar algunos pensamientos.

La flexibilidad no es para principiantes

El OP dice claramente que no tiene experiencia con TDD, y creo que una buena respuesta debe tenerlo en cuenta. En la terminología del modelo Dreyfus de adquisición de habilidades , es probable que sea un novato . No hay nada de malo en ser un novato: todos somos novatos cuando comenzamos a aprender algo nuevo. Sin embargo, lo que explica el modelo Dreyfus es que los principiantes se caracterizan por

  • adherencia rígida a las reglas o planes enseñados
  • no ejercicio de juicio discrecional

Esa no es una descripción de una deficiencia de la personalidad, por lo que no hay razón para avergonzarse de eso: es una etapa que todos debemos atravesar para aprender algo nuevo.

Esto también es cierto para TDD.

Si bien estoy de acuerdo con muchas de las otras respuestas aquí, que TDD no tiene que ser dogmático, y que a veces puede ser más beneficioso trabajar de una manera alternativa, eso no ayuda a nadie que recién comienza. ¿Cómo puedes ejercer un juicio discrecional cuando no tienes experiencia?

Si un novato acepta el consejo de que a veces está bien no hacer TDD, ¿cómo puede determinar cuándo está bien dejar de hacer TDD?

Sin experiencia ni orientación, lo único que puede hacer un novato es saltarse el TDD cada vez que se vuelve demasiado difícil. Esa es la naturaleza humana, pero no es una buena forma de aprender.

Escucha las pruebas

Saltar de TDD cada vez que se vuelve difícil es perder uno de los beneficios más importantes de TDD. Las pruebas proporcionan retroalimentación temprana sobre la API del SUT. Si la prueba es difícil de escribir, es una señal importante de que el SUT es difícil de usar.

Esta es la razón por la cual uno de los mensajes más importantes de GOOS es: ¡ escuche sus pruebas!

En el caso de esta pregunta, mi primera reacción al ver la API propuesta del juego Yahtzee, y la discusión sobre combinatoria que se puede encontrar en esta página, fue que esta es una retroalimentación importante sobre la API.

¿La API tiene que representar tiradas de dados como una secuencia ordenada de enteros? Para mí, ese olor a obsesión primitiva . Es por eso que estaba feliz de ver la respuesta de tallseth sugiriendo la introducción de una Rollclase. Creo que es una excelente sugerencia.

Sin embargo, creo que algunos de los comentarios a esa respuesta se equivocan. Lo que TDD sugiere es que una vez que se tiene la idea de que una Rollclase sería una buena idea, suspendes el trabajo en el SUT original y comienzas a trabajar en TDD en la Rollclase.

Si bien estoy de acuerdo en que TDD está más dirigido a la "ruta feliz" que a una prueba exhaustiva, todavía ayuda a dividir el sistema en unidades manejables. Una Rollclase suena como algo que podría TDD para completar mucho más fácilmente.

Luego, una vez que la Rollclase haya evolucionado lo suficiente, ¿volverías al SUT original y lo desarrollarías en términos de Rollentradas?

La sugerencia de un Test Helper no necesariamente implica aleatoriedad, es solo una forma de hacer que la prueba sea más legible.

Otra forma de abordar y modelar la entrada en términos de Rollinstancias sería introducir un generador de datos de prueba .

Rojo / Verde / Refactor es un proceso de tres etapas.

Si bien estoy de acuerdo con el sentimiento general de que (si tiene suficiente experiencia en TDD), no necesita apegarse a TDD rigurosamente, creo que es un consejo bastante pobre en el caso de un ejercicio de Yahtzee. Aunque no conozco los detalles de las reglas de Yahtzee, no veo ningún argumento convincente aquí de que no se pueda seguir rigurosamente con el proceso Rojo / Verde / Refactor y aún así llegar a un resultado adecuado.

Lo que la mayoría de la gente parece olvidar aquí es la tercera etapa del proceso Rojo / Verde / Refactorizador. Primero escribes el examen. Luego, escribe la implementación más simple que pasa todas las pruebas. Entonces refactorizas.

Es aquí, en este tercer estado, donde puedes poner en práctica todas tus habilidades profesionales. Aquí es donde se le permite reflexionar sobre el código.

Sin embargo, creo que es un error decir que solo debe "escribir lo más simple posible que no sea completamente mental y obviamente incorrecto que funcione". Si (cree) que sabe lo suficiente sobre la implementación de antemano, entonces todo lo que no sea la solución completa será obviamente incorrecto . En lo que respecta al consejo, entonces, esto es bastante inútil para un novato.

Lo que realmente debería suceder es que si puede hacer que todas las pruebas pasen con una implementación obviamente incorrecta , eso es retroalimentación de que debe escribir otra prueba .

Es sorprendente la frecuencia con la que eso lo lleva a una implementación completamente diferente a la que tenía en mente primero. A veces, la alternativa que crece así puede ser mejor que su plan original.

El rigor es una herramienta de aprendizaje.

Tiene mucho sentido seguir procesos rigurosos como Rojo / Verde / Refactorio mientras uno esté aprendiendo. Obliga al alumno a adquirir experiencia con TDD no solo cuando es fácil, sino también cuando es difícil.

Solo cuando haya dominado todas las partes difíciles estará en condiciones de tomar una decisión informada sobre cuándo desviarse del camino "verdadero". Ahí es cuando comienzas a formar tu propio camino.

Mark Seemann
fuente
Otro novato de TDD aquí, con todas las dudas habituales sobre intentarlo. Interesante si puedes hacer que todas las pruebas pasen con una implementación obviamente incorrecta, eso es un comentario de que debes escribir otra prueba. Parece una buena manera de abordar la percepción de que probar las implementaciones de "braindead" es un trabajo innecesario.
Shambulator
1
Wow gracias. Estoy realmente asustado por la tendencia de las personas a decirles a los principiantes en TDD (o cualquier disciplina) que "no se preocupen por las reglas, solo hagan lo que se sienta mejor". ¿Cómo puede saber qué se siente mejor cuando no tiene conocimiento o experiencia? También me gustaría mencionar el principio de prioridad de transformación, o ese código debería volverse más genérico a medida que las pruebas se vuelvan más específicas. los partidarios de TDD más acérrimos como el tío bob no respaldarían la noción de "solo agregar una nueva declaración if para cada prueba".
sara
41

Como descargo de responsabilidad, esto es TDD mientras lo practico y, como Kilian señala acertadamente, desconfiaría de cualquiera que sugiriera que hay una forma correcta de practicarlo. Pero tal vez te ayude ...

En primer lugar, lo más simple que podría hacer para que su examen sea aprobado sería este:

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

Esto es significativo porque no se debe a alguna práctica de TDD, sino porque la grabación en todos esos literales no es realmente una buena idea. Una de las cosas más difíciles de entender con TDD es que no es una estrategia de prueba integral, es una forma de protegerse contra las regresiones y marcar el progreso, manteniendo el código simple. Es una estrategia de desarrollo y no una estrategia de prueba.

La razón por la que menciono esta distinción es que ayuda a guiar las pruebas que debe escribir. La respuesta a "¿qué pruebas debo escribir?" es "cualquier prueba que necesite para obtener el código de la manera que lo desee". Piense en TDD como una forma de ayudarlo a descubrir algoritmos y razonar sobre su código. Entonces, dada su prueba y mi implementación "verde simple", ¿qué prueba viene después? Bueno, has establecido algo que es una casa llena, entonces, ¿cuándo no es una casa llena?

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);
}

Ahora tiene que encontrar alguna manera de diferenciar entre los dos casos de prueba que sea significativo . Personalmente, agregaría un poco de información aclaratoria para "hacer lo más simple para aprobar la prueba" y decir "hacer lo más simple para aprobar la prueba que fomenta su implementación". Escribir pruebas fallidas es su pretexto para alterar el código, así que cuando vaya a escribir cada prueba, debería preguntarse "¿qué no hace mi código que quiero que haga y cómo puedo exponer esa deficiencia?" También puede ayudarlo a fortalecer su código y a manejar casos extremos. ¿Qué haces si una persona que llama no tiene sentido?

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....
}

En resumen, si está probando cada combinación de valores, es casi seguro que lo está haciendo mal (y es probable que termine con una explosión combinatoria de condicionales). Cuando se trata de TDD, debe escribir la cantidad mínima de casos de prueba necesarios para obtener el algoritmo que desea. Cualquier otra prueba que escriba comenzará en verde y, por lo tanto, se convertirá en documentación, en esencia, y no estrictamente parte del proceso de TDD. Solo escribirá más casos de prueba TDD si los requisitos cambian o se expone un error, en cuyo caso documentará la deficiencia con una prueba y luego la aprobará.

Actualizar:

Comencé esto como un comentario en respuesta a su actualización, pero comenzó a ser bastante largo ...

Yo diría que el problema no es con la existencia de literales, punto, sino con lo 'más simple' que es un condicional de 5 partes. Cuando lo piensas, un condicional de 5 partes es bastante complicado. Será común usar literales durante el paso de rojo a verde y luego abstraerlos a constantes en el paso de refactorización o generalizarlos en una prueba posterior.

Durante mi propio viaje con TDD, me di cuenta de que hay que hacer una distinción importante: no es bueno confundir "simple" y "obtuso". Es decir, cuando comencé, vi a las personas hacer TDD y pensé "simplemente están haciendo lo más tonto posible para que las pruebas pasen" y lo imité por un tiempo, hasta que me di cuenta de que "simple" era sutilmente diferente que "obtuso". A veces se superponen, pero a menudo no.

Entonces, disculpas si daba la impresión de que la existencia de literales era el problema, no lo es. Yo diría que la complejidad del condicional con las 5 cláusulas es el problema. Su primer rojo a verde puede ser simplemente "volver verdadero" porque es realmente simple (y obtuso, por coincidencia). El próximo caso de prueba, con (1, 2, 3, 4, 5) tendrá que devolver falso, y aquí es donde comienza a dejar atrás "obtuso". Tiene que preguntarse "¿por qué es (1, 1, 1, 2, 2) una casa llena y (1, 2, 3, 4, 5) no lo es?" Lo más simple que se te ocurre es que uno tiene el último elemento de secuencia 5 o el segundo elemento de secuencia 2 y el otro no. Esos son simples, pero también son (innecesariamente) obtusos. Lo que realmente quieres conducir es "¿cuántos tienen el mismo número?" Por lo tanto, puede pasar la segunda prueba comprobando si hay una repetición o no. En el que tiene una repetición, tienes una casa llena, y en el otro no. Ahora la prueba pasa y usted escribe otro caso de prueba que tiene una repetición pero no es una casa completa para refinar aún más su algoritmo.

Puede o no hacer esto con literales a medida que avanza, y está bien si lo hace. Pero la idea general es hacer crecer su algoritmo 'orgánicamente' a medida que agrega más casos.

Erik Dietrich
fuente
He actualizado mi pregunta para agregar más información sobre por qué comencé con el enfoque literal.
Kristof Claes
99
Esta es una respuesta genial.
tallseth
1
Muchas gracias por su respuesta reflexiva y bien explicada. En realidad, tiene mucho sentido ahora que lo pienso.
Kristof Claes
1
Una prueba exhaustiva no significa probar cada combinación ... Eso es una tontería. Para este caso particular, tome una casa llena particular o dos y un par de casas no llenas. También cualquier combinación especial que pueda causar problemas (es decir, 5 de un tipo).
Schleis
3
+1 Los principios detrás de esta respuesta son descritos por Robert C. Martin's Transformation Priority Premise cleancoder.posterous.com/the-transformation-priority-premise
Mark Seemann
5

La prueba de cinco valores literales particulares en una combinación particular no es "lo más simple" para mi cerebro con fiebre. Si la solución a un problema es realmente obvia (cuente si tiene exactamente tres y exactamente dos de algún valor), entonces siga adelante y codifique esa solución, y escriba algunas pruebas que sería muy, muy poco probable que satisfagan accidentalmente con la cantidad de código que escribió (es decir, diferentes literales y diferentes órdenes de triples y dobles).

Las máximas de TDD son realmente herramientas, no creencias religiosas. Su objetivo es lograr que escriba código correcto y bien factorizado rápidamente. Si una máxima obviamente se interpone en el camino de eso, simplemente salte adelante y continúe con el siguiente paso. Habrá muchos bits no obvios en su proyecto donde podrá aplicarlo.

Kilian Foth
fuente
5

La respuesta de Erik es genial, pero pensé que podría compartir un truco en la escritura de prueba.

Comience con esta prueba:

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

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

Esta prueba es aún mejor si crea una Rollclase en lugar de pasar 5 parámetros:

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

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

Eso le da a esta implementación:

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

Luego escribe esta prueba:

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

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

Una vez que eso esté pasando, escribe este:

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

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

Después de eso, apuesto a que no necesitas escribir más (tal vez dos pares, o tal vez yahtzee, si crees que no es una casa llena).

Obviamente, implemente sus métodos Any para devolver Rolls aleatorios que cumplan con sus criterios.

Hay algunos beneficios en este enfoque:

  • No necesita escribir una prueba cuyo único propósito es evitar que se quede atascado en valores específicos
  • Las pruebas comunican su intención muy bien (el código de la primera prueba grita "cualquier casa llena devuelve verdadero")
  • te lleva rápidamente al punto de trabajar en la carne del problema
  • a veces notará casos en los que no pensó
tallseth
fuente
Si hace este enfoque, necesitará mejorar sus mensajes de registro en su declaración Assert.That. El desarrollador necesita ver qué entrada causó la falla.
Bringer128
¿Esto no crea un dilema de pollo o huevo? Cuando implemente AnyFullHouse (también usando TDD), ¿no necesitaría IsFullHouse para verificar su corrección? Específicamente, si AnyFullHouse tiene un error, ese error podría replicarse en IsFullHouse.
Waxwing
AnyFullHouse () es un método en un caso de prueba. ¿Normalmente TDD sus casos de prueba? No. Además, es mucho más simple crear un ejemplar aleatorio de una casa completa (o cualquier otra tirada) que probar su existencia. Por supuesto, si su prueba tiene un error, podría replicarse en el código de producción. Sin embargo, eso es cierto para cada prueba.
tallseth
AnyFullHouse es un método "auxiliar" en un caso de prueba. Si son lo suficientemente generales, los métodos de ayuda también se prueban.
Mark Hurd
IsFullHouseRealmente debería volver truesi pairNum == trioNum ?
recursion.ninja
2

Puedo pensar en dos formas principales que consideraría al probar esto;

  1. Agregue "algunos" más casos de prueba (~ 5) de conjuntos válidos de full-house, y la misma cantidad de falsificaciones esperadas ({1, 1, 2, 3, 3} es buena. Recuerde que, por ejemplo, 5 podrían ser reconocido como "3 de lo mismo más un par" por una implementación incorrecta). Este método asume que el desarrollador no solo está tratando de pasar las pruebas, sino que realmente lo implementa correctamente.

  2. Prueba todos los juegos de dados posibles (solo hay 252 diferentes). Por supuesto, esto supone que tiene alguna forma de saber cuál es la respuesta esperada (al probar esto se conoce como una oracle). Esto podría ser una implementación de referencia de la misma función, o un ser humano. Si desea ser realmente riguroso, podría valer la pena codificar manualmente cada resultado esperado.

De hecho, una vez escribí una IA de Yahtzee, que por supuesto tenía que conocer las reglas. Puede encontrar el código para la parte de evaluación de puntaje aquí , tenga en cuenta que la implementación es para la versión escandinava (Yatzy), y nuestra implementación asume que los dados se dan en orden ordenado.

Ansjob
fuente
La pregunta del millón es: ¿Derivó la IA de Yahtzee usando TDD puro? Mi apuesta es que no puedes; usted tiene que utilizar el conocimiento del dominio, que por definición no es ciego :)
Andrés F.
Sí, supongo que tienes razón. Este es un problema general con TDD, ya que los casos de prueba necesitan resultados esperados a menos que solo desee probar bloqueos inesperados y excepciones no controladas.
Ansjob
0

Este ejemplo realmente pierde el punto. Estamos hablando de una única función sencilla aquí, no un diseño de software. ¿Es un poco complicado? Sí, entonces lo descomponen. Y absolutamente no prueba todas las entradas posibles de 1, 1, 1, 1, 1 a 6, 6, 6, 6, 6, 6. La función en cuestión no requiere orden, solo una combinación, a saber, AAABB.

No necesita 200 pruebas lógicas separadas. Podría usar un conjunto, por ejemplo. Casi cualquier lenguaje de programación tiene uno incorporado:

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;

Y si recibe una entrada que no es una tirada válida de Yahtzee, debe lanzar como si no hubiera un mañana.

Jay Mueller
fuente