Estoy confundido acerca de cuál es la forma correcta de trabajar con TDD

8

Estoy tratando de comprender cuál es la idea detrás de TDD y cómo se supone que un equipo debe trabajar con ella. Tengo el siguiente caso de prueba con NUnit + Moq (solo escribiendo de memoria, no se garantiza que el ejemplo se compila, pero debería ser explicativo):

[Test]
public void WhenUserLogsCorrectlyIsRedirectedToLoginCorrectView() {
    Mock<IUserDatabaseRepository> repoMock = new Mock<IUserDatabaseRepository>();
    repoMock.Setup(m => m.GetUser(It.IsAny())).Returns(new User { Name = "Peter" });        

    Mock<ILoginHelper> loginHelperMock = new Mock<ILoginHelper>();
    loginHelperMock.Setup(m => m.Login(It.IsAny(), It.IsAny())).Returns(true);
    Mock<IViewModelFactory> factoryMock = new Mock<IViewModelFactory>();
    factoryMock.Setup(m => m.CreateViewModel()).Returns(new LoginViewModel());

    AccountController controller = new AccountController(repoMock.Object, loginHelperMock.Object, factoryMock.Object)

    var result = controller.Index(username : "Peter", password: "whatever");

    Assert.AreEqual(result.Model.Username, "Peter");
}

AccountController tiene 3 dependencias de las cuales me burlo que, cuando se orquesta dentro del controlador, me permite verificar si un inicio de sesión fue correcto o no.

Lo que me hace pensar es que ... si en teoría TDD tiene que escribir primero su conjunto de pruebas y construir su código a partir de él, ¿cómo se supone que debo saber de antemano que para realizar mi operación tendré que usar esas tres dependencias y que la operación llamará ciertas operaciones? Es como si necesitara conocer las entrañas del sujeto bajo prueba antes de incluso implementarlo para burlar las dependencias y aislar la clase, creando algún tipo de prueba de escritura - código de escritura - modificar la prueba si es necesario.

Naturalmente, sin ningún conocimiento de las entrañas de mi código y solo expresando la prueba, podría expresarlo como si solo necesitara el ILoginHelper y supongo "mágicamente" antes de escribir el código que devolverá al usuario un inicio de sesión exitoso (y finalmente darse cuenta de que el marco subyacente no funciona de esa manera, por ejemplo, devolver solo una ID en lugar del objeto completo).

¿Estoy entendiendo TDD de manera incorrecta? ¿Cuál es una práctica típica de TDD en un caso complejo?

Gracias

David Jiménez Martínez
fuente
1
No tiene que seguir un estricto TDD para obtener el mayor beneficio de las pruebas unitarias.
Den
2
@Den: "TDD estricto" no significa lo que el OP cree que significa.
Doc Brown
Recomiendo ver vimeo.com/album/3143213/video/71816368 (8LU: Conceptos avanzados en TDD). Podría ayudarte a entender las cosas.
Andrew Eddie

Respuestas:

19

si en TDD, en teoría, tiene que escribir primero su traje de prueba y construir su código a partir de él

Aquí está tu malentendido. TDD no se trata de escribir un conjunto de pruebas completo primero , es un falso mito. TDD significa trabajar en pequeños ciclos,

  • escribir una prueba a la vez
  • implementar solo la cantidad de código que sea necesario para que la prueba sea "verde"
  • refactor (el código y las pruebas)

Por lo tanto, la creación de un conjunto de pruebas no se realiza en un solo paso, y no "antes de que se escriba el código", se entrelaza con la implementación del código en juego.

Aplicado a su ejemplo: debe intentar comenzar con una prueba simple para un controlador sin ninguna dependencia (algo así como un prototipo). Luego implementa el controlador y refactoriza. Luego agrega una nueva prueba que espera que su controlador haga un poco más, o refactoriza / extiende su prueba existente. Luego modifica su controlador hasta que la nueva prueba se vuelva "verde". De esa manera, comienza con una combinación simple de pruebas y materias bajo prueba, y termina con una prueba compleja y materia bajo prueba.

