pruebas en Python ejercicios unittest caja negra TDD chuletario

Pruebas en Python — ejercicios para dominar unittest, caja negra y TDD

Las pruebas en Python ejercicios con solución cierran este bloque. Ya viste la teoría y practicaste con programas reales. Ahora toca resolver por tu cuenta. Tres ejercicios en tres niveles: caja negra, detección de errores en pruebas mal diseñadas y TDD para una clase completa. El hilo conductor es siempre el mismo objeto: una cuenta bancaria sencilla.

Como siempre: intenta resolverlo, usa la pista si llevas más de 10 minutos atascado, y compara con la solución comentada al final.


Pruebas en Python ejercicios — Nivel Básico

Ejercicio 1 — Diseñar pruebas de caja negra para validar un ingreso

Tienes esta especificación de una función. No ves el código, solo la descripción:

función: validar_ingreso(cantidad, saldo_actual)

Valida si se puede realizar un ingreso en una cuenta bancaria.

Parámetros:
    cantidad (float o int): importe a ingresar
    saldo_actual (float): saldo actual de la cuenta

Devuelve:
    True  si el ingreso es válido
    False si la cantidad es 0

Lanza:
    TypeError  si cantidad o saldo_actual no son numéricos
    ValueError si cantidad es negativa
    ValueError si saldo_actual es negativo
    ValueError si el saldo resultante superaría 100.000€ (límite de cuenta)

Restricciones:
    - cantidad debe ser > 0 para ser válida (0 devuelve False, negativo lanza error)
    - saldo_actual debe ser >= 0
    - saldo_actual + cantidad no puede superar 100.000

Tu tarea es:

  1. Identificar todas las clases de equivalencia válidas e inválidas
  2. Elegir los valores representativos de cada clase
  3. Identificar los valores límite de cada frontera
  4. Escribir el conjunto completo de pruebas con unittest

La salida esperada al ejecutar tus pruebas (cuando la implementación sea correcta):

test_ce_cantidad_cero ... ok
test_ce_cantidad_valida ... ok
test_ce_saldo_resultante_en_limite ... ok
test_ce_tipo_incorrecto_cantidad ... ok
test_ce_tipo_incorrecto_saldo ... ok
test_ce_valor_negativo_cantidad ... ok
test_ce_valor_negativo_saldo ... ok
test_ce_saldo_supera_limite ... ok
test_limite_cantidad_minima ... ok
test_limite_saldo_exactamente_cero ... ok
test_limite_saldo_resultado_exactamente_100000 ... ok
test_limite_saldo_resultado_supera_100000 ... ok

Ran 12 tests in 0.001s
OK

💡 Pista — el error más común en caja negra:

El error que más aparece en FP2 es diseñar pruebas que solo cubren el camino feliz, los casos donde todo va bien. Una buena batería de pruebas de caja negra tiene más pruebas de casos inválidos que de casos válidos, porque los errores casi siempre están en los extremos y en los casos que el programador no anticipó.

# MAL — solo prueba el caso normal
def test_ingreso(self):
    self.assertTrue(validar_ingreso(100, 500))

# BIEN — prueba todos los casos que pueden fallar
def test_cantidad_cero_devuelve_false(self):
    self.assertFalse(validar_ingreso(0, 500))

def test_cantidad_negativa_lanza_error(self):
    with self.assertRaises(ValueError):
        validar_ingreso(-50, 500)

def test_saldo_supera_limite(self):
    with self.assertRaises(ValueError):
        validar_ingreso(1, 100000)    # 100000 + 1 > 100000

Para los valores límite de esta función presta especial atención a las fronteras: cantidad=0 (devuelve False), cantidad=0.01 (válida), saldo_actual + cantidad = 100000 (válido), saldo_actual + cantidad = 100000.01 (error).


Pruebas en Python ejercicios — Nivel Interedio

Ejercicio 2 — Encuentra los errores en estas pruebas mal diseñadas

Un compañero ha escrito las pruebas para la clase CuentaBancaria pero ha cometido varios errores de diseño. Tu tarea es identificar exactamente qué está mal en cada prueba y reescribirla correctamente.

Esta es la especificación de la clase que se está probando:

class CuentaBancaria:
    """
    Cuenta bancaria con titular, saldo y límite de 100.000€.
    
    __init__(titular, saldo_inicial=0)
        titular (str): nombre del titular, mínimo 2 caracteres
        saldo_inicial (float): saldo inicial, debe ser >= 0
        Lanza ValueError si titular tiene menos de 2 caracteres
        Lanza ValueError si saldo_inicial es negativo
    
    ingresar(cantidad)
        Ingresa cantidad en la cuenta.
        Lanza ValueError si cantidad <= 0
        Lanza ValueError si el saldo superaría 100.000€
    
    retirar(cantidad)
        Retira cantidad de la cuenta.
        Lanza ValueError si cantidad <= 0
        Lanza ValueError si no hay saldo suficiente
    
    saldo (property): saldo actual
    titular (property): nombre del titular
    """

Y estas son las pruebas mal diseñadas de tu compañero — hay 6 errores en total:

import unittest

class TestCuentaBancaria(unittest.TestCase):

    # PRUEBA A
    def crear_cuenta(self):
        cuenta = CuentaBancaria("Sergio", 1000)
        self.assertEqual(cuenta.saldo, 1000)

    # PRUEBA B
    def test_ingreso_correcto(self):
        cuenta = CuentaBancaria("Sergio", 500)
        cuenta.ingresar(200)
        cuenta.ingresar(300)
        cuenta.ingresar(100)
        self.assertEqual(cuenta.saldo, 1100)

    # PRUEBA C
    def test_retirada_correcta(self):
        cuenta = CuentaBancaria("Sergio", 500)
        cuenta.retirar(200)
        self.assertEqual(cuenta.saldo, 200)    # ← ¿es correcto este valor?

    # PRUEBA D
    def test_titular_invalido(self):
        cuenta = CuentaBancaria("A", 0)
        self.assertFalse(cuenta)

    # PRUEBA E
    def test_saldo_insuficiente(self):
        cuenta = CuentaBancaria("Sergio", 100)
        cuenta.retirar(200)
        self.assertEqual(cuenta.saldo, -100)

    # PRUEBA F
    def test_comparar_saldos(self):
        cuenta1 = CuentaBancaria("Sergio", 500)
        cuenta2 = CuentaBancaria("María", 500)
        self.assertEqual(cuenta1.saldo == cuenta2.saldo, True)

if __name__ == "__main__":
    unittest.main()

Para cada prueba, identifica:

  • ¿Hay un error? Sí o no
  • ¿Qué tipo de error es? (nombre incorrecto, lógica incorrecta, assert incorrecto, etc.)
  • ¿Cómo la reescribirías?

💡 Pista — los errores más frecuentes en pruebas:

Hay tres categorías de errores que aparecen siempre en FP2:

# ERROR 1 — método sin prefijo test_ → no se ejecuta nunca
def crear_cuenta(self):    # ← unittest no lo ve
    ...
# CORRECCIÓN: def test_crear_cuenta(self):

# ERROR 2 — assert incorrecto para la situación
self.assertFalse(cuenta)           # prueba si cuenta es falsy, no si lanza error
self.assertEqual(resultado, True)  # usa assertTrue directamente
# CORRECCIÓN:
with self.assertRaises(ValueError): ...    # para excepciones
self.assertTrue(condicion)                  # para booleanos

# ERROR 3 — lógica incorrecta en el valor esperado
cuenta = CuentaBancaria("Sergio", 500)
cuenta.retirar(200)
self.assertEqual(cuenta.saldo, 200)    # 500 - 200 = 300, no 200
# CORRECCIÓN: self.assertEqual(cuenta.saldo, 300)

Pruebas en Python ejercicios — Desafío Final

Ejercicio 3 — TDD para la clase CuentaBancaria completa

Construye la clase CuentaBancaria completa usando TDD, primero la prueba, luego el código mínimo, luego refactorizas. Sigue el ciclo ROJO → VERDE → REFACTOR en cada iteración.

La clase debe tener exactamente lo que describe la especificación del Ejercicio 2, más:

  • __str__ que devuelva "Cuenta de {titular}: {saldo}€"
  • __repr__ que devuelva "CuentaBancaria('{titular}', {saldo})"
  • transferir(cantidad, cuenta_destino) que transfiera dinero de una cuenta a otra — lanza ValueError si no hay saldo suficiente o si la cantidad es inválida

El orden de las iteraciones TDD que debes seguir:

Iteración 1: Constructor con titular y saldo_inicial
Iteración 2: Validación del titular (mínimo 2 caracteres)
Iteración 3: Validación del saldo inicial (no negativo)
Iteración 4: Método ingresar — caso normal
Iteración 5: Método ingresar — validaciones (cantidad <= 0, límite 100.000€)
Iteración 6: Método retirar — caso normal
Iteración 7: Método retirar — validaciones (cantidad <= 0, saldo insuficiente)
Iteración 8: __str__ y __repr__
Iteración 9: transferir — caso normal y validaciones

La salida esperada al ejecutar todas las pruebas al final:

test_constructor_titular_y_saldo ... ok
test_constructor_saldo_por_defecto ... ok
test_titular_minimo_dos_caracteres ... ok
test_titular_exactamente_dos_caracteres ... ok
test_saldo_inicial_negativo ... ok
test_ingresar_cantidad_valida ... ok
test_ingresar_actualiza_saldo ... ok
test_ingresar_cantidad_cero ... ok
test_ingresar_cantidad_negativa ... ok
test_ingresar_supera_limite ... ok
test_ingresar_exactamente_limite ... ok
test_retirar_cantidad_valida ... ok
test_retirar_actualiza_saldo ... ok
test_retirar_cantidad_cero ... ok
test_retirar_cantidad_negativa ... ok
test_retirar_saldo_insuficiente ... ok
test_str ... ok
test_repr ... ok
test_transferir_caso_normal ... ok
test_transferir_actualiza_ambos_saldos ... ok
test_transferir_saldo_insuficiente ... ok
test_transferir_cantidad_invalida ... ok

----------------------------------------------------------------------
Ran 22 tests in 0.002s

OK

💡 Pistas:

  • No escribas el código antes que la prueba — si lo haces no estás haciendo TDD
  • Empieza siempre por el setUp para no repetir la creación de cuentas en cada prueba
  • Para transferir piensa en qué debe pasar si la transferencia falla a mitad: el saldo de origen no debe cambiar si la cantidad es inválida
  • El límite de 100.000€ aplica también al destino de una transferencia

Soluciones Comentadas

Solución Ejercicio 1:

import unittest

# Tabla de clases de equivalencia
# ─────────────────────────────────────────────────────────────
# CE válidas:
#   CV1: cantidad > 0 y saldo + cantidad <= 100000 → True
#   CV2: cantidad == 0                              → False
#
# CE inválidas:
#   CI1: cantidad < 0            → ValueError
#   CI2: saldo_actual < 0        → ValueError
#   CI3: saldo + cantidad > 100000 → ValueError
#   CI4: cantidad no numérico    → TypeError
#   CI5: saldo no numérico       → TypeError
#
# Valores límite:
#   cantidad = 0       → False (frontera CV1/CV2)
#   cantidad = 0.01    → True  (mínimo válido)
#   saldo + cantidad = 100000    → True  (límite exacto)
#   saldo + cantidad = 100000.01 → ValueError (justo por encima)
# ─────────────────────────────────────────────────────────────

class TestValidarIngreso(unittest.TestCase):

    # ── Clases de equivalencia válidas ──────────────────────────────────

    def test_ce_cantidad_valida(self):
        """CV1 — cantidad positiva con margen de saldo."""
        self.assertTrue(validar_ingreso(100, 500))

    def test_ce_cantidad_cero(self):
        """CV2 — cantidad cero devuelve False."""
        self.assertFalse(validar_ingreso(0, 500))

    def test_ce_saldo_resultante_en_limite(self):
        """CV1 — saldo resultante justo en el límite."""
        self.assertTrue(validar_ingreso(500, 99500))

    # ── Clases de equivalencia inválidas ────────────────────────────────

    def test_ce_valor_negativo_cantidad(self):
        """CI1 — cantidad negativa lanza ValueError."""
        with self.assertRaises(ValueError):
            validar_ingreso(-50, 500)

    def test_ce_valor_negativo_saldo(self):
        """CI2 — saldo_actual negativo lanza ValueError."""
        with self.assertRaises(ValueError):
            validar_ingreso(100, -500)

    def test_ce_saldo_supera_limite(self):
        """CI3 — saldo resultante supera 100.000€."""
        with self.assertRaises(ValueError):
            validar_ingreso(200, 99900)    # 99900 + 200 = 100100 > 100000

    def test_ce_tipo_incorrecto_cantidad(self):
        """CI4 — cantidad como cadena lanza TypeError."""
        with self.assertRaises(TypeError):
            validar_ingreso("cien", 500)

    def test_ce_tipo_incorrecto_saldo(self):
        """CI5 — saldo como None lanza TypeError."""
        with self.assertRaises(TypeError):
            validar_ingreso(100, None)

    # ── Valores límite ───────────────────────────────────────────────────

    def test_limite_cantidad_minima(self):
        """Mínimo positivo — 0.01€ es válido."""
        self.assertTrue(validar_ingreso(0.01, 0))

    def test_limite_saldo_exactamente_cero(self):
        """Saldo inicial exactamente 0 es válido."""
        self.assertTrue(validar_ingreso(100, 0))

    def test_limite_saldo_resultado_exactamente_100000(self):
        """Saldo resultante exactamente 100.000€ — válido."""
        self.assertTrue(validar_ingreso(100, 99900))

    def test_limite_saldo_resultado_supera_100000(self):
        """Saldo resultante 100.000,01€ — inválido."""
        with self.assertRaises(ValueError):
            validar_ingreso(0.01, 100000)


if __name__ == "__main__":
    unittest.main(verbosity=2)

Solución Ejercicio 2 — Los 6 errores:

# PRUEBA A — ERROR: el método no tiene prefijo test_
# unittest solo ejecuta métodos que empiecen por test_
# Este método nunca se ejecuta — la prueba no existe para unittest
def crear_cuenta(self):              # ← MAL
    ...
# CORRECCIÓN:
def test_crear_cuenta(self):         # ← BIEN
    cuenta = CuentaBancaria("Sergio", 1000)
    self.assertEqual(cuenta.saldo, 1000)


# PRUEBA B — ERROR: la prueba hace demasiadas cosas a la vez
# Si falla no sabes cuál de los tres ingresos causó el problema
# Además el valor esperado es correcto (500+200+300+100=1100) pero
# la prueba debería ser más pequeña y enfocada
def test_ingreso_correcto(self):     # ← MEJORABLE
    cuenta = CuentaBancaria("Sergio", 500)
    cuenta.ingresar(200)
    cuenta.ingresar(300)
    cuenta.ingresar(100)
    self.assertEqual(cuenta.saldo, 1100)
# CORRECCIÓN — una prueba, una responsabilidad:
def test_ingresar_actualiza_saldo(self):
    cuenta = CuentaBancaria("Sergio", 500)
    cuenta.ingresar(200)
    self.assertEqual(cuenta.saldo, 700)    # 500 + 200 = 700


# PRUEBA C — ERROR: valor esperado incorrecto
# 500 - 200 = 300, no 200
def test_retirada_correcta(self):
    cuenta = CuentaBancaria("Sergio", 500)
    cuenta.retirar(200)
    self.assertEqual(cuenta.saldo, 200)    # ← MAL: debería ser 300
# CORRECCIÓN:
    self.assertEqual(cuenta.saldo, 300)    # ← BIEN: 500 - 200 = 300


# PRUEBA D — ERROR: assert incorrecto para comprobar una excepción
# assertFalse comprueba si cuenta es falsy — no si lanzó un error
# CuentaBancaria("A", 0) debería lanzar ValueError pero la prueba
# no lo captura — si la clase lanza el error la prueba falla con
# un error inesperado, no con un fallo limpio
def test_titular_invalido(self):
    cuenta = CuentaBancaria("A", 0)
    self.assertFalse(cuenta)               # ← MAL
# CORRECCIÓN:
def test_titular_invalido(self):
    with self.assertRaises(ValueError):    # ← BIEN
        CuentaBancaria("A", 0)


# PRUEBA E — ERROR: la prueba espera un comportamiento incorrecto
# Si retirar() está bien implementado debe lanzar ValueError
# cuando no hay saldo suficiente — no devolver un saldo negativo
# La prueba valida un comportamiento que NO debería existir
def test_saldo_insuficiente(self):
    cuenta = CuentaBancaria("Sergio", 100)
    cuenta.retirar(200)
    self.assertEqual(cuenta.saldo, -100)   # ← MAL: valida comportamiento erróneo
# CORRECCIÓN:
def test_saldo_insuficiente(self):
    cuenta = CuentaBancaria("Sergio", 100)
    with self.assertRaises(ValueError):    # ← BIEN: espera el error correcto
        cuenta.retirar(200)


# PRUEBA F — ERROR: assert innecesariamente complejo
# assertEqual(a == b, True) es equivalente a assertTrue(a == b)
# pero ambos son peores que assertEqual(a, b) directamente
def test_comparar_saldos(self):
    cuenta1 = CuentaBancaria("Sergio", 500)
    cuenta2 = CuentaBancaria("María", 500)
    self.assertEqual(cuenta1.saldo == cuenta2.saldo, True)    # ← MAL
# CORRECCIÓN:
def test_comparar_saldos(self):
    cuenta1 = CuentaBancaria("Sergio", 500)
    cuenta2 = CuentaBancaria("María", 500)
    self.assertEqual(cuenta1.saldo, cuenta2.saldo)             # ← BIEN

Solución Ejercicio 3:

# cuenta_bancaria.py
class CuentaBancaria:
    LIMITE = 100_000.0

    def __init__(self, titular, saldo_inicial=0):
        if not isinstance(titular, str) or len(titular) < 2:
            raise ValueError("El titular debe tener al menos 2 caracteres.")
        if saldo_inicial < 0:
            raise ValueError("El saldo inicial no puede ser negativo.")
        self.__titular = titular
        self.__saldo = float(saldo_inicial)

    @property
    def titular(self):
        return self.__titular

    @property
    def saldo(self):
        return self.__saldo

    def ingresar(self, cantidad):
        if cantidad <= 0:
            raise ValueError("La cantidad a ingresar debe ser positiva.")
        if self.__saldo + cantidad > self.LIMITE:
            raise ValueError(f"El saldo superaría el límite de {self.LIMITE}€.")
        self.__saldo += cantidad

    def retirar(self, cantidad):
        if cantidad <= 0:
            raise ValueError("La cantidad a retirar debe ser positiva.")
        if cantidad > self.__saldo:
            raise ValueError("Saldo insuficiente.")
        self.__saldo -= cantidad

    def transferir(self, cantidad, cuenta_destino):
        if cantidad <= 0:
            raise ValueError("La cantidad a transferir debe ser positiva.")
        if cantidad > self.__saldo:
            raise ValueError("Saldo insuficiente para la transferencia.")
        if cuenta_destino.saldo + cantidad > self.LIMITE:
            raise ValueError("La transferencia superaría el límite de la cuenta destino.")
        self.__saldo -= cantidad
        cuenta_destino.__saldo += cantidad    # acceso al atributo privado de la misma clase

    def __str__(self):
        return f"Cuenta de {self.__titular}: {self.__saldo}€"

    def __repr__(self):
        return f"CuentaBancaria('{self.__titular}', {self.__saldo})"
# test_cuenta_bancaria.py
import unittest
from cuenta_bancaria import CuentaBancaria

class TestCuentaBancaria(unittest.TestCase):

    def setUp(self):
        """Cuentas reutilizables para varios tests."""
        self.cuenta = CuentaBancaria("Sergio", 1000)
        self.cuenta2 = CuentaBancaria("María", 500)

    # ── Constructor ──────────────────────────────────────────────────────

    def test_constructor_titular_y_saldo(self):
        self.assertEqual(self.cuenta.titular, "Sergio")
        self.assertEqual(self.cuenta.saldo, 1000)

    def test_constructor_saldo_por_defecto(self):
        cuenta = CuentaBancaria("Ana")
        self.assertEqual(cuenta.saldo, 0)

    # ── Validación titular ───────────────────────────────────────────────

    def test_titular_minimo_dos_caracteres(self):
        with self.assertRaises(ValueError):
            CuentaBancaria("A", 0)

    def test_titular_exactamente_dos_caracteres(self):
        cuenta = CuentaBancaria("Jo", 0)
        self.assertEqual(cuenta.titular, "Jo")

    # ── Validación saldo inicial ─────────────────────────────────────────

    def test_saldo_inicial_negativo(self):
        with self.assertRaises(ValueError):
            CuentaBancaria("Sergio", -100)

    # ── Ingresar ─────────────────────────────────────────────────────────

    def test_ingresar_cantidad_valida(self):
        self.cuenta.ingresar(500)
        self.assertEqual(self.cuenta.saldo, 1500)

    def test_ingresar_actualiza_saldo(self):
        saldo_antes = self.cuenta.saldo
        self.cuenta.ingresar(200)
        self.assertEqual(self.cuenta.saldo, saldo_antes + 200)

    def test_ingresar_cantidad_cero(self):
        with self.assertRaises(ValueError):
            self.cuenta.ingresar(0)

    def test_ingresar_cantidad_negativa(self):
        with self.assertRaises(ValueError):
            self.cuenta.ingresar(-100)

    def test_ingresar_supera_limite(self):
        with self.assertRaises(ValueError):
            self.cuenta.ingresar(99_001)    # 1000 + 99001 > 100000

    def test_ingresar_exactamente_limite(self):
        cuenta = CuentaBancaria("Test", 0)
        cuenta.ingresar(100_000)
        self.assertEqual(cuenta.saldo, 100_000)

    # ── Retirar ──────────────────────────────────────────────────────────

    def test_retirar_cantidad_valida(self):
        self.cuenta.retirar(300)
        self.assertEqual(self.cuenta.saldo, 700)

    def test_retirar_actualiza_saldo(self):
        saldo_antes = self.cuenta.saldo
        self.cuenta.retirar(200)
        self.assertEqual(self.cuenta.saldo, saldo_antes - 200)

    def test_retirar_cantidad_cero(self):
        with self.assertRaises(ValueError):
            self.cuenta.retirar(0)

    def test_retirar_cantidad_negativa(self):
        with self.assertRaises(ValueError):
            self.cuenta.retirar(-50)

    def test_retirar_saldo_insuficiente(self):
        with self.assertRaises(ValueError):
            self.cuenta.retirar(5000)

    # ── Representación ───────────────────────────────────────────────────

    def test_str(self):
        self.assertEqual(str(self.cuenta), "Cuenta de Sergio: 1000.0€")

    def test_repr(self):
        self.assertEqual(repr(self.cuenta), "CuentaBancaria('Sergio', 1000.0)")

    # ── Transferir ───────────────────────────────────────────────────────

    def test_transferir_caso_normal(self):
        self.cuenta.transferir(300, self.cuenta2)
        self.assertEqual(self.cuenta.saldo, 700)

    def test_transferir_actualiza_ambos_saldos(self):
        self.cuenta.transferir(300, self.cuenta2)
        self.assertEqual(self.cuenta2.saldo, 800)    # 500 + 300

    def test_transferir_saldo_insuficiente(self):
        with self.assertRaises(ValueError):
            self.cuenta.transferir(5000, self.cuenta2)

    def test_transferir_cantidad_invalida(self):
        with self.assertRaises(ValueError):
            self.cuenta.transferir(0, self.cuenta2)


if __name__ == "__main__":
    unittest.main(verbosity=2)

Chuletario — Pruebas en Python

# ============================================
# CHULETARIO — Pruebas en Python
# Sergio Learns · sergiolearns.com
# ============================================

# QUÉ ES UNA PRUEBA
# Entrada conocida + resultado esperado + comparación
# Objetivo: ENCONTRAR errores, no confirmar que funciona
# Una prueba que pasa no garantiza ausencia de errores —
# solo garantiza ausencia de errores para ese caso concreto

# ── CAJA NEGRA ───────────────────────────────────────────────

# CLASES DE EQUIVALENCIA
# Dividir entradas en grupos con el mismo comportamiento
# Probar un valor representativo por grupo
# Tipos:
#   Válidas   → entrada correcta → resultado esperado
#   Inválidas → entrada incorrecta → error esperado

# VALORES LÍMITE
# Probar los extremos de cada clase, no solo el centro
# Para cada frontera: mínimo, mínimo+1, máximo-1, máximo
# Ejemplo para rango [8, 15]:
#   Probar: 7, 8, 9 (frontera inferior) y 14, 15, 16 (frontera superior)

# PROCESO CAJA NEGRA
# 1. Leer solo la especificación (no el código)
# 2. Identificar clases válidas e inválidas
# 3. Elegir un valor representativo por clase
# 4. Añadir valores límite en cada frontera
# 5. Escribir una prueba por caso

# ── CAJA BLANCA ──────────────────────────────────────────────

# GRAFO DE FLUJO
# Nodo          → bloque de instrucciones consecutivas
# Nodo predicado → condición (if, while, for) — dos salidas
# Arco          → flecha entre nodos

# COMPLEJIDAD CICLOMÁTICA
# = número de nodos predicado + 1
# = número mínimo de pruebas para cubrir todos los arcos

# PROCESO CAJA BLANCA
# 1. Identificar nodos predicado (if, while, for)
# 2. Dibujar el grafo de flujo
# 3. Calcular: complejidad = predicados + 1
# 4. Encontrar caminos que cubran todos los arcos
# 5. Diseñar un caso de prueba por camino

# ── TDD ──────────────────────────────────────────────────────

# CICLO TDD
# 1. ROJO    → escribe la prueba ANTES del código (falla)
# 2. VERDE   → escribe el mínimo código para que pase
# 3. REFACTOR → mejora sin romper las pruebas
# 4. Repite

# ORDEN TDD PARA UNA CLASE
# 1. Constructor básico
# 2. Validaciones del constructor
# 3. Propiedades (getters)
# 4. Métodos simples
# 5. Métodos que dependen de otros
# 6. __str__, __repr__, __eq__

# ── UNITTEST ─────────────────────────────────────────────────

import unittest

class TestMiClase(unittest.TestCase):

    def setUp(self):
        """Se ejecuta ANTES de cada prueba."""
        self.objeto = MiClase(...)    # preparar el terreno

    def tearDown(self):
        """Se ejecuta DESPUÉS de cada prueba."""
        pass    # limpiar recursos (ficheros, conexiones...)

    def test_caso_normal(self):
        """Descripción clara de qué prueba este método."""
        resultado = self.objeto.metodo(entrada)
        self.assertEqual(resultado, esperado)

    def test_caso_limite(self):
        with self.assertRaises(ValueError):
            self.objeto.metodo(entrada_invalida)

if __name__ == "__main__":
    unittest.main(verbosity=2)    # verbosity=2 → muestra nombre de cada prueba

# MÉTODOS ASSERT ESENCIALES
self.assertEqual(a, b)           # a == b
self.assertNotEqual(a, b)        # a != b
self.assertTrue(x)               # bool(x) es True
self.assertFalse(x)              # bool(x) es False
self.assertIsNone(x)             # x is None
self.assertIsNotNone(x)          # x is not None
self.assertIn(a, b)              # a in b
self.assertNotIn(a, b)           # a not in b
self.assertRaises(Error, f, *args)   # f(*args) lanza Error
self.assertAlmostEqual(a, b)     # para flotantes (evita imprecisión)
self.assertAlmostEqual(a, b, places=2)  # hasta 2 decimales

# USO DE assertRaises — dos formas
# Forma 1 — como gestor de contexto (recomendada)
with self.assertRaises(ValueError):
    funcion(dato_invalido)

# Forma 2 — como llamada directa
self.assertRaises(ValueError, funcion, dato_invalido)

# INFORME DE RESULTADOS
# . → prueba pasada
# F → fallo (assert incorrecto)
# E → error inesperado (excepción no capturada)
# S → prueba saltada (skip)

# ERRORES TÍPICOS
# 1. Método sin prefijo test_ → no se ejecuta
def mi_prueba(self): ...         # MAL — no se ejecuta
def test_mi_prueba(self): ...    # BIEN

# 2. Comparar flotantes con assertEqual
self.assertEqual(0.1 + 0.2, 0.3)         # FALLA por imprecisión
self.assertAlmostEqual(0.1 + 0.2, 0.3)   # BIEN

# 3. Assert incorrecto para excepciones
resultado = funcion(malo)
self.assertFalse(resultado)               # MAL — puede no capturar el error
with self.assertRaises(ValueError):       # BIEN
    funcion(malo)

# 4. Valor esperado incorrecto
cuenta = CuentaBancaria("Sergio", 500)
cuenta.retirar(200)
self.assertEqual(cuenta.saldo, 200)       # MAL — 500-200=300, no 200

# 5. Prueba que hace demasiadas cosas
def test_todo(self):                      # MAL — si falla no sabes dónde
    objeto.metodo1()
    objeto.metodo2()
    objeto.metodo3()
    self.assertEqual(objeto.estado, ...)

def test_metodo1_actualiza_estado(self):  # BIEN — una cosa por prueba
    objeto.metodo1()
    self.assertEqual(objeto.estado, ...)

# 6. Solo probar el camino feliz
def test_ingreso(self):                   # MAL — falta probar errores
    self.assertTrue(ingresar(100))
# BIEN — probar también casos inválidos:
def test_ingreso_cantidad_negativa(self):
    with self.assertRaises(ValueError): ingresar(-100)
def test_ingreso_supera_limite(self):
    with self.assertRaises(ValueError): ingresar(999999)

Python testing — exercises to master unittest, black box and TDD

Testing exercises close out this block. Theory and practice done. Now solve on your own. Three exercises, three levels, black box, error detection in badly designed tests, and TDD for a complete class. The thread running through all three is the same object: a simple bank account.

As always: try to solve it yourself, use the hint if you’ve been stuck for more than 10 minutes, and compare with the commented solution at the end.

Basic Level

Exercise 1 — Black box tests for a deposit validator

function: validate_deposit(amount, current_balance)

Returns:
    True  if deposit is valid
    False if amount is 0

Raises:
    TypeError  if amount or current_balance are not numeric
    ValueError if amount is negative
    ValueError if current_balance is negative
    ValueError if resulting balance would exceed €100,000

Your tasks: identify all equivalence classes (valid and invalid), choose representative values, identify boundary values, write the complete unittest suite.

💡 Hint — the most common black box mistake:

The most common FP2 mistake is designing tests that only cover the happy path. A good black box test suite has more invalid case tests than valid ones, errors almost always hide at the extremes.

# WRONG — only tests normal case
def test_deposit(self):
    self.assertTrue(validate_deposit(100, 500))

# RIGHT — tests cases that can fail
def test_zero_amount_returns_false(self):
    self.assertFalse(validate_deposit(0, 500))

def test_negative_amount_raises_error(self):
    with self.assertRaises(ValueError):
        validate_deposit(-50, 500)

def test_balance_exceeds_limit(self):
    with self.assertRaises(ValueError):
        validate_deposit(1, 100000)

Pay special attention to frontiers: amount=0 (returns False), amount=0.01 (valid), current_balance + amount = 100000 (valid), current_balance + amount = 100000.01 (error).

Intermediate Level

Exercise 2 — Find the errors in these badly designed tests

A classmate wrote tests for BankAccount but made several design mistakes. Find exactly what’s wrong with each test and rewrite it correctly. There are 6 errors in total.

class TestBankAccount(unittest.TestCase):

    # TEST A
    def create_account(self):
        account = BankAccount("Sergio", 1000)
        self.assertEqual(account.balance, 1000)

    # TEST B
    def test_correct_deposit(self):
        account = BankAccount("Sergio", 500)
        account.deposit(200)
        account.deposit(300)
        account.deposit(100)
        self.assertEqual(account.balance, 1100)

    # TEST C
    def test_correct_withdrawal(self):
        account = BankAccount("Sergio", 500)
        account.withdraw(200)
        self.assertEqual(account.balance, 200)    # ← is this right?

    # TEST D
    def test_invalid_holder(self):
        account = BankAccount("A", 0)
        self.assertFalse(account)

    # TEST E
    def test_insufficient_balance(self):
        account = BankAccount("Sergio", 100)
        account.withdraw(200)
        self.assertEqual(account.balance, -100)

    # TEST F
    def test_compare_balances(self):
        account1 = BankAccount("Sergio", 500)
        account2 = BankAccount("Maria", 500)
        self.assertEqual(account1.balance == account2.balance, True)

💡 Hint: The three most common error categories in FP2 tests are: method without test_ prefix (never runs), wrong assert for the situation (should use assertRaises for exceptions), and incorrect expected value in the assert.

Final Challenge

Exercise 3 — TDD for the complete BankAccount class

Build BankAccount using TDD, test first, minimum code, then refactor. Follow RED → GREEN → REFACTOR for each iteration.

Required iterations:

1. Constructor with holder and initial_balance
2. Holder validation (minimum 2 characters)
3. Initial balance validation (not negative)
4. deposit() — normal case
5. deposit() — validations (amount <= 0, €100,000 limit)
6. withdraw() — normal case
7. withdraw() — validations (amount <= 0, insufficient balance)
8. __str__ and __repr__
9. transfer() — normal case and validations

💡 Hints: Don’t write code before the test. Start with setUp to avoid repeating account creation. For transfer, if it fails the source balance must not change. The €100,000 limit applies to the destination account too.

(Solutions same as Spanish version)

Cheat sheet — Python testing

# ============================================
# CHEAT SHEET — Python Testing
# Sergio Learns · sergiolearns.com
# ============================================

# WHAT IS A TEST
# Known input + expected result + comparison
# Goal: FIND errors, not confirm it works

# ── BLACK BOX ────────────────────────────────────────────────

# EQUIVALENCE CLASSES
# Group inputs with same behaviour → test one value per group
# Valid classes   → correct input → expected result
# Invalid classes → wrong input   → expected error

# BOUNDARY VALUES
# Test extremes of each class, not just centre
# For each frontier: min, min+1, max-1, max

# BLACK BOX PROCESS
# 1. Read only the specification (not the code)
# 2. Identify valid and invalid classes
# 3. Pick one representative per class
# 4. Add boundary values at each frontier
# 5. Write one test per case

# ── WHITE BOX ────────────────────────────────────────────────

# FLOW GRAPH
# Node           → consecutive instruction block
# Predicate node → condition (if, while, for) — two outgoing arcs
# Arc            → arrow between nodes

# CYCLOMATIC COMPLEXITY = predicate nodes + 1
# = minimum tests needed to cover all arcs

# WHITE BOX PROCESS
# 1. Identify predicate nodes
# 2. Draw flow graph
# 3. complexity = predicates + 1
# 4. Find paths covering all arcs
# 5. One test per path

# ── TDD ──────────────────────────────────────────────────────

# TDD CYCLE
# 1. RED    → write failing test (code doesn't exist)
# 2. GREEN  → minimum code to pass
# 3. REFACTOR → improve without breaking
# 4. Repeat

# TDD ORDER FOR A CLASS
# 1. Basic constructor
# 2. Constructor validations
# 3. Properties (getters)
# 4. Simple methods
# 5. Methods depending on others
# 6. __str__, __repr__, __eq__

# ── UNITTEST ─────────────────────────────────────────────────

import unittest

class TestMyClass(unittest.TestCase):

    def setUp(self):
        """Runs BEFORE each test."""
        self.obj = MyClass(...)

    def tearDown(self):
        """Runs AFTER each test."""
        pass

    def test_normal_case(self):
        """Clear description of what this test checks."""
        result = self.obj.method(input_value)
        self.assertEqual(result, expected)

    def test_boundary_case(self):
        with self.assertRaises(ValueError):
            self.obj.method(invalid_input)

if __name__ == "__main__":
    unittest.main(verbosity=2)

# ESSENTIAL ASSERT METHODS
self.assertEqual(a, b)
self.assertNotEqual(a, b)
self.assertTrue(x)
self.assertFalse(x)
self.assertIsNone(x)
self.assertIsNotNone(x)
self.assertIn(a, b)
self.assertRaises(Error, f, *args)
self.assertAlmostEqual(a, b)          # for floats
self.assertAlmostEqual(a, b, places=2)

# assertRaises — two forms
with self.assertRaises(ValueError):    # context manager (recommended)
    function(invalid)

self.assertRaises(ValueError, function, invalid)    # direct call

# TEST REPORT
# . → passed    F → failure    E → unexpected error    S → skipped

# COMMON MISTAKES
# 1. Method without test_ prefix → never runs
def my_test(self): ...          # WRONG
def test_my_test(self): ...     # RIGHT

# 2. Comparing floats with assertEqual
self.assertEqual(0.1 + 0.2, 0.3)        # FAILS (precision)
self.assertAlmostEqual(0.1 + 0.2, 0.3)  # RIGHT

# 3. Wrong assert for exceptions
result = function(bad)
self.assertFalse(result)                 # WRONG
with self.assertRaises(ValueError):      # RIGHT
    function(bad)

# 4. Incorrect expected value
account.withdraw(200)
self.assertEqual(account.balance, 200)   # WRONG: 500-200=300
self.assertEqual(account.balance, 300)   # RIGHT

# 5. Test doing too many things — split into focused tests

# 6. Only testing happy path — always test error cases too

Publicaciones Similares

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *