Escribir el código mínimo para aprobar una prueba unitaria, ¡sin hacer trampa!

36

Al hacer TDD y escribir una prueba unitaria, ¿cómo se resiste el impulso de "hacer trampa" al escribir la primera iteración del código de "implementación" que está probando?

Por ejemplo:
necesito calcular el factorial de un número. Comienzo con una prueba unitaria (usando 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);
    }
}

Ejecuto este código y falla ya que el CalculateFactorialmétodo ni siquiera existe. Entonces, ahora escribo la primera iteración del código para implementar el método bajo prueba, escribiendo el código mínimo requerido para pasar la prueba.

La cuestión es que continuamente tengo la tentación de escribir lo siguiente:

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

Esto es técnicamente correcto, ya que realmente es el código mínimo requerido para hacer que esa prueba específica pase (ir verde), aunque es claramente un "truco" ya que realmente ni siquiera intenta realizar la función de calcular un factorial. Por supuesto, ahora la parte de refactorización se convierte en un ejercicio para "escribir la funcionalidad correcta" en lugar de una verdadera refactorización de la implementación. Obviamente, agregar pruebas adicionales con diferentes parámetros fallará y forzará una refactorización, pero debe comenzar con esa prueba.

Entonces, mi pregunta es, ¿cómo logras ese equilibrio entre "escribir el código mínimo para pasar la prueba" mientras lo mantienes funcional y en el espíritu de lo que realmente estás tratando de lograr?

CraigTP
fuente
44
Es algo humano: tienes que resistir el impulso de hacer trampa. No hay nada más que eso. Puede agregar más pruebas y escribir más código de prueba que código para probar, pero si no tiene ese lujo, tendrá que resistir. Hay MUCHOS lugares en la codificación donde tenemos que resistir el impulso de piratear o hacer trampa, porque sabemos que, si bien podría funcionar hoy, no funcionará más tarde.
Dan Rosenstark
77
Seguramente, en TDD, hacerlo al revés es hacer trampa, es decir, devolver 120 es la forma correcta. Me resulta bastante difícil obligarme a hacer eso, y no correr hacia adelante y comenzar a escribir el cálculo factorial.
Paul Butcher
2
Consideraría que esto es una trampa, solo porque puede pasar la prueba pero no agrega ninguna funcionalidad verdadera ni lo acerca a una solución final al problema en cuestión.
GrumpyMonkey
3
Si resulta que el código del código del cliente solo pasa un 5, devolver 120 no es solo una trampa, sino que es una solución legítima.
Kramii reinstala a Monica el
Estoy de acuerdo con @PaulButcher; de hecho, muchos ejemplos de pruebas unitarias en textos y artículos adoptarían este enfoque.
HorusKol

Respuestas:

45

Es perfectamente legítimo. Rojo, verde, refactor.

La primera prueba pasa.

Agregue la segunda prueba, con una nueva entrada.

Ahora llegue rápidamente al verde, puede agregar un if-else, que funciona bien. Pasa, pero aún no has terminado.

La tercera parte de Red, Green, Refactor es la más importante. Refactorizar para eliminar la duplicación . Tendrás duplicación en tu código ahora. Dos declaraciones que devuelven enteros. Y la única forma de eliminar esa duplicación es codificar la función correctamente.

No digo que no lo escribas correctamente la primera vez. Solo digo que no es trampa si no lo haces.

CaffGeek
fuente
12
Esto solo plantea la pregunta, ¿por qué no simplemente escribir la función correctamente en primer lugar?
Robert Harvey
8
@Robert, los números factoriales son trivialmente simples. La verdadera ventaja de TDD es cuando escribe bibliotecas no triviales, y escribir la prueba primero lo obliga a diseñar la API antes de la implementación, lo que, en mi experiencia, conduce a un mejor código.
1
@Robert, eres tú quien está preocupado por resolver el problema en lugar de pasar la prueba. Le digo que para problemas no triviales simplemente funciona mejor posponer el diseño rígido hasta que tenga las pruebas en su lugar.
1
@ Thorbjørn Ravn Andersen, no, no estoy diciendo que solo puedas tener una devolución. Hay razones válidas para múltiples (es decir, declaraciones de guardia). El problema es que ambas declaraciones de devolución fueron "iguales". Hicieron la misma 'cosa'. Simplemente tuvieron valores diferentes. TDD no se trata de rigidez y de adherirse a un tamaño específico de relación prueba / código. Se trata de crear un nivel de comodidad dentro de su código base. Si puede escribir una prueba fallida, entonces una función que funcionará para futuras pruebas de esa función, genial. Hágalo, luego escriba sus pruebas de caso extremo asegurándose de que su función aún funcione.
CaffGeek
3
El punto de no escribir la implementación completa (aunque simple) a la vez es que no tienes ninguna garantía de que tus pruebas PUEDEN fallar. el punto de ver que una prueba falla antes de hacerla pasar es que tiene una prueba real de que su cambio en el código es lo que satisfizo la afirmación que hizo sobre él. Esta es la única razón por la que TDD es tan bueno para construir un conjunto de pruebas de regresión y borra completamente el piso con el enfoque "prueba después" en ese sentido.
Sara
25

