13. Cobertura de ramas: condicionales, ciclos y caminos alternativos

13.1 Objetivo del tema

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.

Objetivo práctico: reconocer situaciones donde una línea aparece cubierta, pero una rama de decisión sigue sin probar.

13.2 El límite de la cobertura de sentencias

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.

13.3 Qué es una rama

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:

  • Verdadera: se entra al bloque y se calcula el recargo.
  • Falsa: se salta el bloque y el recargo queda en 0.

Para probar el comportamiento completo, necesitamos cubrir ambos caminos.

13.4 Probar 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

13.5 Condicionales con else

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"

13.6 Condicionales encadenados

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.

13.7 Condiciones compuestas

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.

13.8 Ramas en ciclos

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.

13.9 Caminos alternativos por retorno temprano

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

13.10 Señales de ramas sin probar

Aunque todavía no activemos branch coverage, podemos detectar sospechas al leer el código:

  • Condicionales con un solo caso probado: se probó el camino verdadero, pero no el falso.
  • Funciones con varios retornos: falta comprobar alguno de los resultados posibles.
  • Ciclos solo probados con listas cargadas: falta el caso vacío.
  • Condiciones compuestas: faltan combinaciones de valores.

13.11 Relación con casos borde

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

13.12 Errores frecuentes

  • Conformarse con ejecutar la línea del if: puede faltar el camino falso.
  • Probar solo el resultado más común: los caminos alternativos suelen contener reglas importantes.
  • Olvidar listas vacías: un ciclo con cero iteraciones es un camino distinto.
  • No probar combinaciones: las condiciones con and y or necesitan más de un caso.

13.13 Conclusión

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.