Al seguir esta ruta, en algún momento descubrirá qué datos adicionales necesita como entrada para que el controlador haga su trabajo. De hecho, esto puede suceder en un momento en el que intentas implementar un método de controlador, y no cuando diseñas la próxima prueba. Ese es el punto donde se detiene para implementar el método por un corto tiempo, y comienza a introducir las dependencias que faltan primero (tal vez refactorizando el constructor de su controlador). Esto lleva directamente a una refactorización de sus pruebas existentes: en TDD, normalmente primero cambiará las pruebas que llaman al constructor y luego agregará los nuevos atributos del constructor. Y ahí es donde la codificación y escritura de las pruebas se entrelazan por completo.

Doc Brown
fuente
13

Lo que me hace pensar es que ... si en TDD, en teoría, tiene que escribir primero su traje de prueba y construir su código a partir de él, ¿cómo se supone que debo saber de antemano que para realizar mi operación tendré que usar esas tres dependencias y que la operación llamará ciertas operaciones? Es como si necesitara conocer las entrañas del sujeto bajo prueba antes de incluso implementarlo para burlar las dependencias y aislar la clase, creando algún tipo de prueba de escritura - código de escritura - modificar la prueba si es necesario.

Se siente mal, ¿verdad? Y debería hacerlo, no porque sus pruebas para el controlador sean incorrectas o "malas" de alguna manera, sino porque desea probar el controlador antes de que tenga algo para "controlar". :)

Mi punto es: TDD se sentirá más natural para usted una vez que comience a hacerlo al nivel de "reglas de negocio" y "lógica de aplicación real", que también es donde es más útil. Los controladores generalmente se ocupan solo de la delegación a otros componentes, por lo que es natural que, para probar si la delegación se realiza correctamente, necesita saber a qué objeto va a delegar. El único problema es cuando intentas hacerlo antes de tener implementada una lógica real. Mi sugerencia es que intente implementar LoginHelper, por ejemplo, haciendo TDD de una manera más "orientada al comportamiento". Se sentirá más natural y probablemente verá más de sus beneficios.

Entonces, para una respuesta más genérica: TDD es una práctica con la que producimos pruebas antes de escribir el código que necesitamos, pero no especifica qué tipo de pruebas. Los controladores suelen ser integradores de componentes, por lo que escribes pruebas unitarias que generalmente requieren mucha burla. Cuando escribe la lógica de la aplicación (reglas comerciales, como hacer un pedido, validar las autorizaciones de usuario, etc.), escribe pruebas de comportamiento, que generalmente serán pruebas basadas en estado (entrada dada frente a salida deseada). La comunidad de TDD suele referirse a esta diferencia como Mockism vs. Statism. Soy parte del grupo (pequeño) que insiste en que ambas formas son correctas, es solo que ofrecen diferentes compensaciones, por lo que son útiles para diferentes escenarios como se describe anteriormente.

MichelHenrich
fuente
1
Su respuesta tiene algunos puntos buenos, pero permítame señalar una cosa. "Los controladores suelen ser integradores de componentes, por lo que escribes pruebas de integración, que generalmente requieren muchas burlas", bueno, supongo que probablemente quisiste decir "cuando intentas escribir pruebas unitarias para controladores, generalmente requerirán muchas burlas" . En mi humilde opinión, el término "prueba de integración" se ajusta mejor a una prueba sin burlarse, donde realmente se utilizan los componentes reales, y no se burlan, para ver si funcionan juntos como se esperaba.
Doc Brown
Gracias @DocBrown, de hecho me refería a una "prueba de unidad que prueba la integración / comunicación entre componentes", y no al concepto de pruebas de integración que incluyen los componentes reales.
MichelHenrich
1
Bueno, ahora que estamos de acuerdo con el término "prueba de integración", creo que su respuesta nos lleva directamente a la siguiente pregunta: ¿realmente vale la pena usar TDD (o escribir pruebas unitarias) para controladores con la función principal de "integradores"? ¿O debería preferir escribir solo pruebas de integración para estos componentes (tal vez después)?
Doc Brown
4

