Ir al contenido principal

Pruebas unitarias con unittest

Introducción

Las pruebas unitarias son una práctica fundamental en el desarrollo de software profesional que consiste en verificar que partes individuales del código (unidades) funcionan correctamente. En Python, el módulo unittest forma parte de la biblioteca estándar y proporciona un marco completo para crear y ejecutar pruebas. Dominar las pruebas unitarias te permitirá crear código más robusto, facilitar el mantenimiento y refactorización, y detectar errores tempranamente. En este artículo, aprenderás a implementar pruebas unitarias eficaces utilizando el módulo unittest de Python.

El concepto de prueba unitaria

Una prueba unitaria verifica que un componente específico del código (normalmente una función o método) produce el resultado esperado para una entrada determinada. Estas pruebas son:

  • Automatizadas: Se ejecutan sin intervención manual
  • Independientes: No dependen de otras pruebas
  • Repetibles: Producen el mismo resultado cada vez que se ejecutan
  • Específicas: Comprueban una única funcionalidad

El módulo unittest

El módulo unittest está inspirado en JUnit (framework de pruebas para Java) y sigue un enfoque orientado a objetos para definir y ejecutar pruebas.

Componentes principales de unittest

  • TestCase: Clase base para crear pruebas
  • TestSuite: Colección de casos de prueba
  • TestRunner: Ejecutor de pruebas
  • Assertions: Métodos para verificar resultados

Creando tu primera prueba unitaria

Vamos a crear una función simple y luego escribir pruebas para ella:

# calculadora.py
def suma(a, b):
    return a + b

def resta(a, b):
    return a - b

def multiplica(a, b):
    return a * b

def divide(a, b):
    if b == 0:
        raise ValueError("No se puede dividir por cero")
    return a / b

Ahora, creamos un archivo para las pruebas:

# test_calculadora.py
import unittest
from calculadora import suma, resta, multiplica, divide

class TestCalculadora(unittest.TestCase):
    
    def test_suma(self):
        self.assertEqual(suma(3, 5), 8)
        self.assertEqual(suma(-1, 1), 0)
        self.assertEqual(suma(-1, -1), -2)
        
    def test_resta(self):
        self.assertEqual(resta(5, 3), 2)
        self.assertEqual(resta(1, 5), -4)
        self.assertEqual(resta(-1, -1), 0)
        
    def test_multiplica(self):
        self.assertEqual(multiplica(3, 5), 15)
        self.assertEqual(multiplica(-1, 5), -5)
        self.assertEqual(multiplica(-1, -1), 1)
        
    def test_divide(self):
        self.assertEqual(divide(6, 3), 2)
        self.assertEqual(divide(5, 2), 2.5)
        self.assertEqual(divide(-1, 1), -1)
        
        # Comprobamos que se lanza la excepción correcta
        with self.assertRaises(ValueError):
            divide(5, 0)
            
if __name__ == '__main__':
    unittest.main()

Ejecutando las pruebas

Puedes ejecutar las pruebas de varias formas:

  1. Desde la línea de comandos:

    python -m unittest test_calculadora.py
    
  2. Ejecutando el archivo directamente (gracias a la línea if __name__ == '__main__'):

    python test_calculadora.py
    
  3. Descubrimiento automático de pruebas:

    python -m unittest discover
    

Métodos de aserción

Las aserciones son el corazón de las pruebas unitarias. unittest proporciona varios métodos para verificar resultados:

# Igualdad
self.assertEqual(valor_actual, valor_esperado)
self.assertNotEqual(valor_actual, valor_no_esperado)

# Booleanos
self.assertTrue(expresion)
self.assertFalse(expresion)

# Identidad
self.assertIs(objeto_actual, objeto_esperado)
self.assertIsNot(objeto_actual, objeto_no_esperado)

# Nulos
self.assertIsNone(valor)
self.assertIsNotNone(valor)

# Membresía
self.assertIn(elemento, coleccion)
self.assertNotIn(elemento, coleccion)

# Tipos
self.assertIsInstance(objeto, clase)
self.assertNotIsInstance(objeto, clase)

# Excepciones
with self.assertRaises(ExcepcionEsperada):
    codigo_que_debe_lanzar_excepcion()

Organización de las pruebas

Métodos setUp y tearDown

Estos métodos se ejecutan antes y después de cada prueba, respectivamente:

import unittest
import tempfile
import os

