En los últimos tiempos, la metodología Test Driven Development se ha ido imponiendo como una forma de trabajo y un cambio de mentalidad en el mundo IT, pero lamentablemente siempre existen excepciones dentro de este sector, ya sea por mentalidad (“esto no vale para nada”) o bien por los deadlines que nos apremian (“esto es una pérdida de tiempo”).

Vamos a tratar de exponer a modo de introducción en qué consiste, cuáles son sus principios básicos, qué supone implantar esta metodología y qué ventajas nos aporta. ¿Preparados?

¿Qué es TDD?

TDD son las siglas de Test Driven Development. Es un proceso de desarrollo que consiste en codificar pruebas, desarrollar y refactorizar de forma continua el código construido.

La idea principal de esta metodología es realizar de forma inicial las pruebas unitarias para el código que tenemos que implementar. Es decir, primero codificamos la prueba y, posteriormente, se desarrolla la lógica de negocio.

Es una visión algo simplificada de lo que supone, ya que bajo mi punto de vista también nos aporta una visión más amplia de lo que vamos a desarrollar. Y en cierta manera nos ayuda a diseñar mejor nuestro sistema (al menos, siempre ha sido así desde mi experiencia).

Para que un test unitario sea útil y esta metodología tenga éxito, previamente a comenzar a codificar, necesitamos cumplir los siguientes puntos:

  1. Tener bien definidos los requisitos de la función a realizar. Sin una definición de requisitos no podemos comenzar a codificar. Debemos saber qué se quiere y qué posibles implicaciones puede tener en el código a desarrollar.
  2. Criterios de aceptación, contemplando todos los casos posibles, tanto exitosos como de error. Imaginemos un sistema de gestión de alta de fichas de jugadores. ¿Qué posibles criterios de aceptación podríamos tener?:
    1. Si el jugador se da de alta correctamente debe de devolver un mensaje satisfactorio como “El jugador con ID “X” se ha dado de alta correctamente”.
    2. Si se encuentra un jugador con el ID duplicado debe devolver un mensaje de error indicando “El jugador con ID “X" no ha podido ser dado de alta debido a que ya existe otro jugador con el mismo ID”.
    3. Si alguno de los campos queda vacío, debe devolver un mensaje de validación indicando qué campo es obligatorio o qué error de formato es el causante del problema.
    4. Cómo vamos a diseñar la prueba. Para realizar un buen test unitario debemos ceñirnos únicamente a testear la lógica de negocio que queremos implementar, abstrayéndonos en cierto modo de otras capas o servicios que puedan interactuar con nuestra lógica, simulando el resultado de dichas interacciones (Mocks). Aquí siempre hay diferentes perspectivas, con sus ventajas y desventajas, aunque en mi opinión al final debe ser el propio desarrollador quien debe decidir la opción con la se encuentre más cómodo y le aporte mayor información y eficiencia, tanto a nivel técnico como a nivel de concepto (aunque eso ya entra dentro de otro tipo de discusión).
    5. Qué queremos probar. El ejemplo expuesto en el punto 2, nos da pistas sobre qué deberíamos probar antes de codificar. Cada casuística para cada criterio de aceptación debería llevar su prueba asociada.
    6. Por ejemplo, si en el caso de “error por validación” es por un campo obligatorio, deberíamos hacer una prueba para este caso. Si es por un “error de validación de formato”, deberíamos hacer una prueba para este otro caso.
    7. ¿Cuántos test son necesarios? Tantos como casuísticas nos encontremos. De esta manera aseguramos que nuestra cobertura de pruebas es lo suficientemente fuerte como para asegurar el correcto funcionamiento del código desarrollado.

    Principios en los que se basa TDD

    1. Principio de responsabilidad simple o SRP (Single Responsability Principle). Una clase o un módulo tendrá una única responsabilidad. Robert C. Martín expresa el principio de la siguiente manera:

    "Una clase solo debe tener una razón para cambiar".

    1. Principio de abierto/cerrado (OCP). Una clase debe permitir ser extendida sin necesidad de ser modificada. Puesto que el software requiere cambios y que unas entidades dependen de otras, las modificaciones en el código de una de ellas pueden generar indeseables efectos colaterales en cascada. Por ejemplo:
    1. Principio de sustitución de Liskov (LSP). Si una función recibe un objeto como parámetro, de tipo X y en su lugar le pasamos otro de tipo Y (que hereda de X) dicha función debe proceder correctamente. Si una función no cumple el LSP rompe de forma automática con el principio OCP puesto que para funcionar con clases hijas necesita saber de la clase padre y por tanto modificarla. Por ejemplo:

    La clase programador debe funcionar correctamente con la clase Vehículo o con cualquier subclase de ella. El LSP es susceptible de ser violado cuando ocurren situaciones del tipo de la imagen de la derecha.

    1. Principio de segregación de Interfaces (ISP). Cuando aplicamos el SRP también empleamos el ISP. El ISP defiende que no debemos obligar a las clases (o interfaces) a depender de clases o interfaces que no necesitan usar. Tal imposición ocurre cuando una clase o interfaz tiene más métodos de los que necesita para sí mismo.
    2. Principio de inversión de dependencia (DIP). Son técnicas para lidiar con las colaboraciones entre clases produciendo un código reutilizable, sobrio y preparado para cambiar. También indica que un módulo A no debe depender directamente de otro módulo B, sino de una abstracción de B (interfaz o clase).

    Ciclo de vida

    El ciclo de vida de TDD se basa en una continua codificación y refactorización. ¿Cómo hacemos esto?

    1. Elegir un requisito. Se elige de una lista el requisito que, en un principio, pensamos que nos dará mayor conocimiento del problema y que a la vez sea fácilmente implementable.
    2. Codificar la prueba. Se comienza escribiendo una prueba para el requisito. Necesitamos que las especificaciones y los requisitos de la funcionalidad que están por implementar sean claros. Este paso fuerza al programador a tomar la perspectiva de un cliente considerando el código a través de sus interfaces.
    3. Tal y como hemos comentado en el anterior punto del blog, debemos tener claro qué vamos a probar y qué filosofía vamos a llevar a cabo para que lo que probemos sea significativo y demuestre que nuestro desarrollo es correcto. En nuestro caso, cuando realicemos un test unitario nos deberíamos centrar en la lógica de negocio del método que vamos a probar y no de sus dependencias.
    4. Verificar que la prueba falla. Si la prueba no falla es porque el requerimiento ya estaba implementado o porque la prueba es errónea.
    5. Codificar la implementación. Escribir el código más sencillo posible que haga que la prueba funcione.
    6. Ejecutar las pruebas automatizadas. Verificar si todo el conjunto de pruebas funciona correctamente.
    7. Refactor. El paso final es la refactorización, que se utilizará principalmente para eliminar código duplicado, eliminar dependencias innecesarias, etc.
    8. Actualización de la lista de requisitos. Se actualiza la lista de requisitos tachando el requisito implementado.

    Consecuencias de implantar esta metodología en un proyecto

    En los diversos proyectos en los que he trabajado a lo largo de mi carrera profesional, me he encontrado con la imposibilidad de implantar o animar a los responsables a cargo de cambiar la filosofía de trabajo (no siempre, afortunadamente).

    Como he contado al principio del post, siempre nos encontramos con los típicos casos en los que no ven utilidad en implementar dicha metodología. Las “excusas” siempre son del mismo tipo, desde “esto no vale para nada”, “no tenemos tiempo”, “no estoy acostumbrado”...

    En general, y bajo mi punto de vista, estas frases se sueltan desde el desconocimiento y la falta de visión a la hora de ver sus ventajas. Yo mismo pensaba eso al principio (cuando eres joven se dicen muchas tonterías), pero cuando ves que no es así...

    En general, en los proyectos donde he usado TDD, me ha ayudado a detectar requisitos que faltaban por parte de negocio, diseñar mejor mi lógica de negocio separando componentes y capas (ayuda en ciertos casos cuando algún miembro del equipo es muy dado a acoplar en demasía el código), y prevenir errores.

    Desconozco si habrá sido buena suerte (aunque en nuestro sector sabemos que normalmente la suerte no está de nuestra parte cuando se trata de un desarrollo complejo), pero en estos proyectos el número de errores se ha reducido drásticamente respecto a otros donde no he utilizado TDD (también hay otros factores, como la parte técnica, la organización y la comunicación que obviamente, influyen).

    Para el programador es un cambio grande en su mentalidad, en su forma de procesar y gestionar la información. Cuesta acostumbrarse al principio, pero llega un momento en que su productividad y eficiencia a la hora de codificar los test y de desarrollar de forma simplificada el código se incrementa y resulta muy productivo.

    Si un test está bien escrito y bien definido, podemos casi asegurar que nuestra lógica de negocio es correcta, y que lo que puede fallar en una posible incidencia puede venir principalmente de los datos que nos proveen sistemas externos. Esa es una forma también de acotar errores.

    Por ejemplo, no hace mucho que me encontré un caso en el cual los gastos de envío de una compra eran de 0,0€. Como sabía que era bastante improbable que mi desarrollo fuera el causante (ya que estaba bien cubierto por pruebas unitarias), me di cuenta que el error provenía de una caché externa que rellenaba una serie de tablas, cuya finalidad era la de devolver una serie de campos necesarios para calcular el importe.

    Al final, ahorras en tiempo de debugging y focalizas los posibles problemas de forma rápida y eficiente. Eso se traduce en menos horas de mantenimiento y “bucear” por el código para ver qué puede estar fallando. Algo en lo que a lo mejor hubiese tardado 1 hora, tardé tan solo 10 minutos en deducir.

    La principal desventaja que veo a esta metodología es que no es válida (al menos bajo mi punto de vista) para test integrados, ya que necesitamos conocer los datos del repositorio y verificar que el contenido es el esperado después de realizar una transacción (o un rollback en su defecto), lo cual, al final,requiere tener especial cuidado y un sistema de gestión para un BBDD (aunque sea en memoria, que sería lo ideal).

    Para este tipo de test integrados o funcionales hay frameworks como Concordion que ofrecen soluciones interesantes, aunque eso es un tema que podemos tratar en otro post.

    Por tanto, ¿qué ventajas nos ofrece TDD?:

    • Mayor calidad en el código desarrollado.
    • Diseño orientado a las necesidades.
    • Simplicidad, nos enfocamos en el requisito concreto.
    • Menor redundancia.
    • Mayor productividad (menor tiempo de debugging).
    • Se reduce el número de errores.

    Conclusión

    Os animo a que experimentéis esta forma de desarrollar vuestras aplicaciones en la medida de lo posible, (bajo mi punto de vista merece la pena), o al menos que lo conozcáis, porque cada día está más extendido en nuestro sector.

    Y desde luego, no tengo la verdad absoluta, ya que dentro de cada equipo de trabajo se discute “a lot of times” sobre cómo llevarlo a cabo de la mejor forma posible y seguramente haya pasado por alto muchas cosas, aunque un post al final se reduce a intentar explicar y desarrollar de forma simplificada una idea basada en la experiencia de uno.

    Por último, os recomiendo el libro de Kent Beck, “Test Driven Development: By Example”, material muy interesante por parte de uno de los gurús sobre este tema.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.