Test Driven Development: qué es y qué no es.

El desarrollo basado en pruebas se ha vuelto popular en los últimos años. Muchos programadores han probado esta técnica, han fallado y han llegado a la conclusión de que TDD no merece el esfuerzo que requiere.

Algunos programadores piensan que, en teoría, es una buena práctica, pero que nunca hay tiempo suficiente para usar TDD. Y otros piensan que básicamente es una pérdida de tiempo.

Si se siente así, creo que es posible que no comprenda lo que realmente es TDD. (OK, la oración anterior fue para llamar su atención). Hay un muy buen libro sobre TDD, Test Driven Development: By Example, de Kent Beck, si desea consultarlo y obtener más información.

En este artículo, analizaré los fundamentos del desarrollo impulsado por pruebas, abordando conceptos erróneos comunes sobre la técnica TDD. Este artículo es también el primero de una serie de artículos que voy a publicar, todo sobre el desarrollo basado en pruebas.

¿Por qué utilizar TDD?

Hay estudios, artículos y debates sobre la eficacia de TDD. Aunque definitivamente es útil tener algunos números, no creo que respondan a la pregunta de por qué deberíamos usar TDD en primer lugar.

Di que eres un desarrollador web. Acaba de terminar una pequeña función. ¿Considera suficiente probar esta función con solo interactuar manualmente con el navegador? No creo que sea suficiente depender solo de las pruebas realizadas por los desarrolladores manualmente. Desafortunadamente, esto significa que parte del código no es lo suficientemente bueno.

Pero la consideración anterior se trata de pruebas, no de TDD en sí. Entonces, ¿por qué TDD? La respuesta corta es "porque es la forma más sencilla de lograr tanto un código de buena calidad como una buena cobertura de prueba".

La respuesta más larga proviene de lo que realmente es TDD ... Comencemos con las reglas.

Reglas del juego

El tío Bob describe TDD con tres reglas:

- No se le permite escribir ningún código de producción a menos que sea para aprobar una prueba unitaria fallida. - No se le permite escribir más de una prueba unitaria de la suficiente para fallar; y las fallas de compilación son fallas.- No se le permite escribir más código de producción del que sea suficiente para pasar la prueba de una unidad que falla.

También me gusta una versión más corta, que encontré aquí:

- Escriba solo lo suficiente de una prueba unitaria para fallar - Escriba solo suficiente código de producción para que la prueba unitaria que falla pase

Estas reglas son simples, pero las personas que se acercan a TDD a menudo violan una o más de ellas. Te desafío: ¿puedes escribir un pequeño proyecto siguiendo estrictamente estas reglas? Por proyecto pequeño me refiero a algo real, no solo a un ejemplo que requiere como 50 líneas de código.

Esas reglas definen la mecánica de TDD, pero definitivamente no son todo lo que necesitas saber. De hecho, el proceso de uso de TDD a menudo se describe como un ciclo Rojo / Verde / Refactor. Veamos de qué se trata.

Ciclo de refactorización verde rojo

Fase roja

En la fase roja, debe escribir una prueba sobre un comportamiento que está a punto de implementar. Sí, escribí comportamiento . La palabra "prueba" en el desarrollo basado en pruebas es engañosa. Deberíamos haberlo llamado "desarrollo impulsado por el comportamiento" en primer lugar. Sí, lo sé, algunas personas argumentan que BDD es diferente de TDD, pero no sé si estoy de acuerdo. Entonces, en mi definición simplificada, BDD = TDD.

Aquí surge un error común: “Primero escribo una clase y un método (pero no una implementación), luego escribo una prueba para probar ese método de clase”. En realidad, no funciona de esta manera.

Demos un paso atrás. ¿Por qué la primera regla de TDD requiere que escriba una prueba antes de escribir cualquier parte del código de producción? ¿Somos maníacos de la gente TDD?

Cada fase del ciclo RGR representa una fase en el ciclo de vida del código y cómo podría relacionarse con él.

En la fase roja, actúa como si fuera un usuario exigente que quiere utilizar el código que está a punto de escribirse de la forma más sencilla posible. Tienes que escribir una prueba que use un fragmento de código como si ya estuviera implementado. ¡Olvídate de la implementación! Si en esta fase estás pensando en cómo vas a escribir el código de producción, ¡lo estás haciendo mal!

