Tests en Python. unittest¶
unittest es un módulo integrado en la biblioteca estándar de Python que proporciona un marco de trabajo para realizar pruebas unitarias (unit testing). Su objetivo principal es ayudar a los desarrolladores a verificar que partes individuales de su código (como funciones, métodos o clases) funcionen correctamente de manera aislada.
Pruebas unitarias¶
Antes de empezar, una breve introducción:
- Las pruebas unitarias (unit tests): Son pequeñas pruebas automáticas que verifican si una parte específica de tu código funciona como esperas. Por ejemplo, si tienes una función que suma dos números, una prueba unitaria comprobaría si
suma(2, 3)realmente devuelve5. - Por qué usarlas: Ayudan a detectar errores de forma temprana, aseguran que cambios futuros no rompan el código existente, y facilitan el mantenimiento.
- cómo usarlo en proyectos: Para proyectos con estructura es modular como los que estamos desarrollando probaremos partes aisladas, como clases o funciones sin ejecutar todo el programa.
Empezaremos con tests unitarios sobre clases del dominio, porque:
- Contienen reglas de negocio (validaciones, cálculos).
- No dependen de consola, archivos o datos externos.
- Son fáciles de probar de forma aislada.
Unittest: estructura mínima¶
Un test con unittest suele tener:
- Una clase que hereda de
unittest.TestCase - Métodos cuyo nombre empieza por
test_ - Asserts:
assertEqual,assertRaises, etc.
Plantilla:
import unittest
class TestAlgo(unittest.TestCase):
def test_algo(self):
self.assertEqual(2 + 3, 5)
if __name__ == "__main__":
unittest.main()
Preparar el proyecto para testear con unittest¶
Vamos a ver de forma guiada, paso a paso, cómo hacer los tests utilizando como referencia el proyecto de la máquina expendedora. Empezaremos creando tests para la clase Item contenida en el fichero domain/item.py
class Item:
"""Producto base con validaciones de codigo, nombre, precio y stock."""
def __init__(self, codigo, nombre, precio, cantidad):
"""Inicializa un producto y valida sus datos."""
self.codigo = codigo
self.nombre = nombre
self.precio = precio
self.cantidad = cantidad
@property
def codigo(self):
return self._codigo
@codigo.setter
def codigo(self, valor):
"""Valida el codigo con formato letra + numero."""
# Regla de negocio: el codigo identifica de forma unica un producto.
# Normalizamos (strip + upper) para que " a1 " y "A1" representen el mismo codigo,
# evitando duplicados por formato.
texto = (valor or "").strip().upper()
if len(texto) < 2:
raise ValueError("Codigo invalido.")
if not texto[0].isalpha() or not texto[1:].isdigit():
raise ValueError("Codigo invalido.")
self._codigo = texto
@property
def nombre(self):
return self._nombre
@nombre.setter
def nombre(self, valor):
"""Valida nombre no vacio y sin espacios laterales."""
texto = valor or ""
if not texto.strip():
raise ValueError("El nombre no puede estar vacio.")
if texto != texto.strip():
raise ValueError("El nombre no puede tener espacios laterales.")
self._nombre = texto
@property
def precio(self):
return self._precio
@precio.setter
def precio(self, valor):
"""Valida precio mayor que 0."""
# Aceptamos entradas como str (por ejemplo desde consola) y las normalizamos a float.
# Nota: para dinero, en proyectos reales se prefiere usar céntimos (int) o Decimal
# para evitar problemas de redondeo con float. Aquí se usa float por simplicidad.
try:
precio = float(valor)
except (TypeError, ValueError):
raise ValueError("El precio debe ser mayor que 0.")
if precio <= 0:
raise ValueError("El precio debe ser mayor que 0.")
self._precio = precio
@property
def cantidad(self):
return self._cantidad
@cantidad.setter
def cantidad(self, valor):
"""Valida cantidad entera mayor o igual que 0."""
# Regla de negocio: el stock se maneja como unidades enteras (no se permiten fracciones).
if not isinstance(valor, int):
raise ValueError("La cantidad debe ser un entero >= 0.")
if valor < 0:
raise ValueError("La cantidad debe ser un entero >= 0.")
self._cantidad = valor
def precio_final(self):
"""Devuelve el precio base del producto."""
return self._precio
def mostrar_producto(self):
"""Devuelve un producto en formato para mostrarlo en consola."""
precio_base = self.precio
precio_final = self.precio_final()
porcentaje_descuento = 0.0
return (self.codigo, self.nombre, precio_base, precio_final, self.cantidad, porcentaje_descuento)
Paso 1. Crear carpeta de tests¶
En la raíz del proyecto crea los siguientes archivos y carpetas:
El fichero __init__.py convierte la subcarpeta tests en un paquete de Python, lo que facilita los imports. Lo dejaremos vacio.
Paso 2. El primer test¶
Editamos el fichero expendedora/tests/test_item.py y le añadimos:
import unittest
from expendedora.domain.item import Item
class TestItem(unittest.TestCase):
def test_crear_item_normaliza_codigo(self):
item = Item(" a1 ", "Agua", 1.5, 10)
self.assertEqual(item.codigo, "A1")
if __name__ == "__main__":
unittest.main()
Explicación del código:
import unittest: Carga la herramienta en memoria..from expendedora.domain.item import Item: Importa la clase a probar. Ponemos esta ruta porque los tests los vamos a ejecutar desde la carpeta padre deexpendedoratal y como hemos hecho también para probar la aplicación.class TestItem(unittest.TestCase): Creamos una clase de pruebas. Debe heredar deunittest.TestCasey su nombre debe empezar porTestdef test_xxx: Cada prueba es un método. Si falla, unittest lo reporta.self.assertEqual(a, b): Verifica sia == b. Si no, la prueba falla.- Otros asserts comunes:
assertTrue(x),assertFalse(x), `assertRaises(Error, func) (para excepciones).
Paso 3. Ejecutar los tests¶
Desde la carpeta padre de expendedora/:
Para ejecutar todos los test:
Al ejecutar el comando anterior:
- Python busca archivos de pruebas en el directorio actual (donde estás ejecutando el comando) y en sus subdirectorios.
- El patrón de búsqueda predeterminado es
test*.py(archivos que comienzan con "test" y terminan en ".py". Con lo que encontrará enexpendedora/testsel archivotest_item.py - Dentro de estos archivos, busca clases que hereden de
unittest.TestCasey métodos que comiencen contest_(comodef test_crear_item_normaliza_codigo(self)). - Si encuentra pruebas válidas, las ejecuta automáticamente y muestra un reporte en la consola:
- Para cada prueba que pasa: Un punto (
.) o "ok". - Para fallos: Una "F" (failure) con detalles del error.
- Para errores inesperados: Una "E" (error).
- Al final, un resumen como: "Ran X tests in Ys" (Ejecutó X pruebas en Y segundos), seguido de "OK" si todas pasan, o detalles de fallos.
- Para cada prueba que pasa: Un punto (
Obtendremos algo como:
python3 -m unittest
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
Si queremos obtener más detalles añadimos el modificador -v:
Diseñando tests¶
Para decidir qué tests crear, conviene “leer” la clase con mentalidad de contrato: qué acepta, qué promete y cómo puede fallar. Con Item se ve muy claro porque está llena de validaciones y de un par de métodos que devuelven resultados.
Responsabilidades y “contratos” públicos¶
En Item lo público es:
- Constructor:
Item(codigo, nombre, precio, cantidad) - Propiedades:
codigo,nombre,precio,cantidad(con validación en setters) - Métodos:
precio_final(),mostrar_producto()
Cada uno es un candidato natural a tests. Si es público y se usa desde otras capas, interesa testearlo.
1) Localiza reglas de negocio (validaciones) y conviértelas en tests¶
En Item las reglas están en los setters. Por cada regla, tenemos que pensar en:
- Caso válido (pasarán los tests)
- Casos inválidos (fallan con
ValueError) - Normalizaciones (se transforma el dato)
Paso 4. Tests para codigo¶
El setter es el siguiente:
@codigo.setter
def codigo(self, valor):
"""Valida el codigo con formato letra + numero."""
# Regla de negocio: el codigo identifica de forma unica un producto.
# Normalizamos (strip + upper) para que " a1 " y "A1" representen el mismo codigo,
# evitando duplicados por formato.
texto = (valor or "").strip().upper()
if len(texto) < 2:
raise ValueError("Codigo invalido.")
if not texto[0].isalpha() or not texto[1:].isdigit():
raise ValueError("Codigo invalido.")
self._codigo = texto
- Se normaliza su valor con
strip().upper() - Debe tener longitud mínima 2
- Primer carácter letra, resto dígitos
Tests a crear (mínimos):
- Válido con normalización:
" a1 "→"A1" - Inválido por demasiado corto:
"A" - Inválido por no empezar con letra:
"1A" - Inválido por no tener números después:
"AA"o"A-"
Lo importante: aquí no estamos inventando tests, estamos “traduciendo” las condiciones if a casos concretos.
Añadimos a la clase TestItem los siguientes métodos:
def test_codigo_normaliza_strip_y_upper(self):
item = Item(" a1 ", "Agua", 1.5, 10)
self.assertEqual(item.codigo, "A1")
def test_codigo_invalido_por_formato_lanza_valueerror(self):
with self.assertRaises(ValueError):
Item("A", "Agua", 1.5, 10) # demasiado corto
with self.assertRaises(ValueError):
Item("1A", "Agua", 1.5, 10) # no empieza por letra
with self.assertRaises(ValueError):
Item("AA", "Agua", 1.5, 10) # no hay número tras la letra
with self.assertRaises(ValueError):
Item("A*", "Agua", 1.5, 10) # parte numérica no es dígito
- El método
assertRaisescomprueba si el caso de prueba lanza una excepción. Como parámetro se le pasa el tipo de excepción que se debería producir, en este casoValueError withes una estructura de contexto en Python que aquí utilizamos para capturar excepciones. La idea es que al crear el objetoItemcon los argumentos que le hemos pasado se debe lanzar una excepción.- Si la excepción no se lanza,
unittestreportará un fallo como "AssertionError: ValueError not raised".
Paso 5. Tests para nombre¶
El setter es el siguiente:
@nombre.setter
def nombre(self, valor):
"""Valida nombre no vacio y sin espacios laterales."""
texto = valor or ""
if not texto.strip():
raise ValueError("El nombre no puede estar vacio.")
if texto != texto.strip():
raise ValueError("El nombre no puede tener espacios laterales.")
self._nombre = texto
Las reglas que observamos son:
- No puede ser vacío ni solo espacios
- No puede tener espacios al inicio o al final
Tests:
- Válido:
"Agua" - Inválido:
""," " - Inválido:
" Agua"y"Agua "
Añadimos a la clase TestItem los siguientes métodos:
def test_nombre_vacio_lanza_valueerror(self):
with self.assertRaises(ValueError):
Item("A1", "", 1.5, 10)
with self.assertRaises(ValueError):
Item("A1", " ", 1.5, 10)
def test_nombre_con_espacios_laterales_lanza_valueerror(self):
with self.assertRaises(ValueError):
Item("A1", " Agua", 1.5, 10)
with self.assertRaises(ValueError):
Item("A1", "Agua ", 1.5, 10)
Paso 6. Tests para precio¶
@precio.setter
def precio(self, valor):
"""Valida precio mayor que 0."""
# Aceptamos entradas como str (por ejemplo desde consola) y las normalizamos a float.
# Nota: para dinero, en proyectos reales se prefiere usar céntimos (int) o Decimal
# para evitar problemas de redondeo con float. Aquí se usa float por simplicidad.
try:
precio = float(valor)
except (TypeError, ValueError):
raise ValueError("El precio debe ser mayor que 0.")
if precio <= 0:
raise ValueError("El precio debe ser mayor que 0.")
self._precio = precio
Reglas:
- Acepta strings numéricos
- Si no se puede convertir o es <= 0:
ValueError
Tests:
- Válido con string:
"2.00"→2.0 - Inválido:
"no-numero" - Inválido:
0,-1
def test_precio_string_numerico_se_convierte_a_float(self):
item = Item("A1", "Agua", "2.00", 10)
self.assertEqual(item.precio, 2.0)
self.assertIsInstance(item.precio, float)
def test_precio_no_valido_lanza_valueerror(self):
with self.assertRaises(ValueError):
Item("A1", "Agua", 0, 10)
with self.assertRaises(ValueError):
Item("A1", "Agua", -1, 10)
with self.assertRaises(ValueError):
Item("A1", "Agua", "no-numero", 10)
with self.assertRaises(ValueError):
Item("A1", "Agua", None, 10)
Paso 7. Tests para cantidad¶
@cantidad.setter
def cantidad(self, valor):
"""Valida cantidad entera mayor o igual que 0."""
# Regla de negocio: el stock se maneja como unidades enteras (no se permiten fracciones).
if not isinstance(valor, int):
raise ValueError("La cantidad debe ser un entero >= 0.")
if valor < 0:
raise ValueError("La cantidad debe ser un entero >= 0.")
self._cantidad = valor
Reglas:
- Debe ser
int - No negativa
Tests:
- Válido:
0(caso límite) - Válido:
10 - Inválido:
-1 - Inválido:
2.5(no int)
def test_cantidad_debe_ser_entero_y_no_negativa(self):
# válidos
item0 = Item("A1", "Agua", 1.0, 0)
self.assertEqual(item0.cantidad, 0)
item10 = Item("A1", "Agua", 1.0, 10)
self.assertEqual(item10.cantidad, 10)
# inválidos
with self.assertRaises(ValueError):
Item("A1", "Agua", 1.0, -1)
with self.assertRaises(ValueError):
Item("A1", "Agua", 1.0, 2.5)
with self.assertRaises(ValueError):
Item("A1", "Agua", 1.0, "10")
2) Busca comportamiento (métodos) y define “entradas → salidas”¶
Los métodos son más directos: dado un estado, devuelven algo.
Paso 8. Tests para precio_final()¶
En Item devuelve el precio base (sin descuentos).
Test:
- Crear
Item(..., precio=2.0, ...)y comprobarprecio_final() == 2.0
def test_precio_final_devuelve_precio_base(self):
item = Item("A1", "Agua", 2.0, 10)
self.assertEqual(item.precio_final(), 2.0)
Paso 9. Tests mostrar_producto()¶
def mostrar_producto(self):
"""Devuelve un producto en formato t-upla para mostrarlo en consola."""
precio_base = self.precio
precio_final = self.precio_final()
porcentaje_descuento = 0.0
return (self.codigo, self.nombre, precio_base, precio_final, self.cantidad, porcentaje_descuento)
Devuelve una tupla con:
(codigo, nombre, precio_base, precio_final, cantidad, porcentaje_descuento)
En Item el descuento siempre es 0.0.
Tests:
- Con un item conocido, la tupla completa debe coincidir con lo esperado
- Esto protege el “contrato” con la capa de presentación.
def test_mostrar_producto_devuelve_tupla_esperada(self):
item = Item("A1", "Agua", 2.0, 10)
esperado = ("A1", "Agua", 2.0, 2.0, 10, 0.0)
self.assertEqual(item.mostrar_producto(), esperado)
3) Cubre casos límite y equivalencias¶
A la hora de diseñar test, además de buscar casos “válidos/inválidos”, también es conveniente comprobar valores límite. En Item hay varios:
preciojusto por encima de 0 (ej:0.01)cantidadigual a 0nombrecon espacios internos sí es válido ("Agua con gas"), lo prohibido son los espacios laterales
No hace falta testear infinitas combinaciones: elige 1–2 representativas por regla.
4) Evita testear implementación interna¶
En Item existen atributos privados (_codigo, _precio…), pero los tests deberían usar solo la interfaz pública. Lo mismo pasaría si la clase incluyera métodos protegidos o privados.
Cobertura de tests¶
La cobertura de tests es una medida que indica qué partes del código se ejecutan cuando lanzamos nuestra suite de tests. Normalmente se expresa como un porcentaje.
- Si una clase tiene 100 líneas de código ejecutables
- Y los tests solo ejecutan 70
- La cobertura será del 70%
La cobertura no mide la calidad de los tests, solo mide qué líneas se han ejecutado. Un test puede ejecutar una línea sin comprobar nada importante. Por eso la cobertura es una herramienta de apoyo, no un objetivo absoluto.
La cobertura nos permite:
- Ver si todas las validaciones están siendo probadas.
- Detectar ramas que nunca se ejecutan (por ejemplo, errores que no estamos comprobando).
- Descubrir partes del dominio sin tests.
Paso 10. Instalar la herramienta de cobertura¶
El paquete coverage no se incluye en la biblioteca estándar de Python, por lo que tendremos que instalarlo. Para ello seguimos los siguientes pasos.
Dentro de un terminal, accedemos a la carpeta expendedora, si no lo hemos hecho previamente creamos un entorno virtual y lo activamos.
python -m venv .venv
source .venv/Scripts/activate # En un terminal de GitBash
source .venv/Scripts/activate.bat # En un terminal de CMD
source .venv/Scripts/Activate.ps1 # En un terminal de GitBash
Actualizamos pip e instalamos coverage
Paso 11. Medir cobertura de todo el proyecto¶
Supongamos que estamos dentro de la carpeta padre de expendedora/ y ya tenemos tests como tests/test_item.py.
Ejecutamos los tests con cobertura:
Esto:
- Ejecuta todos los tests.
- Registra qué líneas del código se han ejecutado.
Después mostramos el informe:
Salida típica:
Name Stmts Miss Cover
------------------------------------------------
domain/item.py 90 5 94%
domain/maquina.py 60 60 0%
...
------------------------------------------------
TOTAL 200 65 67%
Columnas:
- Stmts: líneas ejecutables.
- Miss: líneas no ejecutadas.
- Cover: porcentaje cubierto.
Aquí veríamos que item.py tiene alta cobertura, pero maquina.py no tiene ningún test aún.
Paso 12. Generar informe detallado (HTML)¶
Para ver exactamente qué líneas no están cubiertas:
Se crea una carpeta htmlcov/.
Abre htmlcov/index.html en el navegador. Podremos navegar por los ficheros del proyecto y ver la cobertura de los tests ejecutados.
Las líneas:
- En verde → ejecutadas por los tests.
- En rojo → no ejecutadas.
Si hacemos modificaciones en archivos o tests antes de generar los reportes con
coverage reportocoverage htmlhemos de volver a ejecutar los tests concoverage run -m unittest
Paso 13. Medir cobertura solo de una parte del proyecto¶
A veces queremos medir solo el dominio.
Podemos limitar la medición a un módulo concreto::
Esto solo analizará los archivos dentro de domain/.
Paso 14. Medir cobertura para un test concreto¶
Si queremos ejecutar solo los tests de Item:
Esto permite:
- Ver cuánto cubren únicamente esos tests.
- Comprobar si realmente estamos cubriendo toda la lógica de
Item.
Diseño de tests y coverage¶
Es tentador intentar llegar al 100% de cobertura siempre. Sin embargo:
- Que haya un 100% de cobertura no significa que haya un 100% de calidad en el proyecto.
- Se pueden escribir tests que ejecuten líneas, pero sin embargo dicho test no verifique el resultado, o lo que compruebe es trivial, etc.
- Es mejor cubrir bien las reglas de negocio importantes que perseguir el porcentaje.
Es importante entender que:
- La cobertura ayuda a detectar “zonas sin test”.
- Es una herramienta de diagnóstico.
- No sustituye al razonamiento sobre qué debe probarse.