Principios de Test Driven Development (TDD) con Python

Principios de Test Driven Development (TDD) con Python

Cuando se inicia un nuevo sistema, es común comenzar por diseñar la funcionalidad básica y probar manualmente para asegurarse de que cada pieza de código funcione como se espera.

Sin embargo, a medida que avanzamos en la carrera de desarrollador, nos involucramos en proyectos cada vez más complejos y, por lo tanto, se hace necesaria una forma más efectiva de administrar nuestro código.

Una de las denominadas metodologías ágiles que se pueden utilizar para esta tarea es Test Driven Development (TDD), que detallaremos en este artículo con ayuda de Python.

¿Qué es TDD?

Traduciendo, TDD significa Desarrollo Basado en Pruebas, una metodología que consiste en escribir código de prueba antes de tener realmente un código de trabajo. Esto permite al desarrollador lograr un ciclo de desarrollo mucho más ágil, primero escribiendo el código que fallará y devolverá un error, luego el código para pasar la prueba y, finalmente, el código refactorizado. Este ciclo se conoce como red, green, refactor.

Entre las ventajas de utilizar esta metodología están: saber de antemano qué esperar del código, definir los tipos de datos que recibirán y devolverán las funciones, reducir la cantidad de errores (bugs) escribiendo pruebas para cubrir toda la funcionalidad del código y facilitar el mantenimiento, ya que si un cambio hace que el sistema deje de funcionar correctamente, las pruebas identificarán la ubicación del problema.


¿Qué debe evaluarse?

Primero, vale la pena señalar que existen diferentes tipos de pruebas. Entre las más comunes figuran:

  • Unitarias: garantizan la funcionalidad de pequeñas unidades de código, como una función;
  • de Rendimiento: determinan la fluidez del código;
  • Seguridad: garantizará que el código no pueda ser aprovechado por un usuario malicioso.

Lo que se debe probar principalmente son las reglas de negocio, es decir, el código de funciones y clases que realizarán algún cálculo, procesarán datos o tomarán decisiones. Esto incluye verificar los inputs para ver si los tipos de datos son correctos, en el formato adecuado y dentro del rango (range) esperado.

También verifique si cuando se proporcionan datos incorrectos, el código devolverá el error apropiado y verifique el código que interactúa con dependencias externas, como bases de datos, API o bibliotecas de terceros.


¿Cómo desarrollar un sistema?

El primer paso para crear un nuevo sistema es comprender el problema que se trata de resolver, sabiendo que existen diferentes tipos de problemas y formas de atenderlos.

Puedes tener un bug en un programa ya existente donde debes mirar el error recibido y corregirlo, así como uno más abstracto, como la experiencia del usuario con algún producto o servicio, por ejemplo.

Comienza por establecer el contexto, ¿qué se debe hacer? Luego, desarrolla una visión clara de por qué se debe diseñar un nuevo sistema: ¿por qué es esto un problema? ¿Qué beneficios traerá la solución de este problema?

Después de este ejercicio mental, el siguiente paso es investigar si otras personas han tenido este mismo problema y cómo lo solucionaron. Si no encuentras una solución de inmediato, busca formas que podrían resolver este inconveniente.

Después de eso, es hora de diseñar un algoritmo, que en matemáticas e informática es una secuencia de acciones lógicas destinadas a obtener la solución a un problema determinado. En este primer momento, no es necesario escribir un código funcional, sino que puedes empezar con un diagrama de flujo o un pseudocódigo a modo de comentario en tu editor de código, utilizando un bloc de notas, una aplicación específica o incluso con lápiz y papel.


Entendiendo el ciclo red, green, refactor

Escribir la prueba que falla (red)

Con un algoritmo básico en mano, podemos comenzar a escribir las pruebas. La primera etapa del ciclo es crear una prueba que va a fallar (de ahí el nombre red, por el color asociado al fracaso de la prueba). Voy a utilizar la biblioteca nativa de Python unittest para crear una calculadora básica como ejemplo.

