Una función puede tener el 100% de cobertura de pruebas unitarias y la página que la llama puede seguir devolviendo un 500 en producción. No es una contradicción. Las pruebas unitarias, de integración y end-to-end comprueban cosas distintas, y una suite cargada en una y floja en otra tiene un hueco específico y predecible.

Qué comprueban las pruebas unitarias, de integración y end-to-end

Toma una funcionalidad de registro: una función valida la entrada, escribe una fila en una base de datos y envía un correo de bienvenida. Tres pruebas pueden cubrirla, y cada una responde una pregunta distinta.

NivelPregunta que respondeQué es real, qué se sustituye
Unitaria¿Esta función hace lo que debe?Todo lo que rodea a la función se sustituye: sin base de datos real, sin red real
Integración¿Esta función y una dependencia real funcionan juntas?La base de datos es real; las partes que siguen fuera del límite que pruebas (el servidor de correo) se sustituyen
End-to-end¿Funciona toda la petición, vista desde fuera?Nada se sustituye. Una petición HTTP real llega a un proceso real en ejecución

Una prueba unitaria te dice que la validación del formato del email rechaza lo que debe rechazar. Una prueba de integración te dice que la fila realmente llega a la base de datos con las columnas que crees que tiene. Una prueba end-to-end te dice que un cliente puede llamar al endpoint y recibir un 201. Ninguna puede sustituir a las otras.

Probar una funcionalidad de Flask en los tres niveles

Aquí está la funcionalidad: una función sign_up que valida un email, crea un usuario y envía un mensaje de bienvenida.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# app.py
import os
import smtplib
import sqlite3

from flask import Flask, jsonify, request


def get_db():
    db_path = os.environ.get("DB_PATH", "app.db")
    return sqlite3.connect(db_path)


def get_smtp_client():
    host = os.environ.get("SMTP_HOST", "localhost")
    port = int(os.environ.get("SMTP_PORT", "25"))
    return smtplib.SMTP(host, port)


def init_db(db):
    db.execute("CREATE TABLE IF NOT EXISTS users (email TEXT PRIMARY KEY, name TEXT)")
    db.commit()


def create_user(db, email, name):
    db.execute("INSERT INTO users (email, name) VALUES (?, ?)", (email, name))
    db.commit()


def send_welcome_email(smtp_client, to_address, name):
    smtp_client.sendmail(
        "[email protected]",
        to_address,
        f"Subject: Welcome\n\nHi {name}, thanks for signing up.",
    )


def sign_up(db, smtp_client, email, name):
    if not email or "@" not in email:
        raise ValueError("invalid email")
    create_user(db, email, name)
    send_welcome_email(smtp_client, email, name)
    return {"email": email, "name": name, "status": "created"}


app = Flask(__name__)


@app.post("/signup")
def signup_route():
    data = request.get_json()
    db = get_db()
    init_db(db)
    smtp_client = get_smtp_client()
    result = sign_up(db, smtp_client, data["email"], data["name"])
    smtp_client.quit()
    return jsonify(result), 201


@app.get("/users/<email>")
def get_user_route(email):
    db = get_db()
    row = db.execute("SELECT email, name FROM users WHERE email = ?", (email,)).fetchone()
    if row is None:
        return jsonify({"error": "not found"}), 404
    return jsonify({"email": row[0], "name": row[1]})

sign_up recibe db y smtp_client como argumentos en vez de usar una conexión global. Esa decisión hace posibles las tres pruebas siguientes: en cada una le pasas algo distinto a esos dos parámetros. get_db() y get_smtp_client() leen su destino de una variable de entorno en cada llamada en lugar de fijarlo una vez, y eso es lo que permite que la prueba end-to-end de más abajo apunte la app en ejecución a una base de datos desechable y a un servidor de correo de prueba local sin tocar app.py.

Prueba unitaria: sustituye las dos dependencias

Una prueba unitaria aísla sign_up por completo. Ni la base de datos ni el cliente SMTP son reales: ambos son objetos Mock de la librería estándar de Python.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# test_unit.py
from unittest.mock import Mock

import pytest

from app import sign_up


def test_sign_up_rejects_invalid_email():
    db, smtp_client = Mock(), Mock()
    with pytest.raises(ValueError):
        sign_up(db, smtp_client, "not-an-email", "Ada")
    db.execute.assert_not_called()


def test_sign_up_sends_welcome_email():
    db, smtp_client = Mock(), Mock()
    sign_up(db, smtp_client, "[email protected]", "Ada")
    smtp_client.sendmail.assert_called_once()
    to_address = smtp_client.sendmail.call_args.args[1]
    assert to_address == "[email protected]"