Claramente, se requiere una comprensión del objetivo final y el logro de un algoritmo que cumpla ese objetivo.

TDD no es una bala mágica para el diseño; todavía tiene que saber cómo resolver problemas usando el código, y aún debe saber cómo hacerlo a un nivel superior a unas pocas líneas de código para pasar una prueba.

Me gusta la idea de TDD porque fomenta el buen diseño; te hace pensar en cómo puedes escribir tu código para que sea comprobable y, en general, esa filosofía empujará el código hacia un mejor diseño en general. Pero aún debe saber cómo diseñar una solución.

No estoy a favor de las filosofías reduccionistas de TDD que afirman que puede hacer crecer una aplicación simplemente escribiendo la menor cantidad de código para aprobar una prueba. Sin pensar en la arquitectura, esto no funcionará, y su ejemplo lo demuestra.

El tío Bob Martin dice esto:

Si no está haciendo Test Driven Development, es muy difícil llamarse profesional. Jim Coplin me llamó a la alfombra por este. No le gustó que dije eso. De hecho, su posición en este momento es que Test Driven Development está destruyendo arquitecturas porque la gente está escribiendo pruebas al abandono de cualquier otro tipo de pensamiento y desgarrando sus arquitecturas en el apuro loco para que las pruebas pasen y tiene un punto interesante, Esa es una forma interesante de abusar del ritual y perder la intención detrás de la disciplina.

si no estás pensando en la arquitectura, si lo que estás haciendo es ignorar la arquitectura y lanzar pruebas juntas y hacer que pasen, estás destruyendo lo que permitirá que el edificio permanezca en pie porque es la concentración en el estructura del sistema y decisiones de diseño sólidas que ayudan al sistema a mantener su integridad estructural.

No puede simplemente lanzar un montón de pruebas juntas y hacer que pasen década tras década tras década y asumir que su sistema va a sobrevivir. No queremos evolucionar a nosotros mismos en el infierno. Por lo tanto, un buen desarrollador impulsado por pruebas siempre es consciente de tomar decisiones arquitectónicas, siempre pensando en el panorama general.

Robert Harvey
fuente
No es realmente una respuesta a la pregunta, pero 1+
Nadie
2
@rmx: Um, la pregunta es: ¿cómo logras ese equilibrio entre "escribir el código mínimo para pasar la prueba" mientras lo mantienes funcional y en el espíritu de lo que realmente estás tratando de lograr? ¿Estamos leyendo la misma pregunta?
Robert Harvey
La solución ideal es un algoritmo y no tiene nada que ver con la arquitectura. Hacer TDD no te hará inventar algoritmos. En algún momento debe realizar pasos en términos de un algoritmo / solución.
Joppe el
Estoy de acuerdo con @rmx. Esto realmente no responde a mi pregunta específica, per se, pero da lugar a una reflexión sobre cómo TDD en general encaja en el panorama general del proceso general de desarrollo de software. Entonces, por esa razón, +1.
CraigTP
Creo que podría sustituir "algoritmos" - y otros términos - por "arquitectura" y el argumento aún se mantiene; se trata de no poder ver la madera de los árboles. A menos que vaya a escribir una prueba separada para cada entrada entera, TDD no podrá distinguir entre una implementación factorial adecuada y alguna codificación dura perversa que funcione para todos los casos probados pero no para otros. El problema con TDD es la facilidad con la que se combinan "todas las pruebas pasan" y "el código es bueno". En algún momento se debe aplicar una gran medida de sentido común.
Julia Hayward
16

