11. Diferencia entre probar comportamiento y probar implementación

11.1 Objetivo del tema

En este tema aprenderemos una distinción clave para practicar TDD: probar comportamiento no es lo mismo que probar implementación. Una buena prueba debería verificar qué hace el sistema desde afuera, no cómo está construido internamente.

Si una prueba depende demasiado de los detalles internos, cualquier refactor puede romperla aunque el comportamiento siga siendo correcto.

Objetivo práctico: escribir pruebas que permitan refactorizar el código sin romper la suite cuando el comportamiento no cambia.

11.2 Qué es comportamiento

El comportamiento es lo que un usuario, otra función o un módulo cliente puede observar. En una función pura, suele ser el valor que devuelve para una entrada determinada.

Por ejemplo, si una función calcula el total de un carrito, el comportamiento observable es el total devuelto.

assert calcular_total(productos) == 75

11.3 Qué es implementación

La implementación es la forma interna en que el código logra ese comportamiento. Puede usar un ciclo for, una comprensión, sum, funciones auxiliares o clases.

Dos implementaciones distintas pueden producir exactamente el mismo resultado. Si las pruebas están bien orientadas al comportamiento, ambas deberían pasar.

11.4 Caso práctico: total de carrito

Usaremos este requisito:

El total del carrito es la suma de precio por cantidad de cada producto.

Este requisito habla del resultado, no de si debemos usar un ciclo, una lista intermedia o sum.

11.5 Prueba orientada al comportamiento

Esta prueba verifica el resultado observable:

Archivo a crear: tests/test_carrito.py

from tienda.carrito import calcular_total


def test_total_de_carrito_suma_precio_por_cantidad():
    productos = [
        {"nombre": "Libro", "precio": 30, "cantidad": 2},
        {"nombre": "Lápiz", "precio": 5, "cantidad": 3},
    ]

    total = calcular_total(productos)

    assert total == 75

La prueba no dice cómo debe calcularse el total. Solo expresa qué resultado se espera.

11.6 Primera implementación válida

Una implementación posible usa un ciclo:

Archivo a crear: src/tienda/carrito.py

def calcular_total(productos):
    total = 0
    for producto in productos:
        total += producto["precio"] * producto["cantidad"]
    return total

Ejecutamos:

python -m pytest

La prueba debería pasar.

11.7 Segunda implementación válida

Podemos refactorizar usando sum:

Archivo a modificar: src/tienda/carrito.py

def calcular_total(productos):
    return sum(
        producto["precio"] * producto["cantidad"]
        for producto in productos
    )

El comportamiento es el mismo. Por lo tanto, la prueba debería seguir pasando.

11.8 Prueba atada a la implementación

Una prueba mala intentaría verificar detalles internos. Por ejemplo, podría exigir que exista una función auxiliar concreta:

from tienda.carrito import calcular_subtotal_producto


def test_calcula_subtotal_de_producto():
    producto = {"nombre": "Libro", "precio": 30, "cantidad": 2}

    assert calcular_subtotal_producto(producto) == 60

Esta prueba puede ser útil si calcular_subtotal_producto es parte pública del diseño. Pero si solo era un detalle interno, la prueba hará más difícil refactorizar.

11.9 Señales de prueba frágil

Una prueba probablemente está demasiado atada a la implementación si:

  • Falla después de un refactor aunque el resultado público sigue correcto.
  • Depende de funciones auxiliares que no forman parte de la API pública.
  • Verifica el orden de pasos internos sin necesidad de negocio.
  • Obliga a mantener una estructura que ya no aporta claridad.

11.10 API pública en un módulo pequeño

En este ejemplo, la función pública es calcular_total. El resto puede cambiar si lo necesitamos.

Una prueba enfocada en la API pública tiene más valor a largo plazo:

assert calcular_total(productos) == 75

Mientras esa afirmación siga siendo verdadera, podemos mejorar la implementación interna.

11.11 TDD no prohíbe probar funciones pequeñas

No significa que nunca podamos probar funciones auxiliares. Si una función auxiliar representa una regla de negocio importante y estable, puede merecer pruebas propias.

La pregunta es: ¿esta función es un comportamiento que queremos sostener o solo una decisión interna actual?

11.12 Ejemplo con validador de contraseñas

Supongamos este código:

LONGITUD_MINIMA_PASSWORD = 8


def tiene_longitud_minima(contrasena):
    return len(contrasena) >= LONGITUD_MINIMA_PASSWORD


def es_password_valido(contrasena):
    return tiene_longitud_minima(contrasena)

Si tiene_longitud_minima solo existe para organizar el código, puede ser suficiente probar es_password_valido.

11.13 Prueba recomendada

La prueba pública puede ser:

from seguridad.password import es_password_valido


def test_password_es_valido_si_tiene_ocho_caracteres():
    assert es_password_valido("abcdefgh") is True

Esta prueba seguirá pasando aunque más adelante eliminemos, renombremos o reorganicemos tiene_longitud_minima.

11.14 Prueba demasiado interna

Esta prueba ata la suite a una función auxiliar:

from seguridad.password import tiene_longitud_minima


def test_tiene_longitud_minima():
    assert tiene_longitud_minima("abcdefgh") is True

Puede ser correcta si decidimos que esa función es pública. Si no, reduce libertad para refactorizar.

11.15 Refactor que no debería romper pruebas

Podemos eliminar la función auxiliar:

Archivo a modificar: src/seguridad/password.py

LONGITUD_MINIMA_PASSWORD = 8


def es_password_valido(contrasena):
    return len(contrasena) >= LONGITUD_MINIMA_PASSWORD

Las pruebas de es_password_valido deberían seguir pasando. Las pruebas de tiene_longitud_minima fallarían aunque el comportamiento público siga igual.

11.16 Cómo decidir qué probar

Antes de escribir una prueba, pregúntate:

  • ¿Estoy verificando un resultado observable?
  • ¿Esta función o clase forma parte del diseño público que quiero conservar?
  • ¿La prueba seguiría teniendo sentido si cambio la implementación interna?
  • ¿La prueba describe una regla de negocio o un detalle técnico?

11.17 Cuándo sí probar implementación

Hay casos donde detalles técnicos importan: rendimiento, orden de llamadas a una dependencia externa, uso de una transacción o comunicación con una API. Pero esos casos deben tener una razón concreta.

En este curso todavía estamos priorizando lógica de dominio y funciones puras, por eso conviene favorecer pruebas orientadas al comportamiento.

11.18 Errores frecuentes

  • Probar cada función auxiliar: puede convertir detalles internos en contratos rígidos.
  • Duplicar la implementación en la prueba: la prueba deja de aportar confianza real.
  • Verificar pasos internos sin necesidad: vuelve frágil el refactor.
  • Confundir cobertura con calidad: muchas pruebas internas no siempre significan mejor diseño.
  • Olvidar el usuario del código: las pruebas deberían mirar el comportamiento que alguien consume.

11.19 Ejercicio propuesto

Revisa una prueba de los temas anteriores y responde:

  • ¿La prueba verifica comportamiento observable?
  • ¿Depende de una función auxiliar interna?
  • ¿Seguiría pasando si refactorizas la implementación?
  • ¿Puedes reescribirla para que use solo la API pública?

Luego ejecuta python -m pytest para comprobar que la suite sigue en verde.

11.20 Lista de verificación

Antes de continuar, verifica lo siguiente:

  • Comprendes la diferencia entre comportamiento e implementación.
  • Las pruebas principales verifican resultados observables.
  • Evitas depender de auxiliares internos sin necesidad.
  • La suite permite refactorizar sin romperse artificialmente.
  • Las pruebas describen reglas de negocio, no pasos internos.
  • Ejecutaste python -m pytest después de cambiar pruebas o código.

11.21 Conclusión

En este tema vimos que las pruebas más valiosas en TDD suelen enfocarse en comportamiento observable. Eso nos permite mejorar la implementación interna sin romper pruebas que no deberían romperse.

En el próximo tema aplicaremos estas ideas al diseño incremental de funciones puras, donde cada ejemplo empuja el código hacia una solución más clara.