En el tema anterior vimos pruebas sobre valores de retorno. Pero no todas las unidades devuelven toda la información importante. Muchas unidades modifican el estado de un objeto.
Un objeto puede cambiar su saldo, su lista de ítems, su estado de aprobación, su contador interno o sus propiedades. En esos casos, la prueba debe verificar cómo quedó el objeto después de ejecutar una acción.
En este tema veremos cómo probar cambios de estado de forma clara, cómo identificar el estado inicial y final, y qué errores conviene evitar.
El estado de un objeto es el conjunto de datos que el objeto conserva en un momento determinado. Por ejemplo, una cuenta bancaria puede tener un saldo; un carrito puede tener una lista de productos; un pedido puede tener un estado como pendiente, aprobado o cancelado.
Ejemplo simple:
class Cuenta:
def __init__(self, saldo):
self.saldo = saldo
En este caso, saldo forma parte del estado de la cuenta. Si un método modifica ese saldo, podemos escribir una prueba sobre cambio de estado.
Una prueba sobre cambios de estado suele seguir esta forma:
Veamos una cuenta con un método para depositar dinero.
class Cuenta:
def __init__(self, saldo):
self.saldo = saldo
def depositar(self, importe):
self.saldo += importe
def test_depositar_incrementa_el_saldo():
cuenta = Cuenta(100)
cuenta.depositar(50)
assert cuenta.saldo == 150
La prueba verifica un cambio de estado:
En una prueba sobre retorno, la información importante vuelve como resultado de la función. En una prueba sobre estado, la información importante queda guardada dentro del objeto.
| Tipo de prueba | Qué se verifica | Ejemplo |
|---|---|---|
| Valor de retorno | Lo que devuelve la función. | assert calcular_total(items) == 300 |
| Cambio de estado | Cómo quedó el objeto después de una acción. | assert cuenta.saldo == 150 |
Otro comportamiento de una cuenta es extraer dinero. La prueba debe verificar que el saldo disminuye correctamente.
class Cuenta:
def __init__(self, saldo):
self.saldo = saldo
def extraer(self, importe):
self.saldo -= importe
def test_extraer_disminuye_el_saldo():
cuenta = Cuenta(100)
cuenta.extraer(30)
assert cuenta.saldo == 70
La prueba no necesita que extraer devuelva un valor. La verificación se hace observando el saldo final.
Un carrito de compras suele mantener una lista de ítems. Agregar un ítem modifica esa lista.
class Carrito:
def __init__(self):
self.items = []
def agregar_item(self, item):
self.items.append(item)
def test_agregar_item_lo_incorpora_al_carrito():
carrito = Carrito()
carrito.agregar_item("teclado")
assert carrito.items == ["teclado"]
El estado inicial es una lista vacía. El estado final esperado es una lista con el ítem agregado.
Algunas unidades acumulan información a lo largo de varias operaciones. En una prueba unitaria conviene mantener el caso simple, pero puede ser necesario ejecutar más de una acción si el comportamiento probado es acumulativo.
def test_agregar_dos_items_los_conserva_en_orden():
carrito = Carrito()
carrito.agregar_item("teclado")
carrito.agregar_item("mouse")
assert carrito.items == ["teclado", "mouse"]
Aquí las dos acciones forman parte del mismo comportamiento: acumular ítems en orden. No estamos mezclando reglas diferentes.
Los cambios de estado suelen estar protegidos por reglas. Por ejemplo, un pedido puede pasar de pendiente a aprobado solo si cumple ciertas condiciones.
class Pedido:
def __init__(self, total):
self.total = total
self.estado = "pendiente"
def aprobar(self):
if self.total > 0:
self.estado = "aprobado"
def test_aprobar_pedido_con_total_positivo_cambia_estado_a_aprobado():
pedido = Pedido(total=100)
pedido.aprobar()
assert pedido.estado == "aprobado"
La prueba verifica que una acción cambia el estado solo bajo una condición válida.
A veces el comportamiento esperado es que el estado permanezca igual. Esto también debe probarse cuando la regla es importante.
def test_aprobar_pedido_con_total_cero_mantiene_estado_pendiente():
pedido = Pedido(total=0)
pedido.aprobar()
assert pedido.estado == "pendiente"
Esta prueba protege el caso negativo: un pedido con total cero no debe aprobarse.
Cuando un objeto tiene muchas propiedades, no siempre conviene verificar todas. La prueba debe enfocarse en el estado relevante para el comportamiento probado.
def test_cancelar_pedido_cambia_estado_a_cancelado():
pedido = Pedido(total=100)
pedido.cancelar()
assert pedido.estado == "cancelado"
Si el objetivo es probar la cancelación, quizá no hace falta verificar el total, la fecha, el cliente y otros datos que no deberían cambiar. Agregar aserciones innecesarias puede volver la prueba frágil.
Conviene verificar el estado observable desde la interfaz pública del objeto. Si una propiedad es parte del contrato de uso, puede comprobarse. Si es un detalle interno, quizá no convenga acoplar la prueba a ella.
Por ejemplo, si una clase ofrece un método obtener_saldo(), puede ser mejor usarlo en la prueba en lugar de leer una variable interna privada.
def test_depositar_incrementa_el_saldo_visible():
cuenta = Cuenta(100)
cuenta.depositar(50)
assert cuenta.obtener_saldo() == 150
Esto ayuda a probar el comportamiento desde la perspectiva de quien usa la clase.
Una prueba está demasiado acoplada cuando depende de cómo el objeto guarda internamente sus datos, aunque ese detalle no forme parte del comportamiento público.
Ejemplo riesgoso:
def test_carrito_interno():
carrito = Carrito()
carrito.agregar_item("teclado")
assert carrito._items_internos[0] == "teclado"
Si _items_internos es un detalle privado, la prueba puede romperse por un refactor que no cambia el comportamiento. Es mejor verificar mediante una propiedad o método público si existe.
Una prueba de estado necesita un estado inicial claro. Si no entendemos cómo empieza el objeto, no podremos interpretar bien el cambio.
def test_reiniciar_contador_deja_valor_en_cero():
contador = Contador()
contador.incrementar()
contador.incrementar()
contador.reiniciar()
assert contador.valor == 0
La preparación deja claro que el contador no estaba en cero por casualidad: primero se incrementó dos veces y luego se reinició.
Si una prueba verifica muchos cambios no relacionados, será difícil diagnosticar una falla. Conviene separar comportamientos independientes.
def test_pedido_confuso():
pedido = Pedido(total=100)
pedido.aprobar()
pedido.agregar_nota("urgente")
pedido.asignar_vendedor("Carlos")
assert pedido.estado == "aprobado"
assert pedido.nota == "urgente"
assert pedido.vendedor == "Carlos"
Esta prueba mezcla aprobación, notas y asignación. Si son reglas independientes, deberían tener pruebas separadas.
Cuando un objeto tiene estados definidos, como pendiente, aprobado o cancelado, conviene probar transiciones importantes.
class Pedido:
def __init__(self):
self.estado = "pendiente"
def cancelar(self):
if self.estado == "pendiente":
self.estado = "cancelado"
def test_pedido_pendiente_puede_cancelarse():
pedido = Pedido()
pedido.cancelar()
assert pedido.estado == "cancelado"
La prueba verifica una transición válida desde pendiente hacia cancelado.
También conviene probar que ciertas transiciones no ocurren si la regla lo prohíbe.
def test_pedido_aprobado_no_se_cancela():
pedido = Pedido()
pedido.estado = "aprobado"
pedido.cancelar()
assert pedido.estado == "aprobado"
La prueba confirma que el método cancelar no cambia el estado cuando el pedido ya está aprobado.
| Unidad | Estado inicial | Acción | Estado esperado |
|---|---|---|---|
| Cuenta | Saldo 100 | Depositar 50 | Saldo 150 |
| Carrito | Sin ítems | Agregar "teclado" | Lista con "teclado" |
| Pedido | Pendiente | Aprobar | Aprobado |
| Contador | Valor 2 | Reiniciar | Valor 0 |
Al escribir una prueba sobre cambios de estado, revisa:
Las pruebas sobre cambios de estado son necesarias cuando una unidad modifica objetos en lugar de devolver toda la información como resultado. Son muy comunes al probar clases, métodos y entidades con comportamiento propio.
La clave está en dejar claro cómo empieza el objeto, qué acción se ejecuta y qué estado final esperamos observar.
En el próximo tema estudiaremos pruebas sobre excepciones y errores esperados, otro tipo de comportamiento importante en unidades robustas.