Una muy buena pregunta ... y tengo que estar en desacuerdo con casi todos excepto @Robert.

Escritura

return 120;

para que una función factorial pase una prueba es una pérdida de tiempo . No es "trampa", ni está siguiendo al refactor rojo-verde literalmente. Es equivocado .

Este es el por qué:

  • Calcular Factorial es la característica, no "devolver una constante". "return 120" no es un cálculo.
  • los argumentos 'refactorizadores' están equivocados; Si tiene dos casos de prueba para 5 y 6, este código sigue siendo incorrecto, porque no está calculando un factorial en absoluto :

    if (input == 5) { return 120; } //input=5 case
    else { return 720; }   //input=6 case
    
  • si seguimos el argumento 'refactor' literalmente , cuando tengamos 5 casos de prueba invocaríamos a YAGNI e implementaríamos la función usando una tabla de búsqueda:

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

Ninguno de estos realmente está calculando nada, lo eres . ¡Y esa no es la tarea!

Steven A. Lowe
fuente
1
@rmx: no, no te lo perdiste; "refactorizar para eliminar la duplicación" puede satisfacerse con una tabla de búsqueda. Por cierto, el principio de que la unidad prueba los requisitos de codificación no es específico de BDD, es un principio general de Agile / XP. Si el requisito era "Responda la pregunta 'cuál es el factorial de 5'", entonces "devuelva 120;" sería legítimo ;-)
Steven A. Lowe
2
@Chad todo lo cual es un trabajo innecesario, solo escribe la función la primera vez ;-)
Steven A. Lowe
2
@ Steven A. Lowe, según esa lógica, ¿por qué escribir alguna prueba? "¡Solo escribe la aplicación la primera vez!" El punto de TDD, es pequeños, seguros, cambios incrementales.
CaffGeek
1
@ Chad: Strawman.
Steven A. Lowe
2
El punto de no escribir la implementación completa (aunque simple) a la vez es que no tienes ninguna garantía de que tus pruebas PUEDEN fallar. el punto de ver que una prueba falla antes de hacerla pasar es que tiene una prueba real de que su cambio en el código es lo que satisfizo la afirmación que hizo sobre él. Esta es la única razón por la que TDD es tan bueno para construir un conjunto de pruebas de regresión y borra completamente el piso con el enfoque "prueba después" en ese sentido. nunca escribe accidentalmente una prueba que no puede fallar. Además, eche un vistazo al tío Bobs Prime Factor Kata.
sara
10

Cuando ha escrito solo una prueba unitaria, la implementación de una línea ( return 120;) es legítima. Escribir un bucle que calcule el valor de 120, ¡ sería una trampa!

Estas pruebas iniciales simples son una buena manera de detectar casos extremos y evitar errores únicos. Cinco en realidad no es el valor de entrada con el que comenzaría.

Una regla general que podría ser útil aquí es: cero, uno, muchos, lotes . Cero y uno son casos importantes para el factorial. Se pueden implementar con líneas simples. El caso de prueba "muchos" (por ejemplo, 5!) Te obligaría a escribir un bucle. El caso de prueba "lotes" (1000 !?) podría obligarlo a implementar un algoritmo alternativo para manejar números muy grandes.

azheglov
fuente
2
El caso "-1" sería interesante. Debido a que no está bien definido, tanto el tipo que escribe la prueba como el tipo que escribe el código tienen que estar de acuerdo primero en lo que debería suceder.
gnasher729
2
+1 por señalar que factorial(5)es una mala primera prueba. partimos de los casos más simples posibles y en cada iteración hacemos que las pruebas sean un poco más específicas, instando al código a volverse un poco más genérico. esto es lo que Bob tío llama la premisa prioritaria la transformación ( blog.8thlight.com/uncle-bob/2013/05/27/... )
Sara
5

Siempre que solo tenga una sola prueba, entonces el código mínimo necesario para aprobar la prueba es verdaderamente return 120;, y puede mantenerlo fácilmente mientras no tenga más pruebas.

Esto le permite posponer más diseños hasta que realmente escriba las pruebas que ejercitan OTROS valores de retorno de este método.

Recuerde que la prueba es la versión ejecutable de su especificación, y si todo lo que dice esa especificación es que f (6) = 120, entonces eso se ajusta perfectamente.


fuente
¿Seriamente? Con esta lógica, tendrá que volver a escribir el código cada vez que alguien presente una nueva entrada.
Robert Harvey
66
@Robert, en ALGUNOS puntos, agregar un nuevo caso ya no dará como resultado el código más simple posible, momento en el que escribirá una nueva implementación. Como ya tiene las pruebas en su lugar, sabe exactamente cuándo su nueva implementación hace lo mismo que la anterior.
1
@ Thorbjørn Ravn Andersen, exactamente, la parte más importante de Red-Green-Refactor, es la refactorización.
CaffGeek
+1: Esta es también la idea general de mi conocimiento, pero hay que decir algo sobre el cumplimiento del contrato implícito (es decir, el factorial del nombre del método ). Si solo especificó (es decir, prueba) f (6) = 120, entonces solo necesita 'devolver 120'. Una vez que comience a agregar pruebas para asegurarse de que f (x) == x * x-1 ... * xx-1: upperBound> = x> = 0, llegará a una función que satisfaga la ecuación factorial.
Steven Evers
1
@SnOrfus, el lugar para los "contratos implícitos" es en los casos de prueba. Si el contrato es para factoriales, PRUEBE si los factoriales conocidos son y si los no factoriales conocidos no lo son. Muchisimos. No lleva mucho tiempo convertir la lista de los diez primeros factoriales en una prueba for-loop de cada número hasta el décimo factorial.
4

Si puede "hacer trampa" de esa manera, sugiere que las pruebas de su unidad son defectuosas.

En lugar de probar el método factorial con un solo valor, pruebe que era un rango de valores. Las pruebas basadas en datos pueden ayudar aquí.

Vea sus pruebas unitarias como una manifestación de los requisitos: deben definir colectivamente el comportamiento del método que prueban. (Esto se conoce como desarrollo impulsado por el comportamiento : es el futuro ;-))

Entonces pregúntese: si alguien cambiara la implementación a algo incorrecto, ¿pasarían sus pruebas o dirían "espera un minuto"?

Teniendo esto en cuenta, si su única prueba era la que estaba en su pregunta, entonces técnicamente, la implementación correspondiente es correcta. El problema se ve entonces como requisitos mal definidos.

Nadie
fuente
Como nanda señaló, siempre puede agregar una serie interminable de casedeclaraciones a una switch, y no puede escribir una prueba para cada entrada y salida posible para el ejemplo del OP.
Robert Harvey
Técnicamente podría probar valores de Int64.MinValuea Int64.MaxValue. Llevaría mucho tiempo ejecutarlo, pero definiría explícitamente el requisito sin margen de error. Con la tecnología actual, esto es inviable (sospecho que podría volverse más común en el futuro) y estoy de acuerdo, podría hacer trampa, pero creo que la pregunta de los OP no era práctica (nadie realmente haría trampa de tal manera en la práctica), pero teórica.
Nadie
@rmx: Si pudieras hacer eso, las pruebas serían el algoritmo y ya no necesitarías escribir el algoritmo.
Robert Harvey
Es verdad. Mi tesis universitaria realmente implica la generación automática de la implementación utilizando las pruebas unitarias como guía con un algoritmo genético como ayuda para TDD, y solo es posible con pruebas sólidas. La diferencia es que vincular sus requisitos a su código generalmente es mucho más difícil de leer y comprender que un solo método que incorpora las pruebas unitarias. Luego viene la pregunta: si su implementación es una manifestación de sus pruebas unitarias, y sus pruebas unitarias son una manifestación de sus requisitos, ¿por qué no simplemente omitir las pruebas por completo? No tengo una respuesta
Nadie el
Además, ¿no somos nosotros, como humanos, tan propensos a cometer un error en las pruebas unitarias como lo estamos en el código de implementación? Entonces, ¿por qué prueba unitaria?
Nadie el
3

Solo escribe más pruebas. Eventualmente, sería más corto escribir

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

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
fuente
3
¿Por qué no escribir el algoritmo correctamente en primer lugar?
Robert Harvey
3
@Robert, es el algoritmo correcto para calcular el factorial de un número del 0 al 5. Además, ¿qué significa "correctamente"? Este es un ejemplo muy simple, pero cuando se vuelve más complejo, hay muchas gradaciones de lo que significa "correcto". ¿Es un programa que requiere acceso root lo suficientemente "correcto"? ¿Está usando XML "correcto", en lugar de usar CSV? No puedes responder esto. Cualquier algoritmo es correcto siempre que satisfaga algunos requisitos comerciales, que se formulan como pruebas en TDD.
P Shved
3
Debe tenerse en cuenta que, dado que el tipo de salida es largo, solo hay un pequeño número de valores de entrada (más o menos 20) que la función puede manejar correctamente, por lo tanto, una declaración de cambio grande no es necesariamente la peor implementación, si la velocidad es mayor importante que el tamaño del código, la declaración de cambio podría ser el camino a seguir, dependiendo de sus prioridades.
user281377
3

