Pruebas en Python con unittest — práctica con las notas de FP2, el descuento de la tienda y un TDD de la piscina (y el panel que marcaba 82 grados)

Un lunes, el panel de la entrada del polideportivo anunciaba: «Temperatura del agua: 82 grados». Nadie se metió en la piscina, claro. El sensor nuevo medía en Fahrenheit y el programa del panel mostraba el número tal cual, como si fueran grados Celsius (82 °F son unos 27,8 °C, perfectos para nadar). Nadie había escrito ni una prueba de la conversión. La lección: si una función hace cuentas que importan, se prueba, y mejor antes de escribirla. Al final de esta práctica construirás esa clase Temperatura con TDD.

En la teoría de pruebas en Python viste cómo elegir casos con caja negra (clases de equivalencia y valores límite) y con caja blanca (grafo de flujo y complejidad ciclomática), cómo se escribe y se lee una batería de unittest y el ciclo rojo-verde-refactor de TDD. Hoy lo aplicas en tres bloques de nivel medio, cada uno con una técnica: caja negra con la calificación de una nota, caja blanca con el descuento de la tienda del polideportivo y un TDD completo de la clase Temperatura. Los datos son los de verdad: notas con decimales, compras justo en el límite y temperaturas que se pueden equivocar de escala.

Antes de empezar

Conviene tener claro:

  • Que una clase de tests hereda de unittest.TestCase y que solo se ejecutan los métodos que empiezan por test.
  • Qué es una clase de equivalencia y por qué se prueban los dos lados de cada frontera.
  • Cómo se cuenta la complejidad ciclomática: aristas − nodos + 2, o predicados + 1.
  • Que FAIL es un assert que no se cumple y ERROR, una excepción inesperada.

Si alguno te suena dudoso, repasa la teoría antes de seguir.

Cada programa lleva la función y sus pruebas en el mismo archivo y termina con unittest.main(verbosity=2), así que basta con python3 archivo.py. En un proyecto real irían separados (notas.py y test_notas.py, con from notas import clasificar_nota) y se lanzarían con python3 -m unittest -v. Las salidas son las reales; solo el tiempo («in 0.001s») cambiará en tu ordenador.

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

La función clasificar_nota(nota) recibe una nota de 0 a 10 y devuelve su calificación: menos de 5, Suspenso; de 5 a menos de 7, Aprobado; de 7 a menos de 9, Notable; de 9 a 10, Sobresaliente. Si la nota no es un número lanza TypeError, y si está fuera de 0-10, ValueError. Lo que la complica: las notas llevan decimales (6,99 no es un 7) y las fronteras están justo donde cambia la calificación.

La idea

Diseñar las pruebas solo con la especificación, como si no pudiéramos ver el código. Primero, las clases de equivalencia: cuatro válidas (una por calificación) y tres no válidas (negativa, mayor de 10 y texto). Después, un representativo por clase. Por último, los valores límite: los dos lados de cada frontera, con dos decimales porque así vienen las notas.

Antes de programar, cuenta las fronteras de la especificación. ¿Cuántos valores límite te salen si pruebas los dos lados de cada una?

Clases de clasificar_notaRecta de notas de 0 a 10 con cuatro clases válidas (Suspenso, Aprobado, Notable, Sobresaliente) y dos no válidas por debajo de 0 y por encima de 10.clasificar_nota(nota): 7 clases de equivalenciaerror< 0Suspenso0 – 4.99Aprobado5 – 6.99Notable7 – 8.99Sobres.9 – 10error> 1004.99|56.99|78.99|910Y fuera de la recta, CE7: un texto como «ocho» (TypeError).
Las fronteras coral son las que cambian la calificación: se prueban los dos lados.

En el dibujo, las fronteras coral son las que cambian la calificación; las violetas, las que separan lo válido de lo que da error.

ClaseRangoResultadoRepresentativo
CE10 ≤ nota < 5Suspenso3.2
CE25 ≤ nota < 7Aprobado6
CE37 ≤ nota < 9Notable7.8
CE49 ≤ nota ≤ 10Sobresaliente9.5
CE5nota < 0ValueError−2
CE6nota > 10ValueError11
CE7no es un númeroTypeError'ocho'

