Guía Completa de Python unittest: Conceptos Básicos, Ejemplos y Mejores Prácticas

1. ¿Qué es Python unittest?

unittest es un framework de pruebas unitarias incluido en la biblioteca estándar de Python, y es una herramienta esencial para garantizar la calidad del código. Permite a los desarrolladores probar cada parte del código de forma individual y detectar errores en una etapa temprana. Además, ayuda a confirmar durante el desarrollo continuo que los cambios en el código no rompan las funciones existentes.

La importancia de las pruebas unitarias

A medida que el código se vuelve más complejo, resulta más difícil comprobar si las distintas partes funcionan correctamente en conjunto. Al implementar pruebas unitarias, es más sencillo evitar errores inesperados derivados de pequeños cambios y mantener la estabilidad general del programa.

2. Uso básico de unittest

La base de unittest consiste en crear una clase que herede de unittest.TestCase y definir dentro de ella métodos de prueba. Dentro de los métodos de prueba, se utilizan métodos de aserción como assertEqual() para comparar el resultado esperado con el resultado real.

Ejemplo básico de prueba

El siguiente código es un ejemplo sencillo para probar la función add(a, b):
import unittest

# Código bajo prueba
def add(a, b):
    return a + b

# Clase de prueba
class TestAddFunction(unittest.TestCase):

    def test_add_integers(self):
        result = add(2, 3)
        self.assertEqual(result, 5)

if __name__ == '__main__':
    unittest.main()
En este código, se prueba si la función add() funciona correctamente. El método assertEqual() verifica que el valor esperado y el resultado real sean iguales. De este modo, se puede confirmar que la función funciona correctamente en múltiples casos.

Extensión de las pruebas

Se pueden utilizar varios métodos de prueba para comprobar el comportamiento de la función con diferentes entradas. Por ejemplo, es posible probar números de punto flotante o concatenación de cadenas.
def test_add_floats(self):
    result = add(2.5, 3.5)
    self.assertAlmostEqual(result, 6.0, places=2)

def test_add_strings(self):
    result = add("Hello, ", "World!")
    self.assertEqual(result, "Hello, World!")
De esta forma, al probar la función con diferentes tipos de datos, se puede confirmar que funciona correctamente en distintas situaciones.
RUNTEQ(ランテック)|超実戦型エンジニア育成スクール

3. Uso de setUp() y tearDown()

Para ejecutar automáticamente procesos específicos antes y después de cada prueba, se utilizan los métodos setUp() y tearDown(). Esto permite preparar el entorno antes de cada prueba y limpiar después de su ejecución.

Ejemplo de setUp()

El método setUp() se ejecuta siempre antes de cada método de prueba, y permite reunir procesos comunes de inicialización.
def setUp(self):
    self.temp_value = 42

Ejemplo de tearDown()

El método tearDown() se ejecuta después de cada método de prueba y se utiliza para liberar recursos o realizar tareas de limpieza, como cerrar conexiones a bases de datos o eliminar archivos temporales.
def tearDown(self):
    self.temp_value = None
De esta manera, se reduce la redundancia del código de prueba y se mantiene un código más limpio.

4. Pruebas de dependencias con mocks

Cuando el código bajo prueba depende de recursos externos (bases de datos, APIs, etc.), se puede reemplazar esa dependencia con un mock para mejorar la velocidad de ejecución y realizar pruebas más predecibles. Esto se logra fácilmente con el módulo unittest.mock de Python.

Ejemplo de mock

En el siguiente código, se reemplaza con un mock una función costosa llamada time_consuming_function():
from unittest.mock import patch

class TestAddFunction(unittest.TestCase):

    @patch('my_module.time_consuming_function')
    def test_add_with_mock(self, mock_func):
        mock_func.return_value = 0
        result = add(2, 3)
        self.assertEqual(result, 5)
En este ejemplo, la prueba se ejecuta sin llamar a time_consuming_function, lo que reduce el tiempo de ejecución y proporciona resultados confiables.
RUNTEQ(ランテック)|超実戦型エンジニア育成スクール

5. Manejo de excepciones y aserciones personalizadas

Con unittest también es posible probar el manejo de excepciones. Por ejemplo, para comprobar que una excepción ocurre en una situación específica se utiliza assertRaises().

Prueba de excepciones

En el siguiente ejemplo, se verifica que se produzca un ZeroDivisionError:
def test_divide_by_zero(self):
    with self.assertRaises(ZeroDivisionError):
        divide(1, 0)
Este código prueba que al ejecutar divide(1, 0) se genera la excepción ZeroDivisionError.

Creación de aserciones personalizadas

En casos donde las aserciones estándar no son suficientes, se pueden crear métodos personalizados:
def assertIsPositive(self, value):
    self.assertTrue(value > 0, f'{value} is not positive')
Con aserciones personalizadas, se pueden cubrir escenarios de prueba más específicos.

6. Funcionalidad de descubrimiento de pruebas en unittest

La función de descubrimiento de pruebas de unittest permite encontrar y ejecutar automáticamente todos los archivos de prueba dentro de un proyecto, lo cual es especialmente útil en proyectos de gran escala.

Cómo usar el descubrimiento de pruebas

Para ejecutar el descubrimiento de pruebas, se utiliza el siguiente comando:
python -m unittest discover
Esto ejecutará todos los archivos test_*.py dentro del directorio especificado. También se pueden indicar archivos o directorios con opciones:
python -m unittest discover -s tests -p "test_*.py"
Esta funcionalidad elimina la necesidad de especificar manualmente cada archivo de prueba y permite gestionar pruebas de manera más eficiente en proyectos grandes.

7. Consejos para mejorar el rendimiento con unittest

Si las pruebas son lentas, la eficiencia del desarrollo se reduce. A continuación, algunos consejos para optimizar las pruebas con unittest:

Optimizar operaciones de archivo I/O

Las pruebas que requieren lectura/escritura en archivos pueden acelerarse usando objetos en memoria. Con StringIO se pueden simular archivos en memoria y evitar I/O en disco.
from io import StringIO

class TestFileOperations(unittest.TestCase):

    def test_write_to_memory(self):
        output = StringIO()
        output.write('Hello, World!')
        self.assertEqual(output.getvalue(), 'Hello, World!')
De esta manera, incluso pruebas que requieren acceso a archivos pueden ejecutarse más rápido.

Uso de mocks

Para minimizar el acceso a recursos externos, se pueden usar mocks, lo que acelera las pruebas evitando demoras en red o bases de datos. En el siguiente ejemplo, una llamada a API se reemplaza con un mock:
from unittest.mock import MagicMock

class TestApiCall(unittest.TestCase):

    def test_api_response(self):
        mock_api = MagicMock(return_value={'status': 'success'})
        response = mock_api()
        self.assertEqual(response['status'], 'success')
Así, se puede probar la funcionalidad sin depender de recursos externos, construyendo un entorno de pruebas más rápido y estable.

8. Conclusión y próximos pasos

En este artículo hemos explicado desde lo básico del uso de unittest en Python, pasando por setUp/tearDown, el uso de mocks para dependencias, hasta técnicas para mejorar el rendimiento de las pruebas.

Resumen de puntos clave

  • Uso básico: Crear clases que hereden de unittest.TestCase y utilizar métodos de aserción.
  • setUp() / tearDown(): Facilitan la reutilización del código y mejoran la legibilidad al centralizar procesos comunes.
  • Uso de mocks: Permiten probar sin depender de recursos externos y aceleran las pruebas.
  • Descubrimiento de pruebas: Simplifica la gestión de pruebas en proyectos grandes.
  • Técnicas de rendimiento: Procesamiento en memoria y mocks para reducir tiempos de ejecución.

Próximos pasos

Una vez que domines lo básico de unittest, prueba métodos más avanzados como pruebas parametrizadas o el uso de herramientas de cobertura para analizar el alcance de tus pruebas. También puedes explorar frameworks alternativos como pytest, ampliando así tus opciones según las necesidades del proyecto. Las pruebas son una parte crucial del desarrollo. Adóptalas de manera activa para detectar errores de forma temprana y mantener la calidad del código.