Primero, creamos un archivo de pruebas llamado test_calculator.py y un archivo para el código de la calculadora, que llaamaremos calculator.py. Dentro del archivo de pruebas, importaremos el módulo unittest y todo el contenido del archivo de código de la calculadora:

A continuación, creamos una clase para probar nuestra calculadora, la cual va a depender de la clase TestCase del módulo unittest y, dentro de ella, definiremos las pruebas para sumar, restar, multiplicar y dividir.

Llamaremos la función main de unittest para ejecutar las pruebas:

Nota que todas las funciones comienzan con el nombre test, seguido de un underline o guion bajo y su descripción. Esto es importante porque así interpreta el unittest que una función dentro de la classe TestCase es una prueba y la ejecuta. Si no tuviera el nombre test al inicio, no podría hacerse la prueba.

La forma en la que validamos las pruebas es a través de la función assert. En este ejemplo, utilizamos assertEqual que compara si dos valores son iguales. Si lo son, indica verdadero y las pruebas son exitosas. En caso contrario dirá falso, lo que significa que no fue posible ejecutar la prueba. Existen funciones adicionales como assertTrue, que verifica si un valor es verdadero; assertFalse para lo opuesto y assertNotEqual que verifica si dos valores son diferentes, entre otras.

Otro punto relevante que notarás es que dentro de cada función de la prueba se creó una nueva instancia de la clase Calculator para mostrar que, si fueran necesarias varias funciones de prueba y utilizaran un mismo objeto, puedes incluir una función setUp y puedes acceder a cada función a través de self:

SetUp es una función especial de TestCase que nos permite definir atributos para la clase que los métodos podrán utilizar. Igual que ella, existe tearDown, una función que será llamada cuando se acaba el TestCase. Un ejemplo de uso prático para esas funcionalidades es cuando pruebas una base de datos: en setUp puedes abrir una conexión con la base de datos y cerrarla con tearDown.

Al ejecutar el comando python test_calculator.py en la terminal, veremos que aparecerán cuatro errores según lo esperado, eso porque no implementamos ningún código funcional todavía.

Escribiendo el código para aprobar el test (green)

La próxima etapa es implementar el código funcional. Aquí es importante escribir apenas el mínimo de código para que las pruebas sean exitosas. El código para hacer las pruebas escritas en este ejemplo sería éste:

Ao escrever este código no arquivo calculator.py e rodar novamente o comando no terminal, os testes passarão, pois agora temos implementado a classe que define a calculadora e as operações básicas.

Refactorizando el código (refactor)

La última etapa de este ciclo es la refactorización del código. En este punto, tenemos algunos detalles que considerar, como la legibilidad, o desacoplamiento y el performance.

En cuanto a legibilidad, en el caso de Python (que es un lenguaje tipado dinámicamente, es decir, que el tipo de las variables es determinado cuando el programa es ejecutado), podemos añadir recomendaciones de tipos (type hinting), una funcionalidad que fue creada en las nuevas versiones Python que permite saber lo que va a recibir o devolver una función como parámetro:

En otros lenguajes de programación que no tienen tipado dinámico esto no es necesario, ya que la indicación del tipo ya es obligatoria. Vale la pena señalar que esta práctica no cambia el comportamiento del lenguaje, como su nombre lo indica, es solo una recomendación de tipo para que el desarrollador conozca el comportamiento por defecto de una función, pero diciendo que un parámetro es de tipo int, no nos impide pasarle un string en Python.

Aún hablando de legibilidad, también puedes agregar comentarios y cambiar el nombre de las variables en este punto, si lo consideras necesario.

La práctica de desacoplar su código sirve para mejorar el mantenimiento y la reutilización. La creación de funciones que hacen muchas labores puede dificultar el mantenimiento de ese código, la adición de una nueva característica o la corrección de un error. Asimismo, debemos considerar la reutilización, ya que tener muchas tareas significa decir que una pieza de código completa solo se usará en un lugar específico, cuando en la práctica resulta que esta función podría descomponerse en varias otras que podrían reutilizarse en otras partes del código.

