pruebas en Python práctica caja negra blanca TDD unittest

Pruebas en Python — caja negra, caja blanca y TDD con programas reales

Las pruebas en Python práctica real es lo que toca ahora. En el artículo anterior vimos la teoría, qué es una prueba, cómo funciona caja blanca y caja negra, el grafo de flujo, la complejidad ciclomática, TDD y unittest. Ahora aplicamos todo eso a programas concretos. Tres bloques progresivos: primero diseñamos las pruebas de caja negra, luego las de caja blanca con su grafo, y finalmente construimos una clase completa usando TDD.

Pruebas en Python práctica — Bloque 1: Pruebas de caja negra con clases de equivalencia y valores límite

Tomamos una función real de FP2, el validador de notas, y diseñamos todas las pruebas de caja negra sin mirar cómo está implementada por dentro.

def clasificar_nota(nota):
    """
    Clasifica una nota numérica en su calificación.
    
    Parámetros:
        nota (float): valor entre 0 y 10 (ambos incluidos)
    
    Devuelve:
        str: 'Matrícula' si nota >= 9
             'Sobresaliente' si 9 > nota >= 7.5
             'Notable' si 7.5 > nota >= 6
             'Bien' si 6 > nota >= 5
             'Suficiente' si 5 > nota >= 4.5  (aprobado por compensación)
             'Suspenso' si nota < 4.5
    
    Lanza:
        TypeError si nota no es numérica
        ValueError si nota está fuera del rango [0, 10]
    """

Paso 1 — Identificar las clases de equivalencia

Solo mirando la especificación, identificamos los grupos de valores que se comportan igual:

Clases VÁLIDAS:
┌─────────────────┬───────────────────┬────────────────────┐
│ Clase           │ Rango             │ Calificación       │
├─────────────────┼───────────────────┼────────────────────┤
│ CE1             │ 9 ≤ nota ≤ 10     │ Matrícula          │
│ CE2             │ 7.5 ≤ nota < 9    │ Sobresaliente      │
│ CE3             │ 6 ≤ nota < 7.5    │ Notable            │
│ CE4             │ 5 ≤ nota < 6      │ Bien               │
│ CE5             │ 4.5 ≤ nota < 5    │ Suficiente         │
│ CE6             │ 0 ≤ nota < 4.5    │ Suspenso           │
└─────────────────┴───────────────────┴────────────────────┘

Clases NO VÁLIDAS:
┌─────────────────┬───────────────────┬────────────────────┐
│ Clase           │ Rango             │ Error esperado     │
├─────────────────┼───────────────────┼────────────────────┤
│ CE7             │ nota < 0          │ ValueError         │
│ CE8             │ nota > 10         │ ValueError         │
│ CE9             │ nota no numérica  │ TypeError          │
└─────────────────┴───────────────────┴────────────────────┘

Paso 2 — Elegir valores representativos (mínimo uno por clase)

CE1 → 9.5   CE2 → 8.0   CE3 → 7.0   CE4 → 5.5
CE5 → 4.7   CE6 → 2.0   CE7 → -1    CE8 → 11   CE9 → "ocho"

Paso 3 — Añadir valores límite (extremos de cada clase)

CE1: límites → 9.0, 9.1, 9.9, 10.0
CE2: límites → 7.5, 7.6, 8.9, 8.99
CE3: límites → 6.0, 6.1, 7.4, 7.49
CE4: límites → 5.0, 5.1, 5.9, 5.99
CE5: límites → 4.5, 4.6, 4.9, 4.99
CE6: límites → 0.0, 0.1, 4.4, 4.49

Paso 4 — Escribir las pruebas con unittest

import unittest
from notas import clasificar_nota

class TestClasificarNota(unittest.TestCase):

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

    def test_ce1_matricula_valor_central(self):
        """CE1 — valor central de matrícula."""
        self.assertEqual(clasificar_nota(9.5), 'Matrícula')

    def test_ce2_sobresaliente_valor_central(self):
        """CE2 — valor central de sobresaliente."""
        self.assertEqual(clasificar_nota(8.0), 'Sobresaliente')

    def test_ce3_notable_valor_central(self):
        """CE3 — valor central de notable."""
        self.assertEqual(clasificar_nota(7.0), 'Notable')

    def test_ce4_bien_valor_central(self):
        """CE4 — valor central de bien."""
        self.assertEqual(clasificar_nota(5.5), 'Bien')

    def test_ce5_suficiente_valor_central(self):
        """CE5 — valor central de suficiente."""
        self.assertEqual(clasificar_nota(4.7), 'Suficiente')

    def test_ce6_suspenso_valor_central(self):
        """CE6 — valor central de suspenso."""
        self.assertEqual(clasificar_nota(2.0), 'Suspenso')

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

    def test_limite_matricula_minimo(self):
        """Límite inferior de matrícula — exactamente 9."""
        self.assertEqual(clasificar_nota(9.0), 'Matrícula')

    def test_limite_matricula_maximo(self):
        """Límite superior — exactamente 10."""
        self.assertEqual(clasificar_nota(10.0), 'Matrícula')

    def test_limite_frontera_matricula_sobresaliente(self):
        """Frontera crítica — justo por debajo de 9."""
        self.assertEqual(clasificar_nota(8.99), 'Sobresaliente')

    def test_limite_sobresaliente_minimo(self):
        """Límite inferior de sobresaliente — exactamente 7.5."""
        self.assertEqual(clasificar_nota(7.5), 'Sobresaliente')

    def test_limite_frontera_sobresaliente_notable(self):
        """Frontera crítica — justo por debajo de 7.5."""
        self.assertEqual(clasificar_nota(7.49), 'Notable')

    def test_limite_bien_minimo(self):
        """Límite inferior de bien — exactamente 5."""
        self.assertEqual(clasificar_nota(5.0), 'Bien')

    def test_limite_suficiente_minimo(self):
        """Límite inferior de suficiente — exactamente 4.5."""
        self.assertEqual(clasificar_nota(4.5), 'Suficiente')

    def test_limite_suspenso_maximo(self):
        """Máximo de suspenso — justo por debajo de 4.5."""
        self.assertEqual(clasificar_nota(4.49), 'Suspenso')

    def test_limite_suspenso_minimo(self):
        """Mínimo absoluto — exactamente 0."""
        self.assertEqual(clasificar_nota(0.0), 'Suspenso')

    # ── Clases no válidas ────────────────────────────────────────────────

    def test_ce7_nota_negativa(self):
        """CE7 — nota por debajo de 0."""
        with self.assertRaises(ValueError):
            clasificar_nota(-1)

    def test_ce8_nota_superior_diez(self):
        """CE8 — nota por encima de 10."""
        with self.assertRaises(ValueError):
            clasificar_nota(11)

    def test_ce9_nota_no_numerica_string(self):
        """CE9 — nota como cadena de texto."""
        with self.assertRaises(TypeError):
            clasificar_nota("ocho")

    def test_ce9_nota_no_numerica_none(self):
        """CE9 — nota como None."""
        with self.assertRaises(TypeError):
            clasificar_nota(None)


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

Fíjate en el docstring de cada prueba — explica exactamente qué caso cubre. En FP2 esto es importante porque el examen puede pedirte que identifiques a qué clase de equivalencia o valor límite corresponde cada prueba.

Pruebas en Python práctica — Bloque 2: Pruebas de caja blanca con grafo de flujo

Ahora tomamos una función de FP2 con varias condiciones y diseñamos las pruebas mirando el código por dentro.

def descuento_cliente(compra, puntos, es_premium):
    """
    Calcula el descuento aplicable a una compra.
    
    Parámetros:
        compra (float): importe de la compra en euros
        puntos (int): puntos acumulados del cliente
        es_premium (bool): si el cliente tiene tarjeta premium
    
    Devuelve:
        float: porcentaje de descuento (0, 5, 10 o 15)
    """
    descuento = 0                     # línea 1
    if compra >= 50:                  # línea 2 — predicado
        descuento = 5                 # línea 3
        if puntos >= 100:             # línea 4 — predicado
            descuento = 10            # línea 5
    if es_premium:                    # línea 6 — predicado
        descuento += 5                # línea 7
    return descuento                  # línea 8

Paso 1 — Construir el grafo de flujo

Identificamos los bloques y condiciones:

Nodo 1: descuento = 0
Nodo 2: ¿compra >= 50?  ← PREDICADO
Nodo 3: descuento = 5
Nodo 4: ¿puntos >= 100? ← PREDICADO
Nodo 5: descuento = 10
Nodo 6: ¿es_premium?    ← PREDICADO
Nodo 7: descuento += 5
Nodo 8: return descuento
[1: descuento=0]
       |
[2: compra>=50?]
   /         \
  Sí           No
 /               \
[3: desc=5]      |
    |             |
[4: puntos>=100?] |
   /         \    |
  Sí           No |
 /               \|
[5: desc=10]  [une con No de nodo 2]
    \               /
     \             /
    [6: es_premium?]
       /         \
      Sí           No
     /               \
[7: desc+=5]         |
        \            |
         [8: return descuento]

Paso 2 — Calcular la complejidad ciclomática

3 nodos predicado (nodos 2, 4 y 6) → complejidad ciclomática = 4

Necesitamos 4 pruebas para cubrir todos los arcos.

Paso 3 — Identificar los 4 caminos básicos

┌──────┬────────────────────────────────────────┬────────────────────────────┬──────────┐
│ Path │ Camino                                 │ Datos de prueba            │ Resultado│
├──────┼────────────────────────────────────────┼────────────────────────────┼──────────┤
│  P1  │ 1→2(No)→6(No)→8                        │ compra=30, pts=50,  no prem│    0%    │
│  P2  │ 1→2(No)→6(Sí)→7→8                      │ compra=30, pts=50,  premium│    5%    │
│  P3  │ 1→2(Sí)→3→4(No)→6(No)→8                │ compra=60, pts=50,  no prem│    5%    │
│  P4  │ 1→2(Sí)→3→4(Sí)→5→6(Sí)→7→8            │ compra=60, pts=150, premium│   15%    │
└──────┴────────────────────────────────────────┴────────────────────────────┴──────────┘

Paso 4 — Escribir las pruebas

import unittest
from descuentos import descuento_cliente

class TestDescuentoCliente(unittest.TestCase):

    def test_p1_sin_compra_minima_sin_premium(self):
        """P1 — compra < 50 y no premium → 0% descuento."""
        resultado = descuento_cliente(30, 50, False)
        self.assertEqual(resultado, 0)

    def test_p2_sin_compra_minima_con_premium(self):
        """P2 — compra < 50 y premium → solo 5% de premium."""
        resultado = descuento_cliente(30, 50, True)
        self.assertEqual(resultado, 5)

    def test_p3_compra_minima_sin_puntos_sin_premium(self):
        """P3 — compra >= 50, puntos < 100, no premium → 5%."""
        resultado = descuento_cliente(60, 50, False)
        self.assertEqual(resultado, 5)

    def test_p4_compra_y_puntos_y_premium(self):
        """P4 — compra >= 50, puntos >= 100, premium → 15%."""
        resultado = descuento_cliente(60, 150, True)
        self.assertEqual(resultado, 15)

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

    def test_limite_compra_exactamente_50(self):
        """Límite exacto de compra mínima."""
        resultado = descuento_cliente(50, 0, False)
        self.assertEqual(resultado, 5)

    def test_limite_compra_justo_por_debajo_50(self):
        """Justo por debajo del límite."""
        resultado = descuento_cliente(49.99, 0, False)
        self.assertEqual(resultado, 0)

    def test_limite_puntos_exactamente_100(self):
        """Límite exacto de puntos."""
        resultado = descuento_cliente(60, 100, False)
        self.assertEqual(resultado, 10)

    def test_limite_puntos_justo_por_debajo_100(self):
        """Justo por debajo del límite de puntos."""
        resultado = descuento_cliente(60, 99, False)
        self.assertEqual(resultado, 5)

    def test_combinacion_compra_y_puntos_sin_premium(self):
        """Compra >= 50 y puntos >= 100 pero sin premium → 10%."""
        resultado = descuento_cliente(60, 150, False)
        self.assertEqual(resultado, 10)


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

