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.
Tabla de Contenidos
Antes de empezar
Conviene tener claro:
- Que una clase de tests hereda de
unittest.TestCasey que solo se ejecutan los métodos que empiezan portest. - 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
FAILes unassertque no se cumple yERROR, 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?
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.
| Clase | Rango | Resultado | Representativo |
|---|---|---|---|
| CE1 | 0 ≤ nota < 5 | Suspenso | 3.2 |
| CE2 | 5 ≤ nota < 7 | Aprobado | 6 |
| CE3 | 7 ≤ nota < 9 | Notable | 7.8 |
| CE4 | 9 ≤ nota ≤ 10 | Sobresaliente | 9.5 |
| CE5 | nota < 0 | ValueError | −2 |
| CE6 | nota > 10 | ValueError | 11 |
| CE7 | no es un número | TypeError | '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 PythonTruees unint: sin esa línea,clasificar_nota(True)devolvería «Suspenso» (True vale 1).- Después de validar, los
ifvan de menor a mayor y cada uno solo necesita comparar con el límite de arriba: si llegamos anota < 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 < 7pornota <= 7en 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)
Construyendo el grafo con el código delante:
- Nodo 1:
descuento = 0. Nodo 2: ¿compra >= 50? (predicado). - Si sí, nodo 3 (
descuento = 5) y nodo 4: ¿puntos >= 100? (predicado, dentro del primero). Si sí, nodo 5. - 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). - 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:
| Camino | Nodos | Datos | Cuenta | Esperado |
|---|---|---|---|---|
| 1 | 1-2-6-8 | 30 €, 50 pts, no premium | 30 < 50 y no premium | 0 |
| 2 | 1-2-6-7-8 | 30 €, 50 pts, premium | 0 + 5 | 5 |
| 3 | 1-2-3-4-6-8 | 60 €, 50 pts, no premium | 60 ≥ 50 → 5; 50 < 100 | 5 |
| 4 | 1-2-3-4-5-6-7-8 | 60 €, 150 pts, premium | 5 → 10 (puntos) → 10 + 5 | 15 |
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 >= 100para que esté fuera del primerif(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?
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
_celsiuses un@staticmethodporque 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
Temperaturanueva y no tocan la original. __eq__compara en Celsius y con un margen (< 1e-9): en Python, (80.6 − 32) × 5 / 9 da26.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 usanassertAlmostEqual, 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():Trueentre 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écnica | Pasos | Lo que aporta |
|---|---|---|
| Caja negra | Especificación → clases válidas y no válidas → un representativo por clase → los dos lados de cada frontera | Casos que el programador olvidó |
| Caja blanca | Código → nodos y aristas → V(G) → un caso por camino básico | Ninguna rama sin ejecutar |
| TDD | Test que falla → código mínimo → refactor → siguiente requisito | Có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
ifsinelse: 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:
- En cada vuelta del
for, sigue la flecha dentro dedescuento_cliente: verás que cada caso pasa por un camino distinto del grafo. - En el caso (30, 50, False) la flecha salta directamente del primer
ifal de premium: es la arista 2→6 que muchos olvidan. - En el caso (60, 150, True) la flecha entra en los tres
if: es el único que pasa por el nodo 5. - La última columna (
True) es elassertEqual: 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_notasolo con la especificación? - ¿Te sale V(G) = 4 en
descuento_clientepor las dos fórmulas? - ¿Sabes por qué el primer rojo de TDD fue un
ERRORy el de la iteración 3 unFAIL? - ¿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 →




Qué es un sistema operativo — teoría con tu portátil en una tarde de estudio, una llamada a write() y la CPU que nunca descansa (y los tres programas que no se ejecutaban a la vez)