Es en esta fase donde te concentras en escribir una interfaz limpia para futuros usuarios. Esta es la fase en la que diseña cómo los clientes utilizarán su código.

Esta primera regla es la más importante y es la regla que hace que TDD sea diferente de las pruebas regulares. Escribe una prueba para que luego pueda escribir código de producción. No escribe una prueba para probar su código.

Veamos un ejemplo.

// LeapYear.spec.jsdescribe('Leap year calculator', () => { it('should consider 1996 as leap', () => { expect(LeapYear.isLeap(1996)).toBe(true); });});

El código anterior es un ejemplo de cómo se vería una prueba en JavaScript, utilizando el marco de prueba Jasmine. No es necesario que conozca a Jasmine; es suficiente con entender que it(...)es una prueba y expect(...).toBe(...)es una forma de hacer que Jasmine verifique si algo es como se esperaba.

En la prueba anterior, verifiqué que la función LeapYear.isLeap(...)regresa truepara el año 1996. Puede pensar que 1996 es un número mágico y, por lo tanto, es una mala práctica. No lo es. En el código de prueba, los números mágicos son buenos, mientras que en el código de producción deben evitarse.

Esa prueba en realidad tiene algunas implicaciones:

  • El nombre de la calculadora de años bisiestos es LeapYear
  • isLeap(...)es un método estático de LeapYear
  • isLeap(...)toma un número (y no una matriz, por ejemplo) como argumento y devuelve trueo false.

Es una prueba, ¡pero en realidad tiene muchas implicaciones! ¿Necesitamos un método para saber si un año es bisiesto o necesitamos un método que devuelva una lista de años bisiestos entre una fecha de inicio y una de finalización? ¿Tiene sentido el nombre de los elementos? Estos son los tipos de preguntas que debe tener en cuenta al escribir exámenes en la fase roja.

En esta fase, debe tomar decisiones sobre cómo se utilizará el código. Basa esto en lo que realmente necesita en este momento y no en lo que cree que puede ser necesario.

Aquí viene otro error: no escriba un montón de funciones / clases que crea que puede necesitar. Concéntrese en la función que está implementando y en lo que realmente se necesita. Escribir algo que la función no requiere es un exceso de ingeniería.

¿Y la abstracción? Lo veremos más adelante, en la fase de refactorización.

Fase verde

Esta suele ser la fase más fácil, porque en esta fase se escribe el código (de producción). Si es programador, lo hace todo el tiempo.

Aquí viene otro gran error: en lugar de escribir suficiente código para pasar la prueba roja, escribe todos los algoritmos. Mientras hace esto, probablemente esté pensando en cuál es la implementación con mejor rendimiento. ¡De ninguna manera!

En esta fase, debe actuar como un programador que tiene una tarea simple: escribir una solución sencilla que haga que la prueba pase (y haga que el rojo alarmante en el informe de la prueba se convierta en un verde amigable). En esta fase, se le permite violar las mejores prácticas e incluso duplicar el código. La duplicación de código se eliminará en la fase de refactorización.

Pero, ¿por qué tenemos esta regla? ¿Por qué no puedo escribir todo el código que ya tengo en mente? Por dos razones:

  • Una tarea simple es menos propensa a errores y desea minimizar los errores.
  • Definitivamente no desea mezclar el código que se está probando con el código que no lo está. Puede escribir código que no esté en prueba (también conocido como heredado), pero lo peor que puede hacer es mezclar código probado y no probado.

¿Qué pasa con el código limpio? ¿Y el rendimiento? ¿Qué pasa si escribir código me hace descubrir un problema? ¿Y las dudas?

El rendimiento es una larga historia y está fuera del alcance de este artículo. Digamos que el ajuste del rendimiento en esta fase es, la mayoría de las veces, una optimización prematura.

La técnica de desarrollo basada en pruebas proporciona otras dos cosas: una lista de tareas pendientes y la fase de refactorización.

The refactor phase is used to clean up the code. The to-do list is used to write down the steps required to complete the feature you are implementing. It also contains doubts or problems you discover during the process. A possible to-do list for the leap year calculator could be:

