34. Pruebas unitarias de clases y métodos

34.1 Introducción

En una aplicación orientada a objetos, muchas unidades de código son clases y métodos. Probar una clase no significa revisar cada línea interna, sino verificar los comportamientos que la clase ofrece a través de sus métodos públicos.

Este tema muestra cómo pensar las pruebas cuando una unidad tiene estado, métodos que lo modifican y métodos que calculan resultados a partir de ese estado.

34.2 Qué es la unidad cuando probamos una clase

Al probar una clase, la unidad puede ser la clase completa o un método específico dentro de ella. La decisión depende de la responsabilidad que queramos verificar.

En general, conviene probar la clase desde su interfaz pública: crear un objeto, llamar sus métodos y verificar el resultado observable.

34.3 Probar métodos que devuelven valores

El caso más simple aparece cuando un método calcula y devuelve un valor sin modificar el estado del objeto.

class Calculadora:
    def sumar(self, a, b):
        return a + b


def test_sumar_devuelve_la_suma_de_dos_numeros():
    calculadora = Calculadora()

    resultado = calculadora.sumar(4, 6)

    assert resultado == 10

La prueba crea el objeto, ejecuta el método y verifica el valor devuelto.

34.4 Probar métodos que usan estado interno

Una clase suele guardar datos en atributos. Cuando un método usa ese estado para calcular una respuesta, la prueba debe preparar el objeto en un estado conocido.

class Cuenta:
    def __init__(self, saldo):
        self.saldo = saldo

    def saldo_disponible(self):
        return self.saldo


def test_saldo_disponible_devuelve_el_saldo_actual():
    cuenta = Cuenta(1500)

    assert cuenta.saldo_disponible() == 1500

34.5 Probar métodos que cambian estado

Cuando un método modifica el estado del objeto, la prueba debe verificar el cambio observable después de ejecutarlo.

class Contador:
    def __init__(self):
        self.valor = 0

    def incrementar(self):
        self.valor += 1

    def obtener_valor(self):
        return self.valor


def test_incrementar_aumenta_el_valor_en_uno():
    contador = Contador()

    contador.incrementar()

    assert contador.obtener_valor() == 1

La prueba no necesita conocer cada detalle interno, solo el efecto que el método produce.

34.6 Evitar depender de atributos internos

Si una clase ofrece métodos públicos para consultar su estado, es preferible usarlos en la prueba en lugar de acceder directamente a atributos internos.

class Carrito:
    def __init__(self):
        self._productos = []

    def agregar(self, producto):
        self._productos.append(producto)

    def cantidad(self):
        return len(self._productos)


def test_agregar_producto_incrementa_la_cantidad():
    carrito = Carrito()

    carrito.agregar("mouse")

    assert carrito.cantidad() == 1

Si más adelante la clase cambia la forma de guardar productos, la prueba puede seguir siendo válida mientras el comportamiento público no cambie.

34.7 Probar una secuencia pequeña de operaciones

Algunas clases solo muestran su comportamiento después de una secuencia de llamadas. Esa secuencia debe ser breve y clara.

class Pila:
    def __init__(self):
        self._items = []

    def apilar(self, item):
        self._items.append(item)

    def desapilar(self):
        return self._items.pop()


def test_desapilar_devuelve_el_ultimo_elemento_apilado():
    pila = Pila()
    pila.apilar("primero")
    pila.apilar("segundo")

    resultado = pila.desapilar()

    assert resultado == "segundo"

34.8 Probar el estado inicial

El estado inicial de un objeto puede ser parte importante de su contrato. Si otros métodos dependen de ese estado, conviene probarlo.

class Pedido:
    def __init__(self):
        self._confirmado = False

    def esta_confirmado(self):
        return self._confirmado


def test_pedido_inicia_sin_confirmar():
    pedido = Pedido()

    assert pedido.esta_confirmado() is False

34.9 Probar transiciones de estado

Una transición ocurre cuando un objeto pasa de un estado a otro. Las pruebas deben mostrar qué acción provoca el cambio.

class Pedido:
    def __init__(self):
        self._confirmado = False

    def confirmar(self):
        self._confirmado = True

    def esta_confirmado(self):
        return self._confirmado


def test_confirmar_cambia_el_estado_del_pedido():
    pedido = Pedido()

    pedido.confirmar()

    assert pedido.esta_confirmado() is True

34.10 Probar métodos con reglas simples

Cuando un método contiene una condición, la prueba debe cubrir los ejemplos relevantes de esa regla.

class Cliente:
    def __init__(self, puntos):
        self.puntos = puntos

    def es_vip(self):
        return self.puntos >= 1000


def test_cliente_con_mil_puntos_es_vip():
    cliente = Cliente(1000)

    assert cliente.es_vip() is True

El valor 1000 es importante porque está justo en el límite de la condición.

34.11 Probar métodos que agregan elementos

Cuando un método agrega información a una colección interna, se debe verificar el resultado observable de esa operación.

class ListaDeTareas:
    def __init__(self):
        self._tareas = []

    def agregar(self, descripcion):
        self._tareas.append(descripcion)

    def cantidad(self):
        return len(self._tareas)


def test_agregar_tarea_incrementa_la_cantidad():
    lista = ListaDeTareas()

    lista.agregar("Estudiar pruebas unitarias")

    assert lista.cantidad() == 1

34.12 Probar métodos que eliminan elementos

Las operaciones de eliminación pueden tener errores por índices, búsquedas incorrectas o estados vacíos. Por eso conviene probar el efecto visible.

class ListaDeTareas:
    def __init__(self):
        self._tareas = []

    def agregar(self, descripcion):
        self._tareas.append(descripcion)

    def eliminar(self, descripcion):
        self._tareas.remove(descripcion)

    def contiene(self, descripcion):
        return descripcion in self._tareas


def test_eliminar_tarea_la_quita_de_la_lista():
    lista = ListaDeTareas()
    lista.agregar("Comprar pan")

    lista.eliminar("Comprar pan")

    assert lista.contiene("Comprar pan") is False

34.13 Probar errores esperados en métodos

Un método puede rechazar una operación inválida. Esa situación también forma parte del comportamiento de la clase.

class Cuenta:
    def __init__(self, saldo):
        self.saldo = saldo

    def retirar(self, importe):
        if importe > self.saldo:
            raise ValueError("Saldo insuficiente")
        self.saldo -= importe


def test_retirar_mas_que_el_saldo_genera_error():
    cuenta = Cuenta(500)

    try:
        cuenta.retirar(800)
        assert False
    except ValueError as error:
        assert str(error) == "Saldo insuficiente"

34.14 Probar que una operación inválida no modifica el estado

Además de verificar el error, a veces importa confirmar que el objeto queda igual después de una operación rechazada.

class Cuenta:
    def __init__(self, saldo):
        self.saldo = saldo

    def retirar(self, importe):
        if importe > self.saldo:
            raise ValueError("Saldo insuficiente")
        self.saldo -= importe


def test_retiro_rechazado_no_cambia_el_saldo():
    cuenta = Cuenta(500)

    try:
        cuenta.retirar(800)
    except ValueError:
        pass

    assert cuenta.saldo == 500

34.15 Probar métodos que colaboran con otro objeto

Una clase puede delegar parte de su trabajo en una dependencia. Para mantener la prueba unitaria, podemos usar un objeto simple que reemplace esa dependencia.

class Facturador:
    def __init__(self, repositorio):
        self.repositorio = repositorio

    def registrar_factura(self, numero):
        self.repositorio.guardar(numero)


class RepositorioFalso:
    def __init__(self):
        self.guardados = []

    def guardar(self, numero):
        self.guardados.append(numero)


def test_registrar_factura_guarda_el_numero():
    repositorio = RepositorioFalso()
    facturador = Facturador(repositorio)

    facturador.registrar_factura("A-0001")

    assert repositorio.guardados == ["A-0001"]

34.16 Probar un método por comportamiento

Una prueba debe nombrar el comportamiento, no el detalle de implementación. Si el método ordena internamente, filtra o usa una estructura auxiliar, la prueba debe enfocarse en el resultado que recibe quien usa la clase.

class Catalogo:
    def __init__(self, productos):
        self.productos = productos

    def disponibles(self):
        return [producto for producto in self.productos if producto["stock"] > 0]


def test_disponibles_devuelve_solo_productos_con_stock():
    catalogo = Catalogo([
        {"nombre": "teclado", "stock": 3},
        {"nombre": "monitor", "stock": 0}
    ])

    resultado = catalogo.disponibles()

    assert resultado == [{"nombre": "teclado", "stock": 3}]

34.17 Evitar pruebas demasiado acopladas

Una prueba está demasiado acoplada cuando se rompe ante cualquier refactorización interna, aunque la clase siga funcionando igual.

Para evitarlo, prueba desde los métodos públicos, usa datos simples y evita verificar variables temporales, atributos privados o pasos internos que no forman parte del contrato de la clase.

34.18 Preparación clara del objeto

La preparación del objeto debe ser fácil de leer. Si una prueba necesita muchos pasos para llegar al estado requerido, conviene revisar si la clase tiene demasiadas responsabilidades o si la prueba está intentando cubrir demasiado.

def test_carrito_vacio_tiene_total_cero():
    carrito = Carrito()

    assert carrito.cantidad() == 0

Una preparación breve ayuda a que la intención de la prueba se entienda rápidamente.

34.19 Tabla de situaciones habituales

Esta tabla resume qué observar al probar clases y métodos.

Situación Qué verificar Ejemplo
Método que devuelve valor Resultado devuelto sumar(4, 6) == 10
Método que cambia estado Estado observable posterior contador.obtener_valor() == 1
Método que rechaza operación Error esperado ValueError
Método que usa dependencia Interacción relevante Repositorio falso recibió el dato

34.20 Qué debes recordar

Al probar clases y métodos, ten presentes estas ideas:

  • Prueba la clase desde su interfaz pública.
  • Prepara el objeto en un estado conocido.
  • Verifica valores devueltos, cambios de estado o errores esperados.
  • Evita depender de detalles internos innecesarios.
  • Usa dependencias falsas simples cuando un método colabora con otro objeto.

34.21 Conclusión

Las pruebas unitarias de clases y métodos se enfocan en el comportamiento observable de los objetos. Una clase puede devolver valores, cambiar estado, rechazar operaciones o colaborar con otra dependencia, y cada caso requiere una verificación clara.

La clave es no probar la clase desde sus detalles internos, sino desde lo que promete a quien la usa. Así las pruebas acompañan la evolución del código sin romperse ante cada cambio de implementación.