La cobertura de sentencias responde si una línea se ejecutó. La cobertura de ramas va un paso más allá: revisa si las decisiones del programa tomaron todos sus caminos relevantes.
En este tema vamos a entender el problema con ejemplos de condicionales, ciclos y caminos alternativos. En el próximo tema activaremos formalmente branch coverage en coverage.py.
Observa esta función:
def calcular_recargo(total, envio_urgente):
recargo = 0
if envio_urgente:
recargo = total * 0.15
return recargo
Con esta prueba, todas las líneas pueden quedar ejecutadas:
def test_calcular_recargo_urgente():
assert calcular_recargo(1000, True) == 150
Pero todavía no probamos qué pasa cuando envio_urgente es False. La línea del if fue ejecutada, pero solo se tomó uno de sus caminos.
Una rama es una salida posible de una decisión. En un if, normalmente hay dos ramas: condición verdadera y condición falsa.
if envio_urgente:
recargo = total * 0.15
Las ramas son:
0.Para probar el comportamiento completo, necesitamos cubrir ambos caminos.
Agregamos una prueba para el caso no urgente:
def test_calcular_recargo_no_urgente():
assert calcular_recargo(1000, False) == 0
Ahora las pruebas verifican las dos decisiones posibles del condicional.
def test_calcular_recargo_urgente():
assert calcular_recargo(1000, True) == 150
def test_calcular_recargo_no_urgente():
assert calcular_recargo(1000, False) == 0
En un if con else, las ramas son más visibles:
def clasificar_stock(cantidad):
if cantidad == 0:
return "sin stock"
else:
return "disponible"
Una sola prueba no alcanza para cubrir el comportamiento completo:
def test_clasificar_stock_disponible():
assert clasificar_stock(5) == "disponible"
También falta el camino donde cantidad == 0:
def test_clasificar_stock_sin_stock():
assert clasificar_stock(0) == "sin stock"
Los elif representan varios caminos alternativos:
def clasificar_cliente(compras):
if compras >= 20:
return "oro"
elif compras >= 5:
return "plata"
else:
return "bronce"
Para cubrir bien la decisión, necesitamos casos que lleguen a cada resultado:
def test_clasificar_cliente_oro():
assert clasificar_cliente(20) == "oro"
def test_clasificar_cliente_plata():
assert clasificar_cliente(5) == "plata"
def test_clasificar_cliente_bronce():
assert clasificar_cliente(4) == "bronce"
Estos casos también prueban límites donde cambia la clasificación.
Una condición con and u or puede ocultar caminos importantes:
def puede_comprar_con_descuento(es_vip, total):
if es_vip and total >= 1000:
return True
return False
No alcanza con probar solo el caso exitoso. Conviene revisar combinaciones relevantes:
import pytest
@pytest.mark.parametrize(
"es_vip, total, esperado",
[
(True, 1000, True),
(True, 999, False),
(False, 1000, False),
],
)
def test_puede_comprar_con_descuento(es_vip, total, esperado):
assert puede_comprar_con_descuento(es_vip, total) is esperado
La parametrización ayuda a hacer visibles las combinaciones que importan.
Los ciclos también tienen caminos: pueden ejecutarse cero veces, una vez o varias veces.
def sumar_positivos(numeros):
total = 0
for numero in numeros:
if numero > 0:
total += numero
return total
Casos útiles:
def test_sumar_positivos_lista_vacia():
assert sumar_positivos([]) == 0
def test_sumar_positivos_ignora_negativos():
assert sumar_positivos([-2, 3, -1, 4]) == 7
def test_sumar_positivos_sin_positivos():
assert sumar_positivos([-2, -1]) == 0
Estos casos recorren caminos distintos del ciclo y del condicional interno.
Los retornos tempranos también crean caminos alternativos:
def obtener_descuento(cliente):
if cliente is None:
return 0
if cliente.get("vip"):
return 20
return 5
Las pruebas deberían cubrir cliente ausente, cliente VIP y cliente común:
def test_obtener_descuento_sin_cliente():
assert obtener_descuento(None) == 0
def test_obtener_descuento_cliente_vip():
assert obtener_descuento({"vip": True}) == 20
def test_obtener_descuento_cliente_comun():
assert obtener_descuento({"vip": False}) == 5
Aunque todavía no activemos branch coverage, podemos detectar sospechas al leer el código:
La cobertura de ramas y los casos borde están muy relacionados. Cuando una condición usa >=, < o ==, los valores cercanos al límite suelen activar ramas diferentes.
def envio_gratis(total):
return total >= 50000
Casos recomendables:
def test_envio_gratis_debajo_del_limite():
assert envio_gratis(49999) is False
def test_envio_gratis_en_el_limite():
assert envio_gratis(50000) is True
and y or necesitan más de un caso.En este tema vimos que una línea cubierta no siempre significa que todos sus caminos fueron probados. Los condicionales, ciclos, retornos tempranos y condiciones compuestas pueden dejar ramas sin recorrer.
En el próximo tema activaremos branch coverage para que coverage.py nos muestre esas decisiones parcialmente cubiertas.