Con las 4 pruebas de los caminos básicos cubrimos todos los arcos del grafo — eso es cobertura completa de caja blanca. Las pruebas adicionales de valores límite son caja negra aplicada encima — las dos técnicas se complementan perfectamente.

Pruebas en Python práctica — Bloque 3: TDD paso a paso: construir una clase Temperatura desde las pruebas

Este es el bloque más largo y el más importante para FP2. Vamos a construir una clase Temperatura usando TDD, escribimos cada prueba antes del código, vemos cómo falla, y luego escribimos el mínimo código para que pase.

El ciclo es siempre: ROJO → VERDE → REFACTOR.

Especificación:

Una Temperatura tiene un valor numérico y una escala ('C', 'F' o 'K'). Puede convertirse entre escalas y compararse con otras temperaturas. Lanza ValueError si la temperatura está por debajo del cero absoluto (-273.15 °C).

Iteración 1 — Crear una temperatura válida

# ARCHIVO: test_temperatura.py
import unittest
from temperatura import Temperatura    # no existe todavía

class TestTemperatura(unittest.TestCase):

    def test_crear_celsius(self):
        """Crear una temperatura en Celsius."""
        t = Temperatura(25, 'C')
        self.assertEqual(t.valor, 25)
        self.assertEqual(t.escala, 'C')

# ROJO — ImportError: no module named 'temperatura'
# ARCHIVO: temperatura.py — código mínimo para pasar
class Temperatura:
    def __init__(self, valor, escala):
        self.valor = valor
        self.escala = escala
# VERDE ✓

Iteración 2 — Validar la escala

    def test_escala_invalida(self):
        """Una escala que no sea C, F o K lanza ValueError."""
        with self.assertRaises(ValueError):
            Temperatura(25, 'X')
# ROJO — no lanza nada todavía
class Temperatura:
    ESCALAS_VALIDAS = {'C', 'F', 'K'}

    def __init__(self, valor, escala):
        if escala not in self.ESCALAS_VALIDAS:
            raise ValueError(f"Escala '{escala}' no válida. Usa C, F o K.")
        self.valor = valor
        self.escala = escala
# VERDE ✓

Iteración 3 — Validar el cero absoluto

    def test_cero_absoluto_celsius(self):
        """Por debajo de -273.15 °C lanza ValueError."""
        with self.assertRaises(ValueError):
            Temperatura(-274, 'C')

    def test_exactamente_cero_absoluto_es_valido(self):
        """Exactamente -273.15 °C es válido (cero absoluto)."""
        t = Temperatura(-273.15, 'C')
        self.assertAlmostEqual(t.valor, -273.15)
# ROJO
    CERO_ABSOLUTO_C = -273.15

    def __init__(self, valor, escala):
        if escala not in self.ESCALAS_VALIDAS:
            raise ValueError(f"Escala '{escala}' no válida.")
        # convertir a Celsius para validar el cero absoluto
        valor_en_celsius = self.__a_celsius(valor, escala)
        if valor_en_celsius < self.CERO_ABSOLUTO_C:
            raise ValueError(f"Temperatura por debajo del cero absoluto.")
        self.valor = valor
        self.escala = escala

    @staticmethod
    def __a_celsius(valor, escala):
        if escala == 'C':
            return valor
        if escala == 'F':
            return (valor - 32) * 5 / 9
        if escala == 'K':
            return valor - 273.15
# VERDE ✓