class TestManejadorArchivos(unittest.TestCase):
    
    def setUp(self):
        # Se ejecuta antes de cada prueba
        self.archivo_temp = tempfile.NamedTemporaryFile(delete=False)
        self.nombre_archivo = self.archivo_temp.name
        self.archivo_temp.close()
        
    def tearDown(self):
        # Se ejecuta después de cada prueba
        os.unlink(self.nombre_archivo)
        
    def test_escribir_archivo(self):
        with open(self.nombre_archivo, 'w') as f:
            f.write('Hola mundo')
            
        with open(self.nombre_archivo, 'r') as f:
            contenido = f.read()
            self.assertEqual(contenido, 'Hola mundo')

setUpClass y tearDownClass

Estos métodos de clase se ejecutan una vez antes y después de todas las pruebas:

import unittest
import sqlite3

class TestBaseDatos(unittest.TestCase):
    
    @classmethod
    def setUpClass(cls):
        # Se ejecuta una vez antes de todas las pruebas
        cls.conexion = sqlite3.connect(':memory:')
        cls.cursor = cls.conexion.cursor()
        cls.cursor.execute('CREATE TABLE usuarios (id INTEGER, nombre TEXT)')
        
    @classmethod
    def tearDownClass(cls):
        # Se ejecuta una vez después de todas las pruebas
        cls.conexion.close()
        
    def test_insertar_usuario(self):
        self.cursor.execute('INSERT INTO usuarios VALUES (1, "Ana")')
        self.cursor.execute('SELECT * FROM usuarios WHERE id = 1')
        usuario = self.cursor.fetchone()
        self.assertEqual(usuario, (1, "Ana"))

Subclases de pruebas

Puedes organizar pruebas relacionadas en diferentes subclases:

class TestOperacionesBasicas(unittest.TestCase):
    # Pruebas para suma y resta
    
class TestOperacionesAvanzadas(unittest.TestCase):
    # Pruebas para multiplicación y división

Saltar pruebas

A veces necesitarás desactivar temporalmente una prueba:

import unittest

class TestCalculadora(unittest.TestCase):
    
    @unittest.skip("Demostrando cómo saltar una prueba")
    def test_suma(self):
        self.assertEqual(suma(3, 5), 8)
        
    @unittest.skipIf(sys.version_info.minor < 10, "Requiere Python 3.10+")
    def test_funcion_nueva(self):
        # Código que usa características de Python 3.10+
        pass

Mock objects

Para pruebas más avanzadas, especialmente cuando hay dependencias externas, puedes usar la biblioteca unittest.mock:

from unittest import TestCase, mock
import requests
from mi_aplicacion import obtener_datos_clima

class TestClima(TestCase):
    
    @mock.patch('mi_aplicacion.requests.get')
    def test_obtener_datos_clima(self, mock_get):
        # Configuramos el comportamiento del mock
        mock_response = mock.Mock()
        mock_response.json.return_value = {"temperatura": 25, "humedad": 60}
        mock_response.status_code = 200
        mock_get.return_value = mock_response
        
        # Ejecutamos la función que queremos probar
        resultado = obtener_datos_clima("Madrid")
        
        # Verificamos que la función llamó correctamente a requests.get
        mock_get.assert_called_once_with('https://api.clima.ejemplo/Madrid')
        
        # Verificamos el resultado
        self.assertEqual(resultado, {"temperatura": 25, "humedad": 60})

Buenas prácticas

  1. Nombres descriptivos: Usa nombres que indiquen claramente qué se está probando.
  2. Una aserción por prueba: Idealmente, cada prueba debería verificar un único comportamiento.
  3. Independencia: Cada prueba debe funcionar por sí sola.
  4. Cobertura de código: Intenta que tus pruebas cubran todas las ramas del código.
  5. Prueba casos límite: No solo el caso "feliz", sino también valores extremos y errores.
  6. Mantén las pruebas simples: Las pruebas complicadas pueden tener sus propios errores.

Resumen

Las pruebas unitarias con unittest son una herramienta poderosa para garantizar la calidad del código Python. Al crear pruebas automatizadas, puedes verificar que tu código funciona correctamente y detectar problemas rápidamente cuando realizas cambios. La estructura orientada a objetos de unittest proporciona una forma organizada y flexible de definir pruebas, desde las más simples hasta las más complejas. Implementar pruebas unitarias es una inversión que se traduce en código más robusto y mantenible a largo plazo. En el próximo artículo, aprenderemos sobre la documentación de código con docstrings, otro aspecto fundamental del desarrollo profesional en Python.