Aquí entran en juego dos tipos de doble de prueba, y los nombres importan porque marcan expectativas distintas. db en la primera prueba es casi un dummy: se pasa porque la firma exige algo, y a la prueba solo le importa que nunca se toque. smtp_client en la segunda prueba es un mock en sentido estricto. La prueba define la expectativa (sendmail se llama con esta dirección) y falla si la realidad no coincide. Eso es verificación de comportamiento: compruebas que una llamada ocurrió, no qué devolvió.

Estas pruebas corren en milisegundos y nunca tocan disco ni un socket. Tampoco pueden decirte si INSERT INTO users coincide con las columnas reales de la tabla. db nunca ejecuta nada real, así que una errata en el SQL pasaría todas las pruebas unitarias que escribas.

Prueba de integración: una base de datos real, un servidor de correo falso

Una prueba de integración cierra ese hueco una dependencia a la vez. Aquí la base de datos es real, una conexión SQLite en memoria con el esquema real, mientras que el servidor de correo sigue sustituido. Esta vez es un fake: una implementación pequeña que funciona, en lugar de un doble con guion.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# test_integration.py
import sqlite3

from app import init_db, sign_up


class FakeSMTP:
    def __init__(self):
        self.sent = []

    def sendmail(self, from_addr, to_addr, message):
        self.sent.append((to_addr, message))


def test_sign_up_persists_user_and_records_email():
    db = sqlite3.connect(":memory:")
    init_db(db)
    smtp_client = FakeSMTP()

    sign_up(db, smtp_client, "[email protected]", "Grace")

    row = db.execute(
        "SELECT email, name FROM users WHERE email = ?", ("[email protected]",)
    ).fetchone()
    assert row == ("[email protected]", "Grace")
    assert smtp_client.sent[0][0] == "[email protected]"

FakeSMTP no tiene expectativas escritas de antemano como hacía smtp_client.sendmail.assert_called_once(). Mantiene una lista real, aunque diminuta, de lo que se envió, y la prueba la lee después. Esa es la diferencia entre un mock y un fake: a un mock le dices qué esperar antes de que corra el código; un fake se comporta como una versión más pequeña de la cosa real y lo inspeccionas después.

Esta prueba detecta un nombre de columna equivocado o una tabla que falta. No detecta un bug en el parseo de peticiones de Flask ni una URL equivocada en el decorador de la ruta, porque aquí nada pasa por HTTP.

Prueba end-to-end: una petición real contra un proceso en ejecución

Una prueba end-to-end elimina ambas sustituciones. Arranca la app de Flask real como un proceso aparte, le manda una petición HTTP real por un socket real, y comprueba el resultado como lo haría un cliente externo: volviendo a llamar a la API, no husmeando dentro de la base de datos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# test_e2e.py
import multiprocessing
import os
import tempfile
import time

import requests
from aiosmtpd.controller import Controller

from app import app


class AcceptAllHandler:
    async def handle_DATA(self, server, session, envelope):
        return "250 Message accepted for delivery"


def run_server():
    app.run(port=5055)


def test_signup_end_to_end():
    db_fd, db_path = tempfile.mkstemp(suffix=".db")
    os.close(db_fd)
    os.environ["DB_PATH"] = db_path
    os.environ["SMTP_HOST"] = "127.0.0.1"
    os.environ["SMTP_PORT"] = "1025"

    mail_server = Controller(AcceptAllHandler(), hostname="127.0.0.1", port=1025)
    mail_server.start()

    server = multiprocessing.Process(target=run_server)
    server.start()
    time.sleep(0.5)  # dale al servidor de desarrollo de Flask un momento para empezar a escuchar en el puerto

    try:
        signup = requests.post(
            "http://127.0.0.1:5055/signup",
            json={"email": "[email protected]", "name": "Amelia"},
        )
        assert signup.status_code == 201

        lookup = requests.get("http://127.0.0.1:5055/users/[email protected]")
        assert lookup.json() == {"email": "[email protected]", "name": "Amelia"}
    finally:
        server.terminate()
        mail_server.stop()
        os.remove(db_path)

aiosmtpd sustituye al recolector de correo que ejecutarías en un entorno de staging: algo que realmente habla SMTP por un socket, para que la llamada real smtplib.SMTP(...) de la app tenga dónde conectarse. La prueba también se crea un archivo de base de datos desechable en lugar de reutilizar app.db: correr esta prueba dos veces contra un archivo compartido fallaría la segunda vez por la misma PRIMARY KEY que ya ocupa [email protected], justo el tipo de inestabilidad por estado residual del que habla el artículo más adelante. Nada en esta prueba sabe que existen sign_up o create_user. Solo conoce el contrato HTTP. Cambia el nombre de una ruta o la forma del JSON y esta es la prueba que falla, mientras las pruebas unitarias y de integración de arriba siguen pasando.

Instala las dependencias con pip install flask requests aiosmtpd pytest y corre las tres con pytest. Cuando algo se rompe, qué archivo falla te dice qué clase de bug tienes. Esa es la razón para escribir más de un nivel; el porcentaje de cobertura no lo es.