Iteración 4 — Convertir a Celsius

    def test_convertir_fahrenheit_a_celsius(self):
        """212°F son 100°C."""
        t = Temperatura(212, 'F')
        resultado = t.a_celsius()
        self.assertAlmostEqual(resultado.valor, 100.0)
        self.assertEqual(resultado.escala, 'C')

    def test_convertir_celsius_a_celsius(self):
        """Celsius a Celsius no cambia el valor."""
        t = Temperatura(25, 'C')
        resultado = t.a_celsius()
        self.assertAlmostEqual(resultado.valor, 25.0)

    def test_convertir_kelvin_a_celsius(self):
        """0K son -273.15°C."""
        t = Temperatura(0, 'K')
        resultado = t.a_celsius()
        self.assertAlmostEqual(resultado.valor, -273.15)
# ROJO
    def a_celsius(self):
        """Devuelve una nueva Temperatura en Celsius."""
        valor_c = self.__a_celsius(self.valor, self.escala)
        return Temperatura(valor_c, 'C')
# VERDE ✓

Iteración 5 — Convertir a Fahrenheit y Kelvin

    def test_convertir_celsius_a_fahrenheit(self):
        """100°C son 212°F."""
        t = Temperatura(100, 'C')
        resultado = t.a_fahrenheit()
        self.assertAlmostEqual(resultado.valor, 212.0)
        self.assertEqual(resultado.escala, 'F')

    def test_convertir_celsius_a_kelvin(self):
        """0°C son 273.15 K."""
        t = Temperatura(0, 'C')
        resultado = t.a_kelvin()
        self.assertAlmostEqual(resultado.valor, 273.15)
        self.assertEqual(resultado.escala, 'K')
# ROJO
    def a_fahrenheit(self):
        """Devuelve una nueva Temperatura en Fahrenheit."""
        valor_c = self.__a_celsius(self.valor, self.escala)
        valor_f = valor_c * 9 / 5 + 32
        return Temperatura(valor_f, 'F')

    def a_kelvin(self):
        """Devuelve una nueva Temperatura en Kelvin."""
        valor_c = self.__a_celsius(self.valor, self.escala)
        valor_k = valor_c + 273.15
        return Temperatura(valor_k, 'K')
# VERDE ✓

Iteración 6 — Comparar temperaturas

    def test_igualdad_misma_escala(self):
        """Dos temperaturas iguales en la misma escala."""
        self.assertEqual(Temperatura(25, 'C'), Temperatura(25, 'C'))

    def test_igualdad_distinta_escala(self):
        """100°C y 212°F son iguales."""
        self.assertEqual(Temperatura(100, 'C'), Temperatura(212, 'F'))

    def test_desigualdad(self):
        """25°C y 30°C no son iguales."""
        self.assertNotEqual(Temperatura(25, 'C'), Temperatura(30, 'C'))
# ROJO
    def __eq__(self, otra):
        if not isinstance(otra, Temperatura):
            return NotImplemented
        # comparar en Celsius para neutralizar la escala
        return abs(self.a_celsius().valor - otra.a_celsius().valor) < 1e-9
# VERDE ✓

Iteración 7 — Representación en texto

    def test_str(self):
        """Representación legible."""
        t = Temperatura(25, 'C')
        self.assertEqual(str(t), '25°C')

    def test_repr(self):
        """Representación técnica."""
        t = Temperatura(25, 'C')
        self.assertEqual(repr(t), "Temperatura(25, 'C')")
# ROJO
    def __str__(self):
        return f'{self.valor}°{self.escala}'

    def __repr__(self):
        return f"Temperatura({self.valor}, '{self.escala}')"
# VERDE ✓

Clase completa — resultado del TDD

Al terminar el ciclo TDD tienes la clase completa y el conjunto de pruebas completo. Aquí el estado final:

