Saltar a contenido

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 devuelve 5.
  • 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:

expendedora/
  tests/
    __init__.py
    test_item.py

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 de expendedora tal y como hemos hecho también para probar la aplicación.
  • class TestItem(unittest.TestCase): Creamos una clase de pruebas. Debe heredar de unittest.TestCase y su nombre debe empezar por Test
  • def test_xxx: Cada prueba es un método. Si falla, unittest lo reporta.
  • self.assertEqual(a, b): Verifica si a == 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:

python -m unittest

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á en expendedora/tests el archivo test_item.py
  • Dentro de estos archivos, busca clases que hereden de unittest.TestCase y métodos que comiencen con test_ (como def 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.

Obtendremos algo como:

python3 -m unittest
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK
Si, en lugar de ejecutar todos los tests, queremos ejecutar los de un archivo concreto:

python -m unittest expendedora.tests.test_item

Si queremos obtener más detalles añadimos el modificador -v:

python -m unittest -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
Las reglas que contiene son:

  • 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 assertRaises comprueba 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 caso ValueError
  • with es una estructura de contexto en Python que aquí utilizamos para capturar excepciones. La idea es que al crear el objeto Item con los argumentos que le hemos pasado se debe lanzar una excepción.
  • Si la excepción no se lanza, unittest reportará 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()

    def precio_final(self):
        """Devuelve el precio base del producto."""
        return self._precio

En Item devuelve el precio base (sin descuentos). Test:

  • Crear Item(..., precio=2.0, ...) y comprobar precio_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:

  • precio justo por encima de 0 (ej: 0.01)
  • cantidad igual a 0
  • nombre con 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

python -m pip install --upgrade pip
pip install 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:

coverage run -m unittest

Esto:

  • Ejecuta todos los tests.
  • Registra qué líneas del código se han ejecutado.

Después mostramos el informe:

coverage report

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:

coverage html

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 report o coverage html hemos de volver a ejecutar los tests con coverage 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::

coverage run --source=expendedora.domain -m unittest
coverage report

Esto solo analizará los archivos dentro de domain/.

Paso 14. Medir cobertura para un test concreto

Si queremos ejecutar solo los tests de Item:

coverage run -m unittest expendedora.tests.test_item
coverage report

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.