Otro punto cuando refactorizamos es el rendimiento (performance) del código. Al principio, es posible que hayas escrito un ciclo de repetición usando un for o while, pero ¿no podría ese mismo código funcionar mejor usando una list comprehension o un generator? Estos son puntos a tener en cuenta en términos de rendimiento, que considera no solo el tiempo de ejecución, sino también el uso de recursos, como memoria, procesador, GPU, etc.


Pensando en nuevos usos del sistema

Como dijimos al principio, una buena práctica es asegurarse de que los tipos de datos que se pasan a una función sean correctos, aún usando nuestra calculadora como ejemplo, lo que esperamos que se pase a las funciones son números, ya sean de tipo int o float. Sin embargo, puede suceder que el usuario termine pasando un string, ya sea por error porque su interfaz también permite escribir cadenas en la calculadora y el usuario terminó escribiendo mal sin querer, o porque un hacker está explorando deliberadamente formas de romper su sistema.

Este es un punto sumamente importante y que se debe pensar con cuidado, ya que en el caso de una aplicación que recibe datos del frontend y que serán procesados ​​en el backend, puede haber fallas de seguridad graves como una SQL Injection, donde un usuario malicioso puede enviar comandos a un sistema mal diseñado para obtener acceso a la base de datos, a información confidencial o insertar o eliminar información.

Una forma de verificar si el tipo de dato está correto es generar una prueba utilizando assertRaises de la siguiente forma:

Al añadir esa prueba y ejecutar una vez más el comando en la terminal, va a aparecer un error informando que el error sí esperado, TypeError, no ocurrió. Esto sucede porque en Python es posible utilizar el operador de suma para juntar dos strings, pero pensando en la funcionalidad de nuestro sistema, eso no tiene sentido, ya que una calculadora convencional no necesita agregar textos, por lo que debe marcar el error.

Para que la prueba sea exitosa, podemos agregar una verificación para el tipo de datos que se pasan al principio dentro del archivo de código de la calculadora, por ejemplo:


Herramientas de medición de la cobertura de las pruebas

Existen herramientas que miden la cobertura de las pruebas, útiles para identificar partes del código que no están bajo pruebas o para ayudarnos a generar pruebas más eficientes y mejorar así la calidad del código. Los ejemplos de bibliotecas incluyen: coverage.py, nose-cov, pytest-cov, codecov, etc.

Conclusão

Test-Driven Development (TDD) es una metodología que permite a los desarrolladores escribir código más eficiente y menos propenso a errores. Consiste en escribir códigos de prueba antes de escribir el código real, lo que permite que los desarrolladores tengan un ciclo de desarrollo más ágil.

Las principales ventajas de usar TDD son la capacidad de saber con anticipación lo que debe hacer el código, una reducción del número de bugs y facilidad de mantenimiento. Además, pueden usarse diferentes tipos de prubas para garantizar la funcionalidad de pequeñas unidades de código, desempeño y seguridad. Así como el módulo nativo de python unittest que utilizamos acá, también existen otros como nose, pytest, doctest y mock.

Para comenzar a usar TDD, es fundamental comprender el problema que se debe resolver y definir el contexto, las funcionalidades y las reglas del sistema. El uso de herramientas de cobertura de prueba puede ayudar a identificar partes del código que no se están probando y mejorar la calidad general del código.

¡Saludos!

💡
Las opiniones y comentarios emitidos en este artículo son propiedad única de su autor y no necesariamente representan el punto de vista de Listopro.

Listopro Community da la bienvenida a todas las razas, etnias, nacionalidades, credos, géneros, orientaciones, puntos de vista e ideologías, siempre y cuando promuevan la diversidad, la equidad, la inclusión y el crecimiento profesional de los profesionales en tecnología.