# temperatura.py
class Temperatura:
    ESCALAS_VALIDAS = {'C', 'F', 'K'}
    CERO_ABSOLUTO_C = -273.15

    def __init__(self, valor, escala):
        if escala not in self.ESCALAS_VALIDAS:
            raise ValueError(f"Escala '{escala}' no válida. Usa C, F o K.")
        if self.__a_celsius(valor, escala) < self.CERO_ABSOLUTO_C:
            raise ValueError("Temperatura por debajo del cero absoluto.")
        self.valor = valor
        self.escala = escala

    @staticmethod
    def __a_celsius(valor, escala):
        if escala == 'C': return valor
        if escala == 'F': return (valor - 32) * 5 / 9
        if escala == 'K': return valor - 273.15

    def a_celsius(self):
        return Temperatura(self.__a_celsius(self.valor, self.escala), 'C')

    def a_fahrenheit(self):
        return Temperatura(self.a_celsius().valor * 9 / 5 + 32, 'F')

    def a_kelvin(self):
        return Temperatura(self.a_celsius().valor + 273.15, 'K')

    def __eq__(self, otra):
        if not isinstance(otra, Temperatura): return NotImplemented
        return abs(self.a_celsius().valor - otra.a_celsius().valor) < 1e-9

    def __str__(self):
        return f'{self.valor}°{self.escala}'

    def __repr__(self):
        return f"Temperatura({self.valor}, '{self.escala}')"
# test_temperatura.py — conjunto completo de pruebas
import unittest
from temperatura import Temperatura

class TestTemperatura(unittest.TestCase):

    # ── Creación válida ──────────────────────────────────────────────────

    def test_crear_celsius(self):
        t = Temperatura(25, 'C')
        self.assertEqual(t.valor, 25)
        self.assertEqual(t.escala, 'C')

    def test_crear_fahrenheit(self):
        t = Temperatura(98.6, 'F')
        self.assertAlmostEqual(t.valor, 98.6)

    def test_crear_kelvin(self):
        t = Temperatura(300, 'K')
        self.assertEqual(t.escala, 'K')

    # ── Validación de escala ─────────────────────────────────────────────

    def test_escala_invalida(self):
        with self.assertRaises(ValueError):
            Temperatura(25, 'X')

    def test_escala_minuscula(self):
        with self.assertRaises(ValueError):
            Temperatura(25, 'c')

    # ── Cero absoluto ────────────────────────────────────────────────────

    def test_cero_absoluto_celsius(self):
        with self.assertRaises(ValueError):
            Temperatura(-274, 'C')

    def test_exactamente_cero_absoluto_es_valido(self):
        t = Temperatura(-273.15, 'C')
        self.assertAlmostEqual(t.valor, -273.15)

    def test_cero_absoluto_kelvin(self):
        with self.assertRaises(ValueError):
            Temperatura(-1, 'K')

    def test_cero_kelvin_es_valido(self):
        t = Temperatura(0, 'K')
        self.assertEqual(t.valor, 0)

    # ── Conversiones ─────────────────────────────────────────────────────

    def test_celsius_a_fahrenheit(self):
        self.assertAlmostEqual(Temperatura(100, 'C').a_fahrenheit().valor, 212.0)

    def test_fahrenheit_a_celsius(self):
        self.assertAlmostEqual(Temperatura(212, 'F').a_celsius().valor, 100.0)

    def test_celsius_a_kelvin(self):
        self.assertAlmostEqual(Temperatura(0, 'C').a_kelvin().valor, 273.15)

    def test_kelvin_a_celsius(self):
        self.assertAlmostEqual(Temperatura(0, 'K').a_celsius().valor, -273.15)

    def test_conversion_mantiene_escala_destino(self):
        self.assertEqual(Temperatura(25, 'C').a_fahrenheit().escala, 'F')

    # ── Igualdad ─────────────────────────────────────────────────────────

    def test_igualdad_misma_escala(self):
        self.assertEqual(Temperatura(25, 'C'), Temperatura(25, 'C'))

    def test_igualdad_distinta_escala(self):
        self.assertEqual(Temperatura(100, 'C'), Temperatura(212, 'F'))

    def test_desigualdad(self):
        self.assertNotEqual(Temperatura(25, 'C'), Temperatura(30, 'C'))

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

    def test_str(self):
        self.assertEqual(str(Temperatura(25, 'C')), '25°C')

    def test_repr(self):
        self.assertEqual(repr(Temperatura(25, 'C')), "Temperatura(25, 'C')")


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

