pruebas en Python unittest caja blanca negra TDD guía

Pruebas en Python — caja blanca, caja negra, TDD y unittest desde cero

Las pruebas en Python son el tema de FP2 que más cuesta arrancar, no porque sea difícil técnicamente, sino porque al principio parece que estás aprendiendo a hacer algo que ya sabes hacer: comprobar si tu programa funciona. La diferencia es que en FP2 aprendes a hacerlo de forma sistemática, reproducible y profesional.

En este artículo vamos desde cero, qué es una prueba, cómo diseñarla bien y cómo ejecutarla automáticamente con unittest. Cuando termines, el grafo de flujo y la complejidad ciclomática ya no van a dar miedo.

¿Qué es exactamente son las pruebas en Python y para qué sirve?

Una prueba es una ejecución controlada de tu código con unos datos de entrada conocidos, comparando el resultado que obtienes con el resultado que esperabas. Si coinciden, la prueba pasa. Si no coinciden, has detectado un defecto.

La clave está en esa palabra: detectado. Una prueba que pasa no demuestra que el código no tiene errores. Solo demuestra que no tiene errores para ese caso concreto. Por eso el objetivo de diseñar pruebas es elegir los casos que tengan más probabilidad de encontrar errores, no los que tengas más seguridad de que van a pasar.

Este cambio de mentalidad es lo primero que cuesta en FP2. Cuando pruebas tu propio código tiendes a elegir los casos donde sabes que funciona. Un buen diseño de pruebas hace exactamente lo contrario, busca los casos donde podría fallar.

La prueba más simple que puedes escribir en Python:

def sumar_pares(lista):
    """Devuelve la suma de los números pares de una lista."""
    return sum(x for x in lista if x % 2 == 0)

# Prueba manual — la forma más básica
def test_sin_pares():
    resultado = sumar_pares([1, 3, 5, 7])
    assert resultado == 0, "Sin números pares el resultado debe ser 0"

def test_todos_pares():
    resultado = sumar_pares([2, 4, 6, 8])
    assert resultado == 20, "La suma no coincide"

test_sin_pares()
test_todos_pares()
print("Todas las pruebas pasaron")

Esto funciona, pero tiene un problema, si la primera prueba falla lanza una excepción y las siguientes no se ejecutan. Para eso existe unittest.

Caja blanca vs caja negra — la diferencia real

Antes de diseñar pruebas tienes que entender desde dónde las diseñas. Hay dos enfoques:

Caja negra — diseñas las pruebas mirando solo la especificación del código, sin ver cómo está implementado por dentro. Es como si la función fuera una caja cerrada, solo sabes qué entra y qué debería salir. No importa si usa un for o un while, si tiene una condición o diez.

Entrada → [???? CAJA NEGRA ????] → Salida esperada

Caja blanca — diseñas las pruebas mirando el código por dentro. Tu objetivo es que cada línea, cada rama de cada if, cada iteración de cada while se ejecute al menos una vez durante las pruebas.

Entrada → [if x > 0:          ] → Salida
          [    return "positivo"]
          [else:               ]
          [    return "negativo"]

La analogía que mejor funciona: imagina que pruebas un semáforo. Con caja negra pruebas que cuando aprietas el botón el semáforo cambia, no te importa cómo funciona el circuito por dentro. Con caja blanca abres la caja y pruebas cada cable, cada relé, cada componente del circuito.

En la práctica los dos enfoques se complementan. Las pruebas de caja negra aseguran que el programa cumple lo que promete. Las de caja blanca aseguran que no hay código muerto ni caminos no probados.

Caja negra — clases de equivalencia

El problema con las pruebas de caja negra es que hay infinitos datos de entrada posibles. No puedes probarlos todos. La solución es dividir todas las entradas posibles en grupos, clases de equivalencia, donde el código se comporta igual para cualquier valor del grupo. Si pruebas un valor de cada grupo, es suficiente.

Ejemplo concreto: tienes una función que valida si una contraseña tiene la longitud correcta (entre 8 y 15 caracteres).

Longitud:  0  1  2  3  4  5  6  7 | 8  9  10  11  12  13  14  15 | 16  17  18...
           [     demasiado corta   ] [        longitud correcta     ] [ demasiado larga ]

Tres clases de equivalencia:

  • Clase 1 — longitudes 0-7 (demasiado cortas) → comportamiento igual para todas
  • Clase 2 — longitudes 8-15 (correctas) → comportamiento igual para todas
  • Clase 3 — longitudes 16+ (demasiado largas) → comportamiento igual para todas

Pruebas mínimas: un valor de cada clase. Por ejemplo: longitud 4, longitud 10, longitud 20.

También existen clases de equivalencia no válidas, valores que técnicamente el tipo de dato permite pero que el problema no contempla. Si el parámetro es int pero el problema solo tiene sentido con positivos, los negativos son una clase no válida.

Caja negra — prueba de los valores límite

La experiencia demuestra que la mayoría de los errores se concentran en los extremos de las clases, no en el centro. Por eso la prueba de valores límite complementa las clases de equivalencia, además de probar un valor central de cada clase, pruebas los valores extremos.

Para la contraseña de antes:

Clase 1 (0-7):   probar 0, 1 y 6, 7
Clase 2 (8-15):  probar 8, 9 y 14, 15
Clase 3 (16+):   probar 16, 17 y algún valor alto

En la práctica para FP2 la regla es: para cada clase prueba el mínimo, el mínimo+1, el máximo-1 y el máximo. Más algún valor central para tener más cobertura.

Caja blanca — el grafo de flujo

Aquí viene la parte que más cuesta visualizar. Las pruebas de caja blanca se basan en el grafo de flujo de ejecución: un diagrama que muestra todos los caminos posibles que puede seguir el código.

Las reglas para construirlo son simples:

  • Cada instrucción o bloque de instrucciones consecutivas → un nodo (círculo)
  • Cada condición (if, while, for) → un nodo predicado (del que salen dos flechas)
  • Cada flecha entre nodos → un arco

Ejemplo con este código:

def es_par_positivo(n):
    if n > 0:          # nodo 1 — condición
        if n % 2 == 0: # nodo 2 — condición
            return True  # nodo 3
        else:
            return False # nodo 4
    else:
        return False     # nodo 5
        [1: n > 0?]
        /          \
    Sí /            \ No
      /              \
[2: n%2==0?]      [5: False]
   /       \
  / Sí      \ No
 /           \
[3: True]  [4: False]

Los nodos 1 y 2 son nodos predicado, tienen dos salidas. Los nodos 3, 4 y 5 son nodos terminales.

Caja blanca — complejidad ciclomática

La complejidad ciclomática te dice cuántos caminos independientes hay en el grafo, y por tanto cuántas pruebas necesitas como mínimo para cubrir todos los arcos.

La forma más fácil de calcularla: cuenta los nodos predicado y súmale 1.

En el ejemplo anterior hay 2 nodos predicado (nodo 1 y nodo 2), así que la complejidad ciclomática es 3. Necesitas 3 pruebas para cubrir todos los caminos.

Los tres caminos básicos son:

  • 1→5 — n ≤ 0 (ej: n=-1)
  • 1→2→4 — n > 0 y n impar (ej: n=3)
  • 1→2→3 — n > 0 y n par (ej: n=4)

Con esas tres pruebas ejecutas cada arco del grafo al menos una vez, eso es cobertura completa de caja blanca.

Un ejemplo más completo con el código del PDF de FP2, la multiplicación rusa:

def multiplicacion_rusa(num1, num2):
    product = 0
    while num1 > 0:          # nodo 2 — predicado
        if num1 % 2 != 0:    # nodo 3 — predicado
            product += num2
        num2 *= 2
        num1 //= 2
    return product

Nodos predicado: 2 (el while) y 3 (el if) → complejidad ciclomática = 3.

Caminos básicos y casos de prueba:

  • Camino 2→fin (while no se ejecuta): num1=0, num2=5 → resultado 0
  • Camino con num1 par: num1=2, num2=5 → resultado 10
  • Camino con num1 impar: num1=1, num2=5 → resultado 5

TDD — Test Driven Development

TDD es una técnica de desarrollo que invierte el orden habitual. En vez de escribir el código y luego las pruebas, escribes primero las pruebas y luego el código que las hace pasar. El ciclo es siempre el mismo:

1. ROJO   → escribe una prueba que falla (el código aún no existe)
2. VERDE  → escribe el código mínimo para que la prueba pase
3. REFACTOR → mejora el código sin cambiar su comportamiento
4. Repite

La ventaja no es obvia al principio, parece que tardas más. La ventaja real es que cuando terminas tienes un código que está probado desde el principio, y un conjunto de pruebas que te garantiza que futuras modificaciones no rompen lo que ya funciona.

Ejemplo de TDD para una clase Racional:

# PASO 1 — escribes la prueba ANTES del código
import unittest

class TestRacional(unittest.TestCase):
    def test_crear_racional(self):
        r = Racional(3, 4)
        self.assertEqual(r.numerador, 3)
        self.assertEqual(r.denominador, 4)

# Esta prueba falla porque Racional no existe todavía — ROJO

# PASO 2 — escribes el código mínimo para que pase
class Racional:
    def __init__(self, numerador, denominador):
        self.numerador = numerador
        self.denominador = denominador
# Ahora la prueba pasa — VERDE

# PASO 3 — refactorizas (en este caso ya está bien)

# PASO 4 — añades la siguiente prueba
class TestRacional(unittest.TestCase):
    def test_crear_racional(self): ...

    def test_denominador_cero(self):
        with self.assertRaises(ZeroDivisionError):
            r = Racional(3, 0)
# Falla de nuevo — ROJO → añades la validación → VERDE → REFACTOR

Unittest — montar y ejecutar pruebas en Python

unittest es el framework oficial de pruebas de Python. Para usarlo:

  1. Importas unittest
  2. Creas una clase que hereda de unittest.TestCase
  3. Cada método que empiece por test es una prueba
  4. Usas los métodos assert* en vez de assert directamente
  5. Ejecutas con unittest.main()
import unittest
from funciones import sumar_pares    # la función que vas a probar

class TestSumarPares(unittest.TestCase):

    def test_lista_sin_pares(self):
        """Una lista sin números pares."""
        resultado = sumar_pares([1, 3, 5, 7])
        self.assertEqual(resultado, 0, "Sin pares debe dar 0")

    def test_lista_todos_pares(self):
        """Una lista donde todos son pares."""
        resultado = sumar_pares([2, 6, 4, 8])
        self.assertEqual(resultado, 20, "La suma no coincide")

    def test_lista_mixta(self):
        """Lista con pares e impares."""
        resultado = sumar_pares([1, 2, 3, 4, 5])
        self.assertEqual(resultado, 6)

    def test_lista_vacia(self):
        """Lista vacía — caso límite."""
        resultado = sumar_pares([])
        self.assertEqual(resultado, 0)

    def test_lanza_excepcion_si_no_es_lista(self):
        """Comprueba que lanza error con tipo incorrecto."""
        with self.assertRaises(TypeError):
            sumar_pares("no soy una lista")

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

Cuando ejecutas este archivo el informe tiene este aspecto:

....F
======================================================================
FAIL: test_lanza_excepcion_si_no_es_lista
----------------------------------------------------------------------
AssertionError: TypeError not raised
----------------------------------------------------------------------
Ran 5 tests in 0.001s

FAILED (failures=1)

Cada . es una prueba que pasa. Cada F es un fallo. Cada E es un error inesperado. El informe te dice exactamente qué prueba falló y por qué.

Los métodos assert más importantes:

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 es None
self.assertIsNotNone(x)         # x no es None
self.assertIn(a, b)             # a in b
self.assertRaises(Error, func)  # func lanza Error
self.assertAlmostEqual(a, b)    # para comparar flotantes

setUp y tearDown — preparar el terreno

Si varias pruebas necesitan los mismos objetos inicializados, setUp se ejecuta antes de cada prueba y tearDown después:

class TestRacional(unittest.TestCase):

    def setUp(self):
        """Se ejecuta antes de cada prueba."""
        self.r1 = Racional(3, 4)
        self.r2 = Racional(1, 2)

    def tearDown(self):
        """Se ejecuta después de cada prueba."""
        pass    # aquí cerrarías ficheros, conexiones, etc.

    def test_suma(self):
        resultado = self.r1 + self.r2
        self.assertEqual(resultado, Racional(5, 4))

    def test_igualdad(self):
        self.assertEqual(Racional(6, 8), Racional(3, 4))

Cómo probar una clase — el orden importa

En FP2 probar una clase tiene una estrategia concreta, no puedes probar la suma si el constructor no funciona. El orden es siempre:

  1. Primero prueba los setters/getters o propiedades
  2. Luego prueba __init__
  3. Luego los métodos que solo leen estado
  4. Finalmente los métodos que modifican estado o dependen de otros

Visualízalo

El concepto que más ayuda a entender caja blanca es dibujarlo en papel antes de escribir las pruebas. Para cualquier función:

  1. Escribe el código con números de línea
  2. Identifica cada condición (if, while, for), esos son tus nodos predicado
  3. Traza las flechas entre bloques
  4. Cuenta los nodos predicado y suma 1, eso es cuántas pruebas necesitas
  5. Encuentra los caminos que cubran todos los arcos

Para funciones simples de FP2 rara vez necesitarás más de 3-5 pruebas de caja blanca.

Resumen rápido

# QUÉ ES UNA PRUEBA
# Entrada conocida + resultado esperado + comparación
# El objetivo es ENCONTRAR errores, no confirmar que funciona

# CAJA NEGRA — desde la especificación
# Clases de equivalencia → grupos de entradas con mismo comportamiento
# Valores límite → probar los extremos de cada clase (min, min+1, max-1, max)

# CAJA BLANCA — desde el código
# Grafo de flujo → nodo por bloque, arco por transición
# Complejidad ciclomática = nodos predicado + 1
# Diseñar casos que ejecuten cada arco al menos una vez

# TDD
# 1. ROJO   → prueba que falla
# 2. VERDE  → código mínimo para pasar
# 3. REFACTOR → mejorar sin romper
# 4. Repetir

# UNITTEST
import unittest

class MisPruebas(unittest.TestCase):
    def setUp(self): ...         # antes de cada prueba
    def tearDown(self): ...      # después de cada prueba

    def test_caso_normal(self):
        self.assertEqual(funcion(entrada), esperado)

    def test_caso_limite(self):
        self.assertRaises(ValueError, funcion, entrada_invalida)

if __name__ == "__main__":
    unittest.main()    # ejecuta todas las pruebas

# MÉTODOS ASSERT ESENCIALES
# assertEqual(a, b)         → a == b
# assertNotEqual(a, b)      → a != b
# assertTrue(x)             → bool(x) True
# assertFalse(x)            → bool(x) False
# assertIsNone(x)           → x is None
# assertIn(a, b)            → a in b
# assertRaises(Error, f)    → f lanza Error
# assertAlmostEqual(a, b)   → para flotantes

# ERRORES TÍPICOS
# 1. Método de prueba sin prefijo test_ → no se ejecuta
# 2. Comparar flotantes con assertEqual → usar assertAlmostEqual
# 3. Diseñar pruebas que solo pasan → buscar las que pueden fallar
# 4. Olvidar el caso vacío y el caso límite
# 5. Probar métodos complejos antes de probar el constructor

En el próximo artículo practicamos y en los ejercicios diseñamos pruebas completas para clases de FP2, incluyendo el conjunto completo de pruebas para la clase Racional.


Testing in Python — white box, black box, TDD and unittest from scratch

Testing in Python is the FP2 topic that’s hardest to get started with, not because it’s technically difficult, but because at first it feels like you’re learning to do something you already know: checking if your program works. The difference is that in FP2 you learn to do it systematically, reproducibly, and professionally.

What is a test and what is it for?

A test is a controlled execution of your code with known input data, comparing the result you get with the result you expected. If they match, the test passes. If not, you’ve detected a defect.

The key is in that word, detected. A passing test doesn’t prove the code has no errors. It only proves it has no errors for that specific case. That’s why designing tests means choosing the cases most likely to find errors, not the ones you’re most confident will pass.

Black box vs white box — the real difference

Black box — you design tests looking only at the specification, not the implementation. The function is a closed box, you only know what goes in and what should come out.

White box — you design tests looking at the code inside. Your goal is for every line, every branch of every if, every loop iteration to execute at least once.

The best analogy: testing a traffic light. With black box you test that pressing the button changes the light, you don’t care how the circuit works. With white box you open the box and test every wire, every relay, every component.

Black box — equivalence classes

Divide all possible inputs into groups, equivalence classes, where the code behaves the same for any value in the group. Testing one value per group is enough.

Example: password length validation (must be 8-15 characters).

Length:  0-7          |  8-15          |  16+
         [too short]  |  [correct]     |  [too long]

Three classes — test one value from each: length 4, length 10, length 20.

Black box — boundary value testing

Most errors cluster at the extremes, not the centre. For each class test: minimum, minimum+1, maximum-1, maximum.

For the password: test lengths 0, 1, 6, 7 (class 1), 8, 9, 14, 15 (class 2), 16, 17 (class 3).

White box — the flow graph

Rules to build it:

  • Each instruction block → a node
  • Each condition (if, while, for) → a predicate node (two outgoing arrows)
  • Each arrow → an arc
def is_positive_even(n):
    if n > 0:          # predicate node 1
        if n % 2 == 0: # predicate node 2
            return True  # node 3
        else:
            return False # node 4
    else:
        return False     # node 5
        [1: n > 0?]
        /          \
    Yes/            \No
      /              \
[2: n%2==0?]      [5: False]
   /       \
  /Yes      \No
 /           \
[3: True]  [4: False]

White box — cyclomatic complexity

Count predicate nodes and add 1.

In the example: 2 predicate nodes → complexity = 3 → need 3 tests.

The three basic paths:

  • 1→5: n ≤ 0 (e.g. n=-1)
  • 1→2→4: n > 0 and odd (e.g. n=3)
  • 1→2→3: n > 0 and even (e.g. n=4)

TDD — Test Driven Development

1. RED    → write a failing test (code doesn't exist yet)
2. GREEN  → write minimum code to pass the test
3. REFACTOR → improve without breaking
4. Repeat
# STEP 1 — write the test BEFORE the code
import unittest

class TestRational(unittest.TestCase):
    def test_create_rational(self):
        r = Rational(3, 4)
        self.assertEqual(r.numerator, 3)
        self.assertEqual(r.denominator, 4)
# Test fails — RED

# STEP 2 — minimum code to pass
class Rational:
    def __init__(self, numerator, denominator):
        self.numerator = numerator
        self.denominator = denominator
# Test passes — GREEN

# STEP 3 — refactor if needed
# STEP 4 — add next test

Unittest

import unittest
from functions import sum_evens

class TestSumEvens(unittest.TestCase):

    def setUp(self):
        """Runs before each test."""
        pass

    def test_no_evens(self):
        """List with no even numbers."""
        self.assertEqual(sum_evens([1, 3, 5, 7]), 0)

    def test_all_evens(self):
        """List where all are even."""
        self.assertEqual(sum_evens([2, 6, 4, 8]), 20)

    def test_mixed(self):
        self.assertEqual(sum_evens([1, 2, 3, 4, 5]), 6)

    def test_empty(self):
        """Empty list — boundary case."""
        self.assertEqual(sum_evens([]), 0)

    def test_raises_with_wrong_type(self):
        with self.assertRaises(TypeError):
            sum_evens("not a list")

if __name__ == "__main__":
    unittest.main()
....F
FAIL: test_raises_with_wrong_type
AssertionError: TypeError not raised
Ran 5 tests in 0.001s
FAILED (failures=1)

Each . = passing test. F = failure. E = unexpected error.

Essential assert methods:

self.assertEqual(a, b)
self.assertNotEqual(a, b)
self.assertTrue(x)
self.assertFalse(x)
self.assertIsNone(x)
self.assertIn(a, b)
self.assertRaises(Error, func)
self.assertAlmostEqual(a, b)    # for floats

Quick summary

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

# BLACK BOX — from specification
# Equivalence classes → groups with same behaviour
# Boundary values → min, min+1, max-1, max of each class

# WHITE BOX — from code
# Flow graph → node per block, arc per transition
# Cyclomatic complexity = predicate nodes + 1
# Design cases that execute every arc at least once

# TDD
# 1. RED    → failing test
# 2. GREEN  → minimum code to pass
# 3. REFACTOR → improve without breaking

# UNITTEST
import unittest
class MyTests(unittest.TestCase):
    def setUp(self): ...
    def test_something(self):
        self.assertEqual(function(input), expected)
    def test_raises(self):
        with self.assertRaises(ValueError):
            function(bad_input)

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

# COMMON MISTAKES
# 1. Test method without test_ prefix → doesn't run
# 2. Comparing floats with assertEqual → use assertAlmostEqual
# 3. Only designing tests you know will pass
# 4. Forgetting empty case and boundary case
# 5. Testing complex methods before testing the constructor

Publicaciones Similares

2 comentarios

Deja una respuesta

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