Escribir pruebas de "trampa" está bien, para valores suficientemente pequeños de "OK". Pero recuerde: las pruebas unitarias solo se completan cuando todas las pruebas pasan y no se pueden escribir nuevas pruebas que fallen . Si realmente desea tener un método CalculateFactorial que contenga un montón de declaraciones if (o incluso mejor, una gran declaración de cambio / caso :-) puede hacerlo, y dado que se trata de un número de precisión fija, el código requerido implementar esto es finito (aunque probablemente bastante grande y feo, y tal vez limitado por las limitaciones del compilador o del sistema en el tamaño máximo del código de un procedimiento). En este punto si realmenteinsista en que todo el desarrollo debe ser impulsado por una prueba unitaria; puede escribir una prueba que requiera que el código calcule el resultado en un período de tiempo más corto que el que se puede lograr siguiendo todas las ramas de la instrucción if .

Básicamente, TDD puede ayudarlo a escribir código que implemente los requisitos correctamente , pero no puede obligarlo a escribir un buen código. Eso depende de usted.

Comparte y Disfruta.

Bob Jarvis - Restablece a Monica
fuente
+1 para "las pruebas unitarias solo se completan cuando todas las pruebas pasan y no se pueden escribir nuevas pruebas que fallen" Muchas personas dicen que es legítimo devolver la constante, pero no siguen con "a corto plazo" o " si los requisitos generales solo necesitan esos casos específicos "
Thymine
1

Estoy 100% de acuerdo con la sugerencia de Robert Harvey aquí, no se trata solo de aprobar las pruebas, también debes tener en cuenta el objetivo general.

Como solución a su punto de dolor de "solo se verifica trabajar con un conjunto dado de entradas", propondría usar pruebas basadas en datos, como la teoría de xunit. El poder detrás de este concepto es que le permite crear fácilmente especificaciones de entradas a salidas.

Para Factorials, una prueba se vería así:

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

Incluso podría implementar un suministro de datos de prueba (que devuelve IEnumerable<Tuple<xxx>>) y codificar una invariante matemática, como dividir repetidamente entre n producirá n-1).

Creo que este tp es una forma muy poderosa de prueba.

Johannes Rudolph
fuente
1

Si aún puede hacer trampa, entonces las pruebas no son suficientes. Escribe más pruebas! Para su ejemplo, intentaré agregar pruebas con la entrada 1, -1, -1000, 0, 10, 200.

Sin embargo, si realmente te comprometes a hacer trampa, puedes escribir un interminable si-entonces. En este caso, nada podría ayudar excepto la revisión de código. Pronto sería atrapado en la prueba de aceptación (¡ escrito por otra persona! )

El problema con las pruebas unitarias es que a veces los programadores las consideran un trabajo innecesario. La forma correcta de verlos es como una herramienta para que usted haga el resultado correcto de su trabajo. Entonces, si creas un if-then, sabes inconscientemente que hay otros casos a considerar. Esto significa que tienes que escribir otras pruebas. Y así sucesivamente hasta que te des cuenta de que el engaño no funciona y es mejor simplemente codificar de la manera correcta. Si todavía siente que no ha terminado, no ha terminado.

nanda
fuente
1
Por lo tanto, parece que está diciendo que simplemente escribir el código suficiente para que la prueba pase (como lo recomienda TDD) no es suficiente. También debe tener en cuenta los principios de diseño de software de sonido. Estoy de acuerdo contigo BTW.
Robert Harvey
0

Sugeriría que su elección de prueba no es la mejor prueba.

Yo comenzaría con:

factorial (1) como la primera prueba,

factorial (0) como el segundo

factorial (-ve) como el tercero

y luego continuar con casos no triviales

y terminar con un caso de desbordamiento.

Chris Cudmore
fuente
Que es -ve??
Robert Harvey
Un valor negativo.
Chris Cudmore