Resultado al ejecutar:

test_celsius_a_fahrenheit ... ok
test_celsius_a_kelvin ... ok
test_celsius_a_kelvin ... ok
test_cero_absoluto_celsius ... ok
test_cero_absoluto_kelvin ... ok
test_cero_kelvin_es_valido ... ok
test_conversion_mantiene_escala_destino ... ok
test_crear_celsius ... ok
test_crear_fahrenheit ... ok
test_crear_kelvin ... ok
test_desigualdad ... ok
test_escala_invalida ... ok
test_escala_minuscula ... ok
test_exactamente_cero_absoluto_es_valido ... ok
test_fahrenheit_a_celsius ... ok
test_igualdad_distinta_escala ... ok
test_igualdad_misma_escala ... ok
test_kelvin_a_celsius ... ok
test_repr ... ok
test_str ... ok

----------------------------------------------------------------------
Ran 20 tests in 0.003s

OK

20 puntos verdes. Eso es lo que da TDD al final de la sesión, no solo el código, sino la garantía de que funciona.

El patrón que debes llevarte

# ESTRATEGIA PARA CUALQUIER FUNCIÓN (caja negra)
# 1. Lee la especificación sin mirar el código
# 2. Identifica las clases de equivalencia válidas e inválidas
# 3. Elige un valor representativo por clase
# 4. Añade los valores límite de cada frontera
# 5. Escribe una prueba por caso con assertEqual o assertRaises

# ESTRATEGIA PARA CUALQUIER FUNCIÓN (caja blanca)
# 1. Lee el código e identifica los nodos predicado (if, while, for)
# 2. Dibuja el grafo de flujo
# 3. Calcula: complejidad = predicados + 1
# 4. Encuentra ese número de caminos que cubran todos los arcos
# 5. Diseña un caso de prueba por camino

# ESTRATEGIA TDD PARA UNA CLASE
# 1. Constructor básico → prueba → implementar
# 2. Validaciones → prueba → implementar
# 3. Métodos simples → prueba → implementar
# 4. Métodos que dependen de otros → prueba → implementar
# 5. Métodos mágicos (__eq__, __str__) → prueba → implementar

En el siguiente artículo encontrarás ejercicios propuestos con solución para diseñar pruebas por tu cuenta.


Python testing — black box, white box and TDD with real programs

In the previous article we covered the theory. Now we apply everything to concrete programs. Three progressive blocks: first black box tests, then white box with its flow graph, and finally building a complete class using TDD.

Block 1 — Black box testing with equivalence classes and boundary values

We take a real FP2 function — the grade classifier — and design all black box tests without looking at the implementation.

Step 1 — Identify equivalence classes

VALID classes:
CE1: 9 ≤ grade ≤ 10     → 'Distinction'
CE2: 7.5 ≤ grade < 9    → 'Merit'
CE3: 6 ≤ grade < 7.5    → 'Good'
CE4: 5 ≤ grade < 6      → 'Pass'
CE5: 4.5 ≤ grade < 5    → 'Borderline pass'
CE6: 0 ≤ grade < 4.5    → 'Fail'

INVALID classes:
CE7: grade < 0           → ValueError
CE8: grade > 10          → ValueError
CE9: non-numeric grade   → TypeError

Step 2 — Choose representative values

CE1→9.5  CE2→8.0  CE3→7.0  CE4→5.5  CE5→4.7
CE6→2.0  CE7→-1   CE8→11   CE9→"eight"

Step 3 — Add boundary values

For each class test: minimum, minimum+1, maximum-1, maximum.

Step 4 — Write unittest tests

import unittest
from grades import classify_grade

