TDD aplicado a .NET en 5 pasos

8 marzo, 2019
Test Driven Design workflow

Uno de los factores que más preocupan a un programador de software es cómo de estable es el sistema que está programando. De cara a incrementar la estabilidad de un sistema, una muy buena praxis es implementar juegos de pruebas para las operaciones en las que se trabaja. Estos permiten detectar y corregir errores rápidamente, sin necesidad de enfrentarse a ellos en fases posteriores del desarrollo. Ahora bien, ya sea por prisas o por descuidos, la cruda realidad es que los tests que incluyen muchas soluciones es insuficiente e incluso inexistente. Una opción para tratar de evitar esta desagradable situación es recurrir al TDD (Test Driven Development). En este post veremos cómo aplicar esta técnica en proyectos de .NET en 5 simples pasos.

Pruebas unitarias (Unit Testing)

Antes de abordar la metodología TDD, conviene tener claro qué es el Unit Testing y para qué sirve. La mayoría del software no se programa en un “one shot”, es decir, no se escribe y permanece inalterado por los siglos de los siglos. Por el contrario, todo desarrollo suele estar en continua evolución. Éste sufre constantes modificaciones, ya sea por incorporación de nuevas funcionalidades o por corrección de errores. Lo más habitual es que el código que estés desarrollando hoy lo modifique mañana un compañero tuyo o incluso tú mismo.

Lo más probable es que, en el momento de desarrollar una funcionalidad desde cero, se hayan tenido en cuenta múltiples escenarios y casuísticas que puedan dar problemas. ¿Qué pasa si me llega un nulo por parámetro? ¿Dará error si esta variable es negativa? ¿Y si no hay conexión? … Cuando un buen programador implementa una funcionalidad, se asegura que ésta no de error en un buen abanico de situaciones. Ahora bien, ¿qué ocurre cuando se modifica dicha funcionalidad por otro desarrollador (o incluso por uno mismo)? Lo más probable es que ya no se tengan en cuenta todas esas casuísticas y se cubran únicamente los casos que afectan a la modificación. Aquí es donde la mayoría de los errores (bugs) de una aplicación hacen su aparición 🙁

Taza de bugs de código

La taza ideal para un desarrollador

Una forma de indicar todos los escenarios que se han tenido en cuenta al programar son pruebas unitarias (unit testing), que son la base del TDD. Imaginemos que, además de la funcionalidad en sí misma, el programador implementa una serie de funciones que ejecutan dicha funcionalidad. Aquel que posteriormente modifique el código en cuestión, tendrá herramientas para verificar que no se han introducido errores, al menos para los casos que se tuvieron en cuenta originalmente. Si además de ello, esta modificación viene acompañada de las pruebas unitarias para los nuevos casos, la solución desarrollada será cada vez más estable.

Aplicando TDD

La teoría es muy buena pero, como siempre, la práctica es ligeramente diferente. Cuando se está en pleno desarrollo, es fácil cometer el error de centrarse en la funcionalidad y no escribir pruebas unitarias. Es por eso que el TDD le da la vuelta a la situación y plantea una mecánica distinta. Se trata de, antes de lanzarse a implementar nuestro código, diseñar y escribir primero los casos de prueba. Todas y cada una de las casuísticas que nuestra implementación deberá considerar para que funcione correctamente.