Si bien TDD es un método de prueba primero, no requiere que pase mucho tiempo escribiendo código de prueba antes de escribir cualquier código de producción.

Para este ejemplo, la idea de TDD descrita en el libro seminal de Kent Beck sobre TDD ( 1 ) es comenzar con algo realmente simple, como tal vez

AccountController controller = new AccountController()

var result = controller.Index(username : "Peter", password: "whatever");

Assert.AreEqual(result.Model.Username, "Peter");

Al principio, no sabes todo lo que vas a necesitar para que hagas el trabajo. Solo sabe que necesitará un controlador con un método de índice que le proporcione un modelo con un nombre de usuario. Aún no sabes cómo va a hacer eso. Acabas de establecer una meta para ti mismo.

Luego, puede hacer que funcione utilizando cualquier medio disponible, posiblemente simplemente codificando el resultado correcto al principio. Luego, en las refactorizaciones posteriores (e incluso agregando pruebas adicionales) agrega una mayor sofisticación paso a paso. TDD le permite dar un paso tan pequeño como sea necesario para avanzar, pero también le da la libertad de dar un paso tan grande como lo permitan sus habilidades y conocimientos. Al tomar un ciclo corto entre el código de prueba y el código de producción, obtiene comentarios sobre cada pequeño paso que da y sabe con casi inmediatez si lo que acaba de hacer funcionó y si rompió algo más que estaba funcionando antes.

Robert Martin en ( 2 ) también aboga por un tiempo de ciclo muy corto entre escribir el código de prueba y escribir el código de producción.

J. Lenthe
fuente
3

Es posible que eventualmente necesite toda esta complejidad para una prueba de unidad conceptualmente simple, pero casi seguro que no escribirá la prueba de esta manera en primer lugar.

En primer lugar, la configuración compleja en sus primeras seis líneas debe factorizarse en un código de dispositivo reutilizable y autónomo. Los principios de la programación mantenible se aplican al código de prueba al igual que el código comercial; Si usa el mismo dispositivo para dos o más pruebas, definitivamente debe refactorizarse en un método separado para que solo tenga una línea de distracción en su prueba, o en el código de configuración de clase para que no tenga ninguna.

Pero lo más importante: escribir una prueba primero no garantiza que pueda permanecer sin cambios para siempre . Si no conoce a los colaboradores de una llamada al método, es casi seguro que no podrá adivinarlos correctamente en el primer intento. No hay nada de malo en refactorizar su código de prueba junto con su código comercial si cambia la API pública. Es cierto que el objetivo de TDD es escribir la API correcta y utilizable en primer lugar, pero esto casi nunca se logra al 100%. Requerimientos siemprecambia después del hecho, y con demasiada frecuencia esto requiere absolutamente colaboradores que no existían cuando escribiste la primera iteración de una historia. En ese caso, no hay nada que hacer sino morder la bala y cambiar las pruebas existentes junto con su aplicación; y esas son las ocasiones en que la mayoría del código de configuración que usted cita entraría en su conjunto de pruebas.

Kilian Foth
fuente
2
No estoy de acuerdo con la primera parte. Las pruebas deben ser independientes. Ese es un requisito mucho mayor en las pruebas unitarias que en el código, ya que la independencia mejora la capacidad de mantenimiento de las pruebas unitarias, mientras que la falta de reutilización perjudica el código de producción.
Telastyn
1
Las pruebas de @Telastyn pueden ser independientes mientras se comparte el código de configuración. Solo necesita asegurarse de usar un dispositivo nuevo , lo que significa llamar a un método de configuración compartido o usar una configuración implícita (si su marco de prueba lo admite).
Benjamin Hodgson
1
@BenjaminHodgson: no veo cómo se puede cambiar un método de configuración compartida para una prueba y no romper otra.
Telastyn
1
@Telastyn Pero eso se aplica al código reutilizado en general: una vez que una clase tiene más de un cliente, es más difícil cambiarlo. ¿Está argumentando a favor de copiar y pegar la duplicación del código de configuración del dispositivo en todas las pruebas unitarias?
Benjamin Hodgson
3
@Telastyn: si hacer que las pruebas sean independientes entre sí viola el principio DRY, inevitablemente se encontrará con problemas cuando intente mejorar el diseño de su código, pero tendrá que cambiar 30 métodos de prueba con "configuración similar" en lugar de un método de configuración reutilizado . Ese es en realidad el argumento principal que escucho a menudo contra TDD (demasiado esfuerzo para cambiar las pruebas durante la refactorización), pero casi siempre es el problema que las pruebas no son lo suficientemente SECAS.
Doc Brown
2

Es como si necesitara conocer las entrañas del sujeto bajo prueba antes de incluso implementarlo para burlar las dependencias y aislar la clase, creando algún tipo de prueba de escritura - código de escritura - modificar la prueba si es necesario.

Sí, hasta cierto punto lo haces. Así que no creo que estés malinterpretando cómo funciona TDD.

El problema es que, como han mencionado otros, al principio se siente muy extraño, casi incorrecto hacerlo de esta manera. En mi opinión, eso realmente muestra lo que siento es el mayor beneficio de TDD: debe comprender adecuadamente el requisito antes de escribir el código.

Como programadores, nos gusta escribir código. Entonces, lo que nos parece "correcto" y "natural" es descuidar los requisitos y atascarnos lo más rápido posible. Los problemas de diseño luego se vuelven aparentes a medida que construye y prueba la base de código. Entonces refactorizas y arreglas y las cosas mejoran gradualmente y avanzan hacia tu objetivo.

Aunque es divertido, esta no es una forma particularmente eficiente de hacer las cosas. Es mucho mejor tener una idea adecuada de lo que debe hacer un módulo de software primero, realizar las pruebas y luego escribir el código. Es menos refactorizante, menos mantenimiento de prueba y te obliga a una mejor arquitectura fuera del bloque.

No hago mucho TDD, y creo que el mantra de "cobertura de código 100%" no tiene sentido. Especialmente en casos como el tuyo. Pero adoptar TDD todavía tiene mucho valor porque es una gran ayuda para asegurarse de que las cosas estén bien diseñadas y mantenidas en todo su código.

En resumen, el hecho de que encuentres esto extraño es probablemente una buena señal de que estás en el camino correcto.

Bob Tway
fuente
0

La burla de datos es solo la práctica de usar datos ficticios ... los marcos de trabajo de Moq hacen que la creación de datos ficticios sea "más fácil".

ARREGLO | ACT | AFIRMAR

TDD generalmente se trata de crear sus pruebas y luego validar esas pruebas "pasar". Inicialmente, la primera prueba fallará ya que el código para validar esa prueba aún no se ha creado. Creo que este es realmente un cierto tipo de prueba; Pruebas "rojo / verde", que estoy seguro es la fuente de los métodos "Test Driven" en la actualidad.

En general, las pruebas validan las pequeñas pepitas de lógica que hacen que funcione el código de imagen más grande. Puede comenzar en el nivel de función más pequeño y luego avanzar hasta las funciones más complicadas.

Sí, a veces la configuración o la "burla" serán algo intensas, por lo que es una buena idea usar un marco de trabajo moq, sin embargo, si te enfocas en la lógica comercial central, entonces tus pruebas darán como resultado una garantía beneficiosa de que funciona como se esperaba y pretendía.

Personalmente, no pruebo mis controladores porque todo lo que está usando el controlador ha sido probado para que funcione, y en general, no necesitamos probar el marco.

hanzolo
fuente