Feature: Every year that is exactly divisible by four is a leap year, except for years that are exactly divisible by 100, but these centurial years are leap years if they are exactly divisible by 400.
- divisible by 4- but not by 100- years divisible by 400 are leap anyway
What about leap years in Julian calendar? And years before Julian calendar?

The to-do list is live: it changes while you are coding and, ideally, at the end of the feature implementation it will be blank.

Refactor phase

In the refactor phase, you are allowed to change the code, while keeping all tests green, so that it becomes better. What “better” means is up to you. But there is something mandatory: you have to remove code duplication. Kent Becks suggests in his book that removing code duplication is all you need to do.

In this phase you play the part of a picky programmer who wants to fix/refactor the code to bring it to a professional level. In the red phase, you’re showing off your skills to your users. But in the refactor phase, you’re showing off your skills to the programmers who will read your implementation.

Removing code duplication often results in abstraction. A typical example is when you move two pieces of similar code into a helper class that works for both the functions/classes where the code has been removed.

For example the following code:

class Hello { greet() { return new Promise((resolve) => { setTimeout(()=>resolve('Hello'), 100); }); }}class Random { toss() { return new Promise((resolve) => { setTimeout(()=>resolve(Math.random()), 200); }); }}new Hello().greet().then(result => console.log(result));new Random().toss().then(result => console.log(result));

could be refactored into:

class Hello { greet() { return PromiseHelper.timeout(100).then(() => 'hello'); }}class Random { toss() { return PromiseHelper.timeout(200).then(() => Math.random()); }}class PromiseHelper { static timeout(delay) { return new Promise(resolve => setTimeout(resolve, delay)); }}const logResult = result => console.log(result);new Hello().greet().then(logResult);new Random().toss().then(logResult);

As you can see, in order to remove thenew Promise and setTimeout code duplication, I created a PromiseHelper.timeout(delay) method, which serves both Hello and Random classes.

Just keep in mind that you cannot move to another test unless you’ve removed all the code duplication.

Final considerations

In this section I will try to answer to some common questions and misconceptions about Test Drive Development.

  • T.D.D. requires much more time than “normal” programming!

What actually requires a lot of time is learning/mastering TDD as well as understanding how to set up and use a testing environment. When you are familiar with the testing tools and the TDD technique, it actually doesn’t require more time. On the contrary, it helps keep a project as simple as possible and thus saves time.

  • How many test do I have to write?

The minimum amount that lets you write all the production code. The minimum amount, because every test slows down refactoring (when you change production code, you have to fix all the failing tests). On the other hand, refactoring is much simpler and safer on code under tests.

  • With Test Driven Development I don’t need to spend time on analysis and on designing the architecture.

This cannot be more false. If what you are going to implement is not well-designed, at a certain point you will think “Ouch! I didn’t consider…”. And this means that you will have to delete production and test code. It is true that TDD helps with the “Just enough, just in time” recommendation of agile techniques, but it is definitely not a substitution for the analysis/design phase.

  • Should test coverage be 100%?

No. As I said earlier, don’t mix up tested and untested code. But you can avoid using TDD on some parts of a project. For example I don’t test views (although a lot of frameworks make UI testing easy) because they are likely to change often. I also ensure that there is very a little logic inside views.

  • I am able to write code with very a few bugs, I don’t need testing.

You may able to to that, but is the same consideration valid for all your team members? They will eventually modify your code and break it. It would be nice if you wrote tests so that a bug can be spotted immediately and not in production.

  • TDD works well on examples, but in a real application a lot of the code is not testable.

I wrote a whole Tetris (as well as progressive web apps at work) using TDD. If you test first, code is clearly testable. It is more a matter of understanding how to mock dependencies and how to write simple but effective tests.

  • Tests should not be written by the developers who write the code, they should be written by others, possibly QA people.

If you are speaking about testing your application, yes it is a good idea to ask other people to test what your team did. If you are speaking about writing production code, then that’s the wrong approach.

What’s next?

This article was about the philosophy and common misconceptions of TDD. I am planning to write other articles on TDD where you will see a lot of code and fewer words. If you are interested on how to develop Tetris using TDD, stay tuned!