Como siempre, la metodología de los cursos uTech Academy es learning-by-doing, así que veamos un ejemplo práctico. Imaginemos que nuestro objetivo es desarrollar una clase que valide si un texto tiene formato de correo electrónico. Veamos cómo funciona el TDD aplicando 5 sencillos pasos en un proyecto de .NET que implemente esta funcionalidad:

  1. Prepara el entorno (EmailValidator.zip): para empezar, creamos una solución .NET y añadimos:
    • Un proyecto de librería de clases C# (Class Library) con una clase llamada “EmailValidator”. Ésta tendrá un método “ValidateEmail”. De momento, lo único que hará este método es arrojar una excepción (“NotImplementedException”), indicando que aún no está implementado
    • Un proyecto de test unitario (Unit Test Project), necesario para aplicar TDD

    TDD environment configuration

    Configurando el entorno para trabajar con TDD

  2. Diseña el test: escoge la funcionalidad que vas a implementar y define tu batería de pruebas. Si ya hubiesen pruebas pre-existentes, sería cuestión de ampliarlas. En este caso, se nos han ocurrido las siguientes:
    [TestClass]
    public class EmailValidatorTest
    {
        [TestMethod]
        public void TestCorrectEmailFormats()
        {
            Assert.IsTrue(EmailValidator.ValidateEmail("a@b.c"));
            Assert.IsTrue(EmailValidator.ValidateEmail("test@domain.com"));
            Assert.IsTrue(EmailValidator.ValidateEmail("test.with.dots@domain.com"));
            Assert.IsTrue(EmailValidator.ValidateEmail("abcdefghijklmnoprstuvwxyz0123456789.!#$%&'*+-/=?^_`{|}~@exhaustive.test"));
        }
        
        [TestMethod]
        public void TestIncorrectCorrectEmailFormats()
        {
            Assert.IsFalse(EmailValidator.ValidateEmail("this is not an email"));
            Assert.IsFalse(EmailValidator.ValidateEmail("@b.c"));
            Assert.IsFalse(EmailValidator.ValidateEmail("test@domain@com"));
            Assert.IsFalse(EmailValidator.ValidateEmail(".test.with.wrapping.dots.@domain.com"));
            Assert.IsFalse(EmailValidator.ValidateEmail("<::>@fail.test"));
            Assert.IsFalse(EmailValidator.ValidateEmail("test.with.áccent@fail.test"));
        }
    }
  3. Verifica que el test falla: al ejecutar las pruebas, evidentemente fallarán. Es lógico, puesto que todavía no hemos implementado la funcionalidad. De hecho, en este caso, el método ValidateEmail arroja siempre un “NotImplementedException”. Puedes acceder a esta ventana a través de Test -> Windows -> Text Explorer

    TDD test execution failure

    Ejecutando los tests y comprobando que fallan

  4. Implementa tu funcionalidad: ahora es el momento de centrarte en la implementación de tu funcionalidad. El objetivo es que los tests se ejecuten correctamente. Podría ser modificar código ya existente o desarrollar uno nuevo. A nosotros se nos ha ocurrido esta implementación (es necesario referenciar la librería “System.ComponentModel.DataAnnotations”)
    using System;
    using System.ComponentModel.DataAnnotations;
    
    namespace EmailValidator
    {
        public class EmailValidator
        {
            public static bool ValidateEmail(String text)
            {
                if (String.IsNullOrEmpty(text))
                    return false;
    
                return new EmailAddressAttribute().IsValid(text);
            }
        }
    }
  5. Verifica que el test funciona: al ejecutar de nuevo tu batería de pruebas, ésta debería correr sin problemas

    TDD test execution success

    Ejecutando los tests y comprobando que son correctos

  6. Realiza un refactor del código y verifica que los tests siguen funcionando: al haberte centrado en implementar tu funcionalidad, es posible que el conjunto del código que estás tratando pueda mejorarse, es decir, se pueda refactorizar. Si no tuviéramos una buena batería de pruebas, embarcarse en un refactor de un componente que no conocemos sería una tarea parecida a ésta:
    Refactoring code

    Situación en la que te sueles encontrar al hacer code refactor

    Pero dado que usamos TDD, sabemos que el riesgo de introducir bugs es mínimo: simplemente es cuestión de ejecutar la batería de pruebas y verificar que todas siguen funcionando. En nuestro caso, se nos ha ocurrido este refactor (que no rompe la ejecución de los tests):

    using System;
    using System.ComponentModel.DataAnnotations;
    
    namespace EmailValidator
    {
        public class EmailValidator
        {
            public static bool validateEmail(String text)
            {
                return !String.IsNullOrEmpty(text) && new EmailAddressAttribute().IsValid(text);
            }
        }
    }

Finalmente, se volvería al paso 1 para iniciar el ciclo de nuevo con otra funcionalidad. Dejamos un zip con la solución resultante (EmailValidator_v1.zip).

Conclusiones

Como ves, la metodología TDD es un concepto sencillo de aplicar y muy potente. Permite añadir una capa de robustez a tu sistema que irá incrementándose en la misma medida que la se incremente la funcionalidad. Es más, ésta es una técnica que se integra muy bien en procedimientos de CI / CD (Continous Integration / Continous Delivery), muy usados hoy en día. Esto se debe a que se puede configurar la ejecución de esos tests cada vez que se hace un pull request, se crea una rama de release, de hotfix, etc. Recuerda que esta no es una metodología exclusiva de proyectos que arrancan de cero. Siempre se puede aplicar este concepto, incluso a proyectos ya existentes. Simplemente el paso de refactor se hará más complicado pero, con el paso del tiempo, la aplicación ganará robustez y escalabiliad.

Como es natural, en el curso de programación .NET se ven en detalle todos estos conceptos (¡y muchos más!). Dado que estas son metodologías que las empresas suelen demandar, en uTech Academy nos esforzamos por que nuestros alumnos dispongan de las herramientas necesarias para su futuro como desarrolladores. Bajo nuestro humilde punto de vista, TDD es una herramienta que no puede faltar en toolbox de un desarrollador.