El código

import unittest


def clasificar_nota(nota):
    """Calificación de una nota de 0 a 10 (ambos incluidos):
    menos de 5 Suspenso, de 5 a menos de 7 Aprobado, de 7 a menos de 9 Notable, de 9 a 10 Sobresaliente.
    TypeError si la nota no es un número; ValueError si está fuera de 0-10."""
    if isinstance(nota, bool) or not isinstance(nota, (int, float)):
        raise TypeError('la nota debe ser un número')
    if nota < 0 or nota > 10:
        raise ValueError('la nota debe estar entre 0 y 10')
    if nota < 5:
        return 'Suspenso'
    if nota < 7:
        return 'Aprobado'
    if nota < 9:
        return 'Notable'
    return 'Sobresaliente'


class TestClasificarNota(unittest.TestCase):
    # Clases válidas: un representativo de cada una
    def test_ce1_suspenso(self):
        self.assertEqual(clasificar_nota(3.2), 'Suspenso')

    def test_ce2_aprobado(self):
        self.assertEqual(clasificar_nota(6), 'Aprobado')

    def test_ce3_notable(self):
        self.assertEqual(clasificar_nota(7.8), 'Notable')

    def test_ce4_sobresaliente(self):
        self.assertEqual(clasificar_nota(9.5), 'Sobresaliente')

    # Clases no válidas
    def test_ce5_negativa(self):
        with self.assertRaises(ValueError):
            clasificar_nota(-2)

    def test_ce6_mayor_de_10(self):
        with self.assertRaises(ValueError):
            clasificar_nota(11)

    def test_ce7_texto(self):
        with self.assertRaises(TypeError):
            clasificar_nota('ocho')

    # Valores límite de cada frontera
    def test_lim_invalidos(self):
        for nota in (-0.01, 10.01):
            with self.subTest(nota=nota):
                with self.assertRaises(ValueError):
                    clasificar_nota(nota)

    def test_lim_validos(self):
        casos = [(0, 'Suspenso'), (4.99, 'Suspenso'), (5, 'Aprobado'), (6.99, 'Aprobado'),
                 (7, 'Notable'), (8.99, 'Notable'), (9, 'Sobresaliente'), (10, 'Sobresaliente')]
        for nota, esperada in casos:
            with self.subTest(nota=nota):
                self.assertEqual(clasificar_nota(nota), esperada)


unittest.main(verbosity=2)

La validación del principio tiene un detalle:

def clasificar_nota(nota):
    """Calificación de una nota de 0 a 10 (ambos incluidos):
    menos de 5 Suspenso, de 5 a menos de 7 Aprobado, de 7 a menos de 9 Notable, de 9 a 10 Sobresaliente.
    TypeError si la nota no es un número; ValueError si está fuera de 0-10."""
    if isinstance(nota, bool) or not isinstance(nota, (int, float)):
        raise TypeError('la nota debe ser un número')
    if nota < 0 or nota > 10:
        raise ValueError('la nota debe estar entre 0 y 10')
    if nota < 5:
        return 'Suspenso'
    if nota < 7:
        return 'Aprobado'
    if nota < 9:
        return 'Notable'
    return 'Sobresaliente'
  • isinstance(nota, (int, float)) acepta enteros y decimales con una sola comprobación.
  • isinstance(nota, bool) va antes porque en Python True es un int: sin esa línea, clasificar_nota(True) devolvería «Suspenso» (True vale 1).
  • Después de validar, los if van de menor a mayor y cada uno solo necesita comparar con el límite de arriba: si llegamos a nota < 7, ya sabemos que no era menor que 5.

Salida

test_ce1_suspenso (__main__.TestClasificarNota.test_ce1_suspenso) ... ok
test_ce2_aprobado (__main__.TestClasificarNota.test_ce2_aprobado) ... ok
test_ce3_notable (__main__.TestClasificarNota.test_ce3_notable) ... ok
test_ce4_sobresaliente (__main__.TestClasificarNota.test_ce4_sobresaliente) ... ok
test_ce5_negativa (__main__.TestClasificarNota.test_ce5_negativa) ... ok
test_ce6_mayor_de_10 (__main__.TestClasificarNota.test_ce6_mayor_de_10) ... ok
test_ce7_texto (__main__.TestClasificarNota.test_ce7_texto) ... ok
test_lim_invalidos (__main__.TestClasificarNota.test_lim_invalidos) ... ok
test_lim_validos (__main__.TestClasificarNota.test_lim_validos) ... ok

----------------------------------------------------------------------
Ran 9 tests in 0.001s

OK

Nueve tests, nueve ok: siete clases y dos tests de valores límite. Con subTest, esos dos comprueban 2 + 8 = 10 valores: −0.01 y 10.01 (fuera), y 0, 4.99, 5, 6.99, 7, 8.99, 9 y 10 (dentro). En total, 7 + 10 = 17 casos en 9 tests.

Preguntas que te puedes hacer

¿Por qué 4.99 y no 4.9 como valor límite? Porque las notas de la asignatura llevan dos decimales: el valor más cercano a 5 por debajo que puede aparecer es 4.99. Con 4.9 dejarías sin probar el hueco entre 4.9 y 5.

¿Por qué el 10 es válido y el 10.01 no? Por la especificación: «de 9 a 10» incluye el 10. Si el programador hubiera escrito nota >= 10 para dar error, el test del 10 lo cazaría.

¿Hace falta probar también 5.5 o 8? No: están en la misma clase que 6 y 7.8. Otro valor de la misma clase solo repite trabajo; un valor en la frontera, en cambio, puede destapar un < que debía ser <=.

¿Por qué los nombres llevan ce1, ce2… y lim? Para que el informe salga en el orden de la tabla (unittest los ordena alfabéticamente) y para que, si uno falla, se vea de un vistazo qué clase o qué frontera ha fallado.

Escribir una sola prueba por calificación con valores «del medio» (3, 6, 8, 9.5) y dar la batería por buena. Todos pasarían aunque el código tuviera nota <= 5 en lugar de nota < 5; solo el 5 exacto lo destapa.

Pruébalo tú

  • Añade la Matrícula de Honor como clase nueva (nota 10 con una marca mh=True) y escribe sus pruebas antes de tocar la función.
  • Cambia nota < 7 por nota <= 7 en la función y ejecuta: ¿qué test falla y qué dice el informe?

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

La tienda del polideportivo hace descuentos: con una compra de 50 € o más, un 5 %; si además el socio tiene 100 puntos o más, un 10 %; y los socios premium suman un 5 % extra a lo que tengan. La función descuento_cliente(compra, puntos, es_premium) devuelve el porcentaje (0, 5, 10 o 15). Lo que la complica: un if está dentro de otro, y es fácil dejarse una combinación sin probar.

La idea

Ahora sí miramos el código. Numeramos sus nodos, dibujamos el grafo de flujo, calculamos la complejidad ciclomática y elegimos un caso de prueba por cada camino básico. Encima, como las dos técnicas se complementan, añadimos los valores límite de cada condición (caja negra).

Antes de programar, cuenta los if. Si hay tres, ¿cuántos caminos básicos esperas?

El código

import unittest


def descuento_cliente(compra, puntos, es_premium):
    """Porcentaje de descuento en la tienda del polideportivo (0, 5, 10 o 15)."""
    descuento = 0                     # nodo 1
    if compra >= 50:                  # nodo 2 (predicado)
        descuento = 5                 # nodo 3
        if puntos >= 100:             # nodo 4 (predicado)
            descuento = 10            # nodo 5
    if es_premium:                    # nodo 6 (predicado)
        descuento += 5                # nodo 7
    return descuento                  # nodo 8


class TestDescuentoCliente(unittest.TestCase):
    # Caja blanca: un test por camino básico, V(G) = 3 + 1 = 4
    def test_camino1_compra_pequena(self):
        self.assertEqual(descuento_cliente(30, 50, False), 0)

    def test_camino2_solo_premium(self):
        self.assertEqual(descuento_cliente(30, 50, True), 5)

    def test_camino3_compra_sin_puntos(self):
        self.assertEqual(descuento_cliente(60, 50, False), 5)

    def test_camino4_todo(self):
        self.assertEqual(descuento_cliente(60, 150, True), 15)

    # Caja negra encima: las fronteras de cada if
    def test_valor_limite_compra_50(self):
        self.assertEqual(descuento_cliente(49.99, 0, False), 0)
        self.assertEqual(descuento_cliente(50, 0, False), 5)

    def test_valor_limite_puntos_100(self):
        self.assertEqual(descuento_cliente(50, 99, False), 5)
        self.assertEqual(descuento_cliente(50, 100, False), 10)


unittest.main(verbosity=2)
Grafo de flujo de descuento_clienteEl if de los puntos (4) está dentro del de la compra (2); el de premium (6) va después. Cuatro caminos básicos.SíNoSíNoSíNo12345678compra >= 50?puntos >= 100?descuento += 5es_premium?Caminos básicos (V(G) = 4)1-2-6-830 €, 50 pts, no premium01-2-6-7-830 €, 50 pts, premium51-2-3-4-6-860 €, 50 pts, no premium51-2-3-4-5-6-7-860 €, 150 pts, premium1510 aristas − 8 nodos + 2 = 43 predicados + 1 = 4
Con estos cuatro caminos se recorren las diez aristas al menos una vez.

Construyendo el grafo con el código delante:

  1. Nodo 1: descuento = 0. Nodo 2: ¿compra >= 50? (predicado).
  2. Si sí, nodo 3 (descuento = 5) y nodo 4: ¿puntos >= 100? (predicado, dentro del primero). Si sí, nodo 5.
  3. Las tres salidas posibles (2-No, 4-No y 5) llegan al nodo 6: ¿es_premium? (predicado). Si sí, nodo 7; los dos caminos acaban en el 8 (return).
  4. Cuenta: 8 nodos y 10 aristas → 10 − 8 + 2 = 4. Comprobación: 3 predicados + 1 = 4.

Salida

test_camino1_compra_pequena (__main__.TestDescuentoCliente.test_camino1_compra_pequena) ... ok
test_camino2_solo_premium (__main__.TestDescuentoCliente.test_camino2_solo_premium) ... ok
test_camino3_compra_sin_puntos (__main__.TestDescuentoCliente.test_camino3_compra_sin_puntos) ... ok
test_camino4_todo (__main__.TestDescuentoCliente.test_camino4_todo) ... ok
test_valor_limite_compra_50 (__main__.TestDescuentoCliente.test_valor_limite_compra_50) ... ok
test_valor_limite_puntos_100 (__main__.TestDescuentoCliente.test_valor_limite_puntos_100) ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.000s

OK

Seis ok: los cuatro caminos básicos y dos de valores límite. Comprobación a mano de cada camino:

CaminoNodosDatosCuentaEsperado
11-2-6-830 €, 50 pts, no premium30 < 50 y no premium0
21-2-6-7-830 €, 50 pts, premium0 + 55
31-2-3-4-6-860 €, 50 pts, no premium60 ≥ 50 → 5; 50 < 1005
41-2-3-4-5-6-7-860 €, 150 pts, premium5 → 10 (puntos) → 10 + 515

Y los límites: 49,99 € da 0 y 50 € da 5 (frontera de la compra); 99 puntos dan 5 y 100 dan 10 (frontera de los puntos, con la compra justo en 50).

Preguntas que te puedes hacer

¿Cuatro caminos prueban todas las combinaciones? No: hay más combinaciones (por ejemplo, 60 €, 150 puntos y no premium, que da 10). Los caminos básicos garantizan que cada arista se recorre al menos una vez, no todas las combinaciones. Es el mínimo, no el máximo.

¿Por qué el camino 4 recorre los ocho nodos? Porque añade las aristas 4→5 y 5→6, que ningún camino anterior había usado. Un camino básico solo cuenta si aporta al menos una arista nueva.

¿Para qué los tests de valores límite, si la caja blanca ya cubre todo? Porque la caja blanca no ve si el >= debía ser >: con 60 € y con 30 € los dos operadores dan lo mismo. Solo el 50 exacto distingue >= de >.

¿Por qué dos assert en un mismo test de límite? Porque comprueban una idea (la frontera de la compra) por sus dos lados. Si el test falla, el mensaje dice cuál de los dos valores no cuadró.

Contar mal las aristas de un if sin else. La salida «No» también es una arista (del 2 al 6 aquí). Si te la dejas, te sale V(G) = 3 y te falta un caso de prueba.

Pruébalo tú

  • Cambia el if puntos >= 100 para que esté fuera del primer if (al mismo nivel). Dibuja el grafo nuevo: ¿cambia V(G)? ¿Qué caminos nuevos aparecen?
  • Añade la regla «los premium nunca pasan del 15 %» y escribe primero el test que la compruebe.

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

La clase Temperatura del panel de la piscina guarda un valor y una escala ('C', 'F' o 'K'), convierte entre escalas, se compara con otras temperaturas aunque estén en otra escala y se muestra como 28°C. Lanza ValueError con una escala desconocida o por debajo del cero absoluto (−273,15 °C). Lo que la complica: tres escalas que se convierten entre sí y decimales que no salen exactos.

La idea

TDD estricto: para cada requisito, primero el test (rojo), después el código mínimo (verde) y, si hace falta, refactor. Seis iteraciones: crear, validar la escala, validar el cero absoluto, convertir, comparar y mostrar. Aquí verás dos rojos reales (el de la iteración 1 y el de la 3) y el resultado final con todas las pruebas.

Antes de programar, piensa en una sola función auxiliar que pase cualquier escala a Celsius. ¿Cuántos sitios de la clase la podrían usar?

El ciclo de TDDRojo, verde y refactor en bucle, y las iteraciones de la clase Temperatura con su resultado: E, OK, F, OK, OK, OK, OK.ROJOescribe un testque fallaVERDEel código mínimopara que paseREFACTORlimpia sin romperningún testsiguiente requisitoTemperatura en la práctica (lo que dijo unittest):i1Ei2OKi3Fi3OKi4OKi5OKi6OKi1: la clase no existía (E, NameError). i3: el cero absoluto aún no se comprobaba (F).
El rojo no es un fracaso: es la prueba de que el test sirve para algo.

Iteración 1 — Crear una temperatura válida

El primer test, sin una sola línea de la clase:

import unittest


class TestTemperatura(unittest.TestCase):
    # Iteración 1: crear (la clase todavía no existe)
    def test_i1_crear_celsius(self):
        t = Temperatura(25, 'C')
        self.assertEqual((t.valor, t.escala), (25, 'C'))


unittest.main(verbosity=2)
test_i1_crear_celsius (__main__.TestTemperatura.test_i1_crear_celsius) ... ERROR

======================================================================
ERROR: test_i1_crear_celsius (__main__.TestTemperatura.test_i1_crear_celsius)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "rojo1.py", line 7, in test_i1_crear_celsius
    t = Temperatura(25, 'C')
        ^^^^^^^^^^^
NameError: name 'Temperatura' is not defined. Did you mean: 'TestTemperatura'?

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (errors=1)

Rojo, pero un rojo especial: es un ERROR (NameError), no un FAIL, porque la clase ni existe. En TDD es lo normal en la primera iteración. El código mínimo para pasarlo es un __init__ que guarde valor y escala.

Iteraciones 2 y 3 — Validar la escala y el cero absoluto

En la iteración 2 añadimos el test de la escala inválida y la comprobación if escala not in self.ESCALAS. Al pasar a la 3, escribimos el test del cero absoluto y lo ejecutamos antes de tocar la clase:

import unittest


class Temperatura:
    ESCALAS = ('C', 'F', 'K')

    def __init__(self, valor, escala):
        if escala not in self.ESCALAS:
            raise ValueError(f'escala {escala!r} no válida: usa C, F o K')
        self.valor = valor             # iteración 2 en verde: el cero absoluto aún no se mira
        self.escala = escala


class TestTemperatura(unittest.TestCase):
    def test_i1_crear_celsius(self):
        t = Temperatura(25, 'C')
        self.assertEqual((t.valor, t.escala), (25, 'C'))

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

    def test_i3_bajo_cero_absoluto(self):          # el test nuevo: rojo
        with self.assertRaises(ValueError):
            Temperatura(-273.16, 'C')


unittest.main(verbosity=2)
test_i1_crear_celsius (__main__.TestTemperatura.test_i1_crear_celsius) ... ok
test_i2_escala_invalida (__main__.TestTemperatura.test_i2_escala_invalida) ... ok
test_i3_bajo_cero_absoluto (__main__.TestTemperatura.test_i3_bajo_cero_absoluto) ... FAIL

======================================================================
FAIL: test_i3_bajo_cero_absoluto (__main__.TestTemperatura.test_i3_bajo_cero_absoluto)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "rojo3.py", line 24, in test_i3_bajo_cero_absoluto
    with self.assertRaises(ValueError):
         ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^
AssertionError: ValueError not raised

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1)

Las dos iteraciones anteriores siguen en verde y la nueva falla con ValueError not raised: el código acepta −273,16 °C sin protestar. Para ponerla en verde hace falta saber el valor en Celsius de cualquier escala, y de ahí sale el método auxiliar _celsius, que después reutilizan las conversiones (es el refactor de esta iteración). Fíjate en que el test de la iteración 3 usa justo el valor límite: −273,16, una centésima por debajo del cero absoluto. Y su pareja, test_i3_cero_kelvin_es_valido, comprueba el otro lado: 0 K exactos sí es válido.

Iteraciones 4 a 6 — Convertir, comparar y mostrar

Las conversiones se prueban con valores que se saben de memoria: 0 °C = 273,15 K, 212 °F = 100 °C y 373,15 K = 212 °F. La comparación, con 100 °C == 212 °F (iguales aunque la escala sea distinta) y 25 °C != 30 °C. El texto, con str y repr de 28 °C.

Clase completa — resultado del TDD

import unittest


class Temperatura:
    ESCALAS = ('C', 'F', 'K')
    CERO_ABSOLUTO_C = -273.15

    def __init__(self, valor, escala):
        if escala not in self.ESCALAS:
            raise ValueError(f'escala {escala!r} no válida: usa C, F o K')
        if self._celsius(valor, escala) < self.CERO_ABSOLUTO_C:
            raise ValueError('por debajo del cero absoluto')
        self.valor = valor
        self.escala = escala

    @staticmethod
    def _celsius(valor, escala):
        """Pasa cualquier escala a Celsius (la usan la validación y las conversiones)."""
        if escala == 'C':
            return valor
        if escala == 'F':
            return (valor - 32) * 5 / 9
        return valor - 273.15

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

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

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

    def __eq__(self, otra):
        if not isinstance(otra, Temperatura):
            return NotImplemented
        # en Celsius y con margen: 80.6 °F pasados a C dan 26.999999999999996, no 27
        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!r}, {self.escala!r})'


class TestTemperatura(unittest.TestCase):
    # Iteración 1: crear
    def test_i1_crear_celsius(self):
        t = Temperatura(25, 'C')
        self.assertEqual((t.valor, t.escala), (25, 'C'))

    # Iteración 2: la escala
    def test_i2_escala_invalida(self):
        with self.assertRaises(ValueError):
            Temperatura(25, 'X')

    # Iteración 3: el cero absoluto (con sus valores límite)
    def test_i3_bajo_cero_absoluto(self):
        with self.assertRaises(ValueError):
            Temperatura(-273.16, 'C')

    def test_i3_cero_kelvin_es_valido(self):
        self.assertEqual(Temperatura(0, 'K').valor, 0)

    # Iteración 4: conversiones
    def test_i4_celsius_a_kelvin(self):
        self.assertAlmostEqual(Temperatura(0, 'C').a_kelvin().valor, 273.15)

    def test_i4_fahrenheit_a_celsius(self):
        self.assertAlmostEqual(Temperatura(212, 'F').a_celsius().valor, 100)

    def test_i4_kelvin_a_fahrenheit(self):
        self.assertAlmostEqual(Temperatura(373.15, 'K').a_fahrenheit().valor, 212)

    # Iteración 5: comparar
    def test_i5_iguales_en_distinta_escala(self):
        self.assertEqual(Temperatura(100, 'C'), Temperatura(212, 'F'))

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

    # Iteración 6: texto
    def test_i6_repr(self):
        self.assertEqual(repr(Temperatura(28, 'C')), "Temperatura(28, 'C')")

    def test_i6_str(self):
        self.assertEqual(str(Temperatura(28, 'C')), '28°C')


unittest.main(verbosity=2)

Las piezas que salieron de las pruebas:

@staticmethod
def _celsius(valor, escala):
    """Pasa cualquier escala a Celsius (la usan la validación y las conversiones)."""
    if escala == 'C':
        return valor
    if escala == 'F':
        return (valor - 32) * 5 / 9
    return valor - 273.15

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

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

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

def __eq__(self, otra):
    if not isinstance(otra, Temperatura):
        return NotImplemented
    # en Celsius y con margen: 80.6 °F pasados a C dan 26.999999999999996, no 27
    return abs(self.a_celsius().valor - otra.a_celsius().valor) < 1e-9
  • _celsius es un @staticmethod porque no necesita el objeto: recibe valor y escala y devuelve Celsius. Lo usan el constructor (para el cero absoluto, antes de que el objeto exista) y las tres conversiones.
  • Las conversiones devuelven una Temperatura nueva y no tocan la original.
  • __eq__ compara en Celsius y con un margen (< 1e-9): en Python, (80.6 − 32) × 5 / 9 da 26.999999999999996, así que una comparación exacta diría que 80,6 °F no es igual a 27 °C. Por la misma razón, los tests de conversión usan assertAlmostEqual, aunque los tres valores que hemos elegido salgan exactos.

Salida

test_i1_crear_celsius (__main__.TestTemperatura.test_i1_crear_celsius) ... ok
test_i2_escala_invalida (__main__.TestTemperatura.test_i2_escala_invalida) ... ok
test_i3_bajo_cero_absoluto (__main__.TestTemperatura.test_i3_bajo_cero_absoluto) ... ok
test_i3_cero_kelvin_es_valido (__main__.TestTemperatura.test_i3_cero_kelvin_es_valido) ... ok
test_i4_celsius_a_kelvin (__main__.TestTemperatura.test_i4_celsius_a_kelvin) ... ok
test_i4_fahrenheit_a_celsius (__main__.TestTemperatura.test_i4_fahrenheit_a_celsius) ... ok
test_i4_kelvin_a_fahrenheit (__main__.TestTemperatura.test_i4_kelvin_a_fahrenheit) ... ok
test_i5_distintas (__main__.TestTemperatura.test_i5_distintas) ... ok
test_i5_iguales_en_distinta_escala (__main__.TestTemperatura.test_i5_iguales_en_distinta_escala) ... ok
test_i6_repr (__main__.TestTemperatura.test_i6_repr) ... ok
test_i6_str (__main__.TestTemperatura.test_i6_str) ... ok

----------------------------------------------------------------------
Ran 11 tests in 0.001s

OK

Once tests en verde, ordenados por iteración gracias al prefijo i1…i6. Comprobación de una conversión a mano: 373,15 K − 273,15 = 100 °C, y 100 × 9 / 5 + 32 = 212 °F.

Preguntas que te puedes hacer

¿Por qué escribir el test si sé que va a fallar? Para comprobar que el test puede fallar. Un test que pasa sin código nuevo no está probando nada (o prueba otra cosa). El rojo es la garantía.

¿Por qué «el código mínimo» y no la clase entera de una vez? Porque cada línea que escribes sin un test que la pida es una línea sin probar. Avanzar a pasos cortos hace que, cuando algo se rompe, sepas que ha sido lo último que tocaste.

¿Por qué isinstance(otra, Temperatura) y NotImplemented en __eq__? Para que comparar una temperatura con un número (t == 28) no reviente intentando llamar a otra.a_celsius(). Con NotImplemented, Python prueba otras formas y, al final, devuelve False (lo viste en la sobrecarga de operadores).

¿Esto habría evitado los 82 grados del panel? Un test como test_i4_fahrenheit_a_celsius habría obligado a pensar en la escala del sensor antes de enseñar el número. El fallo no estaba en una cuenta, sino en que nadie había escrito qué debía pasar con los Fahrenheit.

Comparar el resultado de una conversión con assertEqual. Temperatura(80.6, 'F').a_celsius().valor es 26.999999999999996, y assertEqual(..., 27) daría FAIL aunque la clase esté bien. Con decimales, assertAlmostEqual.

Pruébalo tú

  • Añade, con TDD, el método es_agradable_para_nadar(): True entre 26 y 29 °C, en cualquier escala. Escribe primero los tests de los valores límite.
  • Haz que la escala acepte minúsculas ('c'): primero el test (rojo), después el cambio (verde).

El patrón que debes llevarte

TécnicaPasosLo que aporta
Caja negraEspecificación → clases válidas y no válidas → un representativo por clase → los dos lados de cada fronteraCasos que el programador olvidó
Caja blancaCódigo → nodos y aristas → V(G) → un caso por camino básicoNinguna rama sin ejecutar
TDDTest que falla → código mínimo → refactor → siguiente requisitoCódigo que siempre tiene sus pruebas

Errores típicos en esta práctica

  • Probar solo valores del medio de cada clase: las fronteras (5, 50, 100, −273.16) son las que destapan un < donde iba <=.
  • Dejarse la arista «No» de un if sin else: el grafo sale con una arista menos y V(G) con un camino menos.
  • Creer que los caminos básicos son todas las combinaciones: son el mínimo para recorrer todas las aristas.
  • Comparar decimales con assertEqual: las conversiones de temperatura no salen exactas.
  • Escribir el test después del código en TDD: si nunca lo has visto en rojo, no sabes si es capaz de fallar.
  • Copiar en el test lo que devuelve la función: el esperado se calcula con la especificación.

Visualízalo

En Python Tutor no puedes ejecutar unittest, pero sí ver los cuatro caminos básicos del bloque 2 uno a uno. Elige Python 3 y pega:

def descuento_cliente(compra, puntos, es_premium):
    descuento = 0
    if compra >= 50:
        descuento = 5
        if puntos >= 100:
            descuento = 10
    if es_premium:
        descuento += 5
    return descuento


caminos = [(30, 50, False, 0), (30, 50, True, 5), (60, 50, False, 5), (60, 150, True, 15)]
for compra, puntos, premium, esperado in caminos:
    obtenido = descuento_cliente(compra, puntos, premium)
    print(compra, puntos, premium, '->', obtenido, obtenido == esperado)

Salida:

30 50 False -> 0 True
30 50 True -> 5 True
60 50 False -> 5 True
60 150 True -> 15 True

Qué observar:

  1. En cada vuelta del for, sigue la flecha dentro de descuento_cliente: verás que cada caso pasa por un camino distinto del grafo.
  2. En el caso (30, 50, False) la flecha salta directamente del primer if al de premium: es la arista 2→6 que muchos olvidan.
  3. En el caso (60, 150, True) la flecha entra en los tres if: es el único que pasa por el nodo 5.
  4. La última columna (True) es el assertEqual: lo obtenido coincide con lo esperado en los cuatro.

Resumen y siguiente paso

  • Caja negra: clases de equivalencia válidas y no válidas, un representativo por clase y los dos lados de cada frontera.
  • Con decimales, el «otro lado» de la frontera es el valor más cercano que tenga sentido (4.99 para notas con dos decimales).
  • Caja blanca: grafo de flujo, V(G) = aristas − nodos + 2 = predicados + 1, y un test por camino básico.
  • Las dos técnicas se combinan: los caminos cubren el código; los límites, las condiciones.
  • TDD: rojo, verde, refactor. El primer rojo suele ser un ERROR (la clase no existe); los siguientes, FAIL.
  • Con decimales, assertAlmostEqual; para excepciones, with self.assertRaises(...).

Checklist: ¿lo has practicado?

  • ¿Has ejecutado los tres programas y comprobado las salidas?
  • ¿Sabes sacar las 7 clases de clasificar_nota solo con la especificación?
  • ¿Te sale V(G) = 4 en descuento_cliente por las dos fórmulas?
  • ¿Sabes por qué el primer rojo de TDD fue un ERROR y el de la iteración 3 un FAIL?
  • ¿Has intentado los «Pruébalo tú» sin mirar la teoría?

Si has respondido que sí a casi todo, estás listo para los ejercicios de pruebas en Python, donde ya no te doy el código hecho. ¡Nos vemos allí!

Siguiente paso: ejerciciosPruebas en Python con unittest — ejercicios con caja negra, pruebas mal diseñadas y TDD de una cuenta bancaria (y el test que siempre pasaba)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Deja una respuesta

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