class TestClassifyGrade(unittest.TestCase):

    def test_ce1_distinction_central(self):
        self.assertEqual(classify_grade(9.5), 'Distinction')

    def test_ce6_fail_central(self):
        self.assertEqual(classify_grade(2.0), 'Fail')

    def test_boundary_minimum_distinction(self):
        self.assertEqual(classify_grade(9.0), 'Distinction')

    def test_boundary_maximum(self):
        self.assertEqual(classify_grade(10.0), 'Distinction')

    def test_boundary_just_below_distinction(self):
        self.assertEqual(classify_grade(8.99), 'Merit')

    def test_boundary_minimum_pass(self):
        self.assertEqual(classify_grade(5.0), 'Pass')

    def test_boundary_minimum_absolute(self):
        self.assertEqual(classify_grade(0.0), 'Fail')

    def test_ce7_below_zero(self):
        with self.assertRaises(ValueError): classify_grade(-1)

    def test_ce8_above_ten(self):
        with self.assertRaises(ValueError): classify_grade(11)

    def test_ce9_string(self):
        with self.assertRaises(TypeError): classify_grade("eight")

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

Block 2 — White box testing with flow graph

def customer_discount(purchase, points, is_premium):
    discount = 0                     # node 1
    if purchase >= 50:               # node 2 — predicate
        discount = 5                 # node 3
        if points >= 100:            # node 4 — predicate
            discount = 10            # node 5
    if is_premium:                   # node 6 — predicate
        discount += 5                # node 7
    return discount                  # node 8

Flow graph:

[1: discount=0]
       |
[2: purchase>=50?]
   /           \
  Yes            No
 /                \
[3: disc=5]        |
    |              |
[4: points>=100?]  |
   /          \    |
  Yes           No |
 /                \|
[5: disc=10]  [joins No of node 2]
    \               /
     \             /
    [6: is_premium?]
       /          \
      Yes           No
     /               \
[7: disc+=5]          |
        \             |
         [8: return discount]

3 predicate nodes → cyclomatic complexity = 4 → 4 tests needed.

P1: 2(No)→6(No)    purchase=30, pts=50,  not premium → 0%
P2: 2(No)→6(Yes)   purchase=30, pts=50,  premium    → 5%
P3: 2(Yes)→4(No)→6(No)  purchase=60, pts=50,  not premium → 5%
P4: 2(Yes)→4(Yes)→6(Yes) purchase=60, pts=150, premium    → 15%
import unittest
from discounts import customer_discount

class TestCustomerDiscount(unittest.TestCase):

    def test_p1_no_min_purchase_no_premium(self):
        self.assertEqual(customer_discount(30, 50, False), 0)

    def test_p2_no_min_purchase_premium(self):
        self.assertEqual(customer_discount(30, 50, True), 5)

    def test_p3_min_purchase_no_points_no_premium(self):
        self.assertEqual(customer_discount(60, 50, False), 5)

    def test_p4_all_conditions_met(self):
        self.assertEqual(customer_discount(60, 150, True), 15)

    def test_boundary_exactly_50(self):
        self.assertEqual(customer_discount(50, 0, False), 5)

    def test_boundary_just_below_50(self):
        self.assertEqual(customer_discount(49.99, 0, False), 0)

    def test_boundary_exactly_100_points(self):
        self.assertEqual(customer_discount(60, 100, False), 10)

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

Block 3 — TDD step by step

(Same Temperature class as Spanish version — full TDD cycle shown above)

The pattern to take away

# BLACK BOX STRATEGY
# 1. Read specification without looking at code
# 2. Identify valid and invalid equivalence classes
# 3. Pick one representative value per class
# 4. Add boundary values at each frontier
# 5. Write one test per case

# WHITE BOX STRATEGY
# 1. Identify predicate nodes (if, while, for)
# 2. Draw the flow graph
# 3. Complexity = predicates + 1
# 4. Find that many paths covering all arcs
# 5. Design one test per path

# TDD CLASS STRATEGY
# 1. Basic constructor → test → implement
# 2. Validations → test → implement
# 3. Simple methods → test → implement
# 4. Methods depending on others → test → implement
# 5. Magic methods → test → implement

Publicaciones Similares

Deja una respuesta

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