Pirámide de pruebas vs trofeo de pruebas

La forma clásica de una suite de pruebas es la pirámide de pruebas de Mike Cohn: una base ancha de pruebas unitarias rápidas, una capa más pequeña de pruebas de integración y una punta fina de pruebas end-to-end lentas y caras. Lo importante es la proporción, no el número. La mayoría de tus pruebas debería ser del tipo barato.

El trofeo de pruebas de Kent C. Dodds no está de acuerdo en dónde poner el peso. Su argumento: las pruebas unitarias aisladas dan una confianza fácil de conseguir, pero que no refleja cómo se usa el software de verdad, así que una suite hecha sobre todo de pruebas unitarias puede dejar pasar bugs reales en las costuras. El trofeo pone el análisis estático (linting, comprobación de tipos) en la base, una capa unitaria encima, las pruebas de integración como la parte más ancha, y una capa end-to-end fina arriba del todo. test_sign_up_persists_user_and_records_email de arriba es el tipo de prueba que el trofeo quiere en mayor cantidad.

Pirámide (Cohn)Trofeo (Dodds)
Capa más grandeUnitariaIntegración
Capa basePruebas unitariasAnálisis estático (lint, tipos)
Pregunta que guía¿Cuánto cuesta escribir y correr esta prueba?¿Cuánto se parece esta prueba al uso real?
Punto débil que atacaSuites end-to-end lentas y frágilesPruebas unitarias que pasan mientras las costuras están rotas

No sigas ninguna de las dos formas al pie de la letra. Coinciden en el orden: las pruebas end-to-end son las más caras y deberían ser las menos, las unitarias son las más baratas. Dónde va la mayor parte de tu esfuerzo depende del historial de bugs de tu proyecto. Si la mayoría de los bugs son componentes portándose mal por su cuenta, inclínate hacia la pirámide. Si la mayoría son componentes que no se ponen de acuerdo sobre cómo hablarse, inclínate hacia el trofeo.

Por qué las pruebas end-to-end se vuelven inestables

Una suite con una pila creciente de pruebas end-to-end acaba ganándose una reputación: relanzas la que falla y pasa. Eso es inestabilidad, y viene de la misma propiedad que hace valiosas a las pruebas end-to-end. Tocan infraestructura real.

Las causas habituales son un sleep() fijo que es suficiente en tu portátil y demasiado corto en un runner de CI con carga, datos de prueba que deja una ejecución anterior y chocan con la siguiente, una llamada de red a algo fuera de tu control, y estado compartido entre pruebas que solo aparece cuando corren en otro orden o en paralelo. El time.sleep(0.5) de la prueba de arriba es del primer tipo; el archivo temporal que esa misma prueba crea para DB_PATH es una corrección directa para el segundo. Ninguno de estos es un bug en la funcionalidad. Son bugs en cómo la prueba trata la infraestructura real, un problema que las pruebas unitarias nunca tienen.

Relanzar hasta que se ponga en verde no es la solución; esconde el siguiente fallo real entre el ruido. Mantén la capa end-to-end delgada cubriendo las rutas críticas, no cada rama. Dale a cada prueba sus propios datos en vez de fixtures compartidos. Sustituye las esperas fijas por un sondeo de la condición que realmente necesitas, como que el puerto acepte conexiones. El tiempo de CI es el otro costo: si ya aceleraste el pipeline con caché de dependencias en GitHub Actions, una capa end-to-end que necesita tres relanzamientos para ponerse en verde devuelve buena parte de ese tiempo.

Qué prueba escribir a continuación

Si un bug acaba de escaparse a producción, pregúntate qué nivel lo habría detectado antes de añadir ninguna prueba. Un cálculo mal hecho o un caso límite sin gestionar en una función pide una prueba unitaria. Dos piezas de tu propio código que no están de acuerdo sobre una forma o un contrato, como la API devolviendo un campo que el frontend no espera o una consulta que asume una columna que se renombró, pide una prueba de integración. Una funcionalidad que pasa todas las pruebas pero falla para un usuario real suele estar falta de cobertura end-to-end de la ruta que ese usuario siguió.

Escribe primero la prueba unitaria. Es la señal más barata y la más rápida para decirte cuándo has roto la lógica. Añade una prueba de integración cuando dos piezas tuyas necesitan demostrar que están de acuerdo. Reserva las pruebas end-to-end para el puñado de flujos donde “funciona” solo significa algo visto desde fuera: registro, login, checkout, restablecer la contraseña.

Mantén esa separación y consigues una suite cuyos fallos significan algo. Eso importa más allá de la CI. Git bisect con git bisect run necesita un comando de prueba que falle solo cuando el código está realmente roto, y una prueba end-to-end inestable lo mandará al commit equivocado.

Artículos relacionados