Módulo 6: Puertas de calidad — cobertura y umbrales que rompen el build

5. Puertas por marcador y tests smoke

Descripción

Hasta aquí, toda puerta miró la cobertura: un porcentaje contra un umbral. Pero en la lección 2 estableciste que la cobertura no tiene nada de especial —cualquier métrica con un umbral y una consecuencia es una puerta—. Esta lección lo demuestra cambiando la métrica: de "¿qué porcentaje del código se ejecutó?" a "¿pasó este subconjunto de tests críticos?". Es la puerta por marcador, y su caso más común es la puerta smoke: un job de CI que corre solo los tests marcados como smoke —los pocos que verifican que lo esencial funciona— y exige que pasen antes de permitir cualquier merge.

Al terminar vas a saber montar una puerta por marcador de principio a fin: registrar un marcador (smoke) en la configuración de pytest para que sea oficial, marcar los tests-ancla de Reservo con @pytest.mark.smoke, y seleccionarlos con pytest -m smoke para que la puerta corra solo ese grupo. Vas a verlo ejecutado de verdad: pytest -m smoke selecciona 3 tests y deselecciona 10 (exit 0, verde); y cuando alguien rompe la regla del descuento pro —el corazón del negocio de Reservo—, la puerta smoke se pone roja (exit 1, assert 5625 == 6000) y bloquearía el merge. Y vas a entender el patrón que esto habilita: puertas escalonadas —la smoke rápida primero, la suite completa después— para que el feedback sobre lo crítico llegue en segundos, no en minutos.

Conexión con el módulo: esta lección mueve la perilla de la métrica (lección 2): la misma receta —métrica, umbral, consecuencia vía exit code— con "cobertura ≥ 80%" reemplazado por "los smoke pasan". Complementa las puertas de cobertura de las lecciones 3 y 4: cobertura y marcadores responden preguntas distintas —"¿cuánto código probé?" contra "¿lo más importante sigue funcionando?"— y en un pipeline maduro conviven. Prepara además la lección 6 (cuándo una puerta ayuda), porque la smoke es el ejemplo más claro de una puerta que paga: barata, rápida, y protege justo lo que no puede romperse.

El chequeo de "¿enciende y frena?" antes de la revisión completa

Cuando llevas el coche al taller para la revisión anual completa —que tarda horas: aceite, filtros, frenos, suspensión, emisiones—, el mecánico no empieza por lo más lento. Antes de meterlo al foso, hace un chequeo de treinta segundos: ¿enciende el motor?, ¿frena?, ¿giran las ruedas? Si el coche ni enciende, no tiene sentido revisar la suspensión: hay un problema tan básico que la revisión completa sería perder el tiempo. Ese chequeo rápido de lo esencial —"¿lo mínimo funciona?"— filtra los casos rotos de raíz antes de invertir en el análisis a fondo.

En aviación y en ingeniería, a ese chequeo mínimo se le llama smoke test, y el nombre tiene una historia literal: cuando se probaba un aparato electrónico nuevo, lo primero era encenderlo y ver si salía humo. Si salía humo, algo estaba tan mal que no valía la pena seguir probando nada más; había que apagar y arreglar. Si no salía humo, al menos lo básico estaba en pie y podías proceder con las pruebas detalladas. El smoke test no verifica que todo esté perfecto; verifica que lo esencial no esté catastróficamente roto.

Una puerta smoke en tu CI es ese chequeo de "¿enciende y frena?". De toda tu suite, marcas los pocos tests que verifican lo esencial de Reservo —que el precio básico da 7500, que el descuento pro da 6000, que un reembolso completo devuelve todo— y montas una puerta que corre solo esos y exige que pasen. Si el precio pro se rompe, la puerta smoke lo caza en segundos, antes de correr los cien tests de casos borde. Es rápida (pocos tests), es barata (segundos, no minutos), y protege lo que no puede romperse nunca. No reemplaza la suite completa —la revisión a fondo sigue siendo necesaria—; es la primera línea, el filtro que atrapa los desastres obvios de inmediato.

Una puerta smoke corre solo un subconjunto de tests críticos —los que verifican que lo esencial funciona— y exige que pasen antes de mergear. Como el chequeo de "¿enciende y frena?" antes de la revisión completa, es rápida, barata, y atrapa los desastres obvios de inmediato, sin esperar a la suite entera.

Paso 1: registrar el marcador

Un marcador en pytest es una etiqueta que le pones a un test para agruparlo o darle un atributo. Ya viste uno en el módulo 4: @pytest.mark.skipif, que salta un test bajo una condición. Aquí usamos un marcador propio, smoke, para etiquetar los tests críticos. Pero antes de usarlo hay que registrarlo, y este paso es importante por una razón concreta: si marcas un test con @pytest.mark.smoke sin registrar smoke en ningún lado, pytest lo acepta pero te lanza una advertencia —PytestUnknownMarkWarning: Unknown pytest.mark.smoke - is this a typo?—, porque no sabe si escribiste smoke a propósito o te equivocaste tecleando. Registrar el marcador le dice a pytest "sí, smoke es un marcador de verdad, lo uso aposta", y la advertencia desaparece.

Se registra en el archivo de configuración de pytest, pytest.ini (o en la sección equivalente de pyproject.toml):

# pytest.ini
[pytest]
markers =
    smoke: fast, critical tests that must pass before any merge (the smoke gate).

La sintaxis es nombre: descripción. La descripción no es decorativa: aparece cuando alguien pregunta qué marcadores existen, y documenta para qué es el marcador —aquí, "tests rápidos y críticos que deben pasar antes de cualquier merge"—. Puedes ver los marcadores registrados con pytest --markers, que confirma que smoke es oficial:

python -m pytest --markers
@pytest.mark.smoke: fast, critical tests that must pass before any merge (the smoke gate).

Ahí está, registrado y documentado. Un marcador registrado es un marcador que el equipo entiende; uno sin registrar es una etiqueta suelta que genera advertencias y que nadie sabe si es intencional o un dedazo.

Paso 2: marcar los tests críticos

Ahora etiquetamos los tests que forman el "¿enciende y frena?" de Reservo. ¿Cuáles son críticos? Los que verifican los números-ancla del negocio: el precio básico, el descuento pro, el reembolso completo. Si cualquiera de esos se rompe, Reservo le cobra mal a un cliente o le devuelve mal su dinero —un desastre de los que no pueden llegar a producción—. No marcamos todos los tests como smoke; eso los volvería la suite completa y perdería el sentido. Marcamos los pocos, esenciales, cuyo fallo significa "no sigas, hay humo".

Se marca poniendo @pytest.mark.smoke encima del test:

# tests/test_pricing.py (extracto)
import pytest

from reservo.models import Member, Room
from reservo.pricing import price_cents

focus = Room(id="r-focus", name="Focus", capacity=1, hourly_cents=2500)
basic = Member(id="m-1", name="Ana", tier="basic")
pro = Member(id="m-2", name="Bruno", tier="pro")


@pytest.mark.smoke
def test_basic_three_hours():
    # basic, 3h -> 7500 (precio esencial, sin descuento)
    assert price_cents(focus, basic, 3) == 7500


@pytest.mark.smoke
def test_pro_three_hours():
    # pro, 3h -> 6000 (el descuento del 20%, corazon del negocio)
    assert price_cents(focus, pro, 3) == 6000


def test_basic_one_hour():
    # basic, 1h -> 2500 (correcto, pero no critico: es un caso mas)
    assert price_cents(focus, basic, 1) == 2500
# tests/test_refunds.py (extracto)
@pytest.mark.smoke
def test_full_refund_72h_before():
    # cancelar 72h antes (>= 48h) -> 100% de 6000 = 6000 (dinero del cliente)
    booking = _booking(datetime(2026, 1, 4, 12, 0))
    now = datetime(2026, 1, 1, 12, 0)
    assert refund_cents(booking, 6000, now) == 6000

Marcamos tres tests: el precio básico (7500), el descuento pro (6000) y el reembolso completo (6000). Fíjate en lo que no marcamos: test_basic_one_hour (2500) es correcto y útil, pero es "un caso más", no el corazón del negocio —va en la suite completa, no en el smoke—. La decisión de qué marcar es un juicio: los smoke son los tests cuyo fallo debería frenar todo de inmediato, no todos los tests que tienes. Un smoke inflado con cincuenta tests deja de ser rápido y pierde su gracia.

Paso 3: la puerta corre solo los smoke

Con los tests marcados, la puerta se arma con la bandera -m (de marker), que selecciona tests por su marcador. pytest -m smoke corre solo los tests marcados smoke y salta el resto. Corrámoslo de verdad, en Python 3.14.0 con pytest 9.1.1, con la bandera -v para ver cada test:

python -m pytest -m smoke -v

Qué esperar (salida real):

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
configfile: pytest.ini
plugins: cov-7.1.0
collecting ... collected 13 items / 10 deselected / 3 selected

tests/test_pricing.py::test_basic_three_hours PASSED                     [ 33%]
tests/test_pricing.py::test_pro_three_hours PASSED                       [ 66%]
tests/test_refunds.py::test_full_refund_72h_before PASSED                [100%]

======================= 3 passed, 10 deselected in 0.02s =======================

Lee la línea clave: collected 13 items / 10 deselected / 3 selected. pytest encontró los 13 tests de Reservo, pero -m smoke deseleccionó 10 (los que no llevan el marcador) y seleccionó 3 (los smoke). Corrió solo esos tres, los tres pasaron, y el resumen lo confirma: 3 passed, 10 deselected. La puerta smoke es verde. El exit code:

python -m pytest -m smoke > /dev/null 2>&1; echo "exit code: $?"
exit code: 0

Exit code 0. Verde: los tests críticos pasan, el merge puede proceder (por lo que a la puerta smoke respecta). Y el complemento —correr todo menos los smoke, para la suite completa en otro job— se hace con pytest -m "not smoke":

python -m pytest -m "not smoke"
collected 13 items / 3 deselected / 10 selected
======================= 10 passed, 3 deselected in 0.01s =======================

Ahí se invierte: 3 deselected, 10 selected. Los tres smoke se saltan (ya los corrió la puerta smoke) y corren los otros diez. Entre pytest -m smoke y pytest -m "not smoke", cubres los 13 tests, repartidos en dos puertas con propósitos distintos.

Paso 4: la puerta smoke muerde

Una puerta que solo has visto en verde no sirve de nada si no sabes que se pone roja cuando debe. Comprobémoslo rompiendo la regla que uno de los smoke protege: el descuento pro. En reservo/pricing.py, cambiamos el descuento de 20% a 25% —un bug que le cobraría de menos al cliente pro— y corremos la puerta smoke:

# tras cambiar PRO_DISCOUNT_PERCENT de 20 a 25 en reservo/pricing.py
python -m pytest -m smoke

Qué esperar (salida real):

collected 13 items / 10 deselected / 3 selected

tests/test_pricing.py::test_pro_three_hours FAILED                       [ 66%]
...
>       assert price_cents(focus, pro, 3) == 6000
E       AssertionError: assert 5625 == 6000
tests/test_pricing.py:20: AssertionError

FAILED tests/test_pricing.py::test_pro_three_hours - AssertionError: assert 5625 == 6000
================== 1 failed, 2 passed, 10 deselected in 0.03s ==================

La puerta smoke atrapó el bug: 1 failed, 2 passed, 10 deselected. test_pro_three_hours falló con assert 5625 == 6000 —con el descuento al 25%, el pro pagaría 5625 en vez de 6000—. El exit code:

python -m pytest -m smoke > /dev/null 2>&1; echo "exit code: $?"
exit code: 1

Exit code 1. Rojo. En el CI, este 1 bloquearía el merge: el chequeo de "¿enciende y frena?" detectó que el freno del descuento pro no funciona, y no deja pasar el coche a la carretera. Y fíjate en la velocidad —in 0.03s—: la puerta smoke dio su veredicto en centésimas de segundo, corriendo solo 3 tests. No tuvo que esperar a los otros 10 para decirte "hay un problema grave en el precio". Restauramos el descuento a 20% y la puerta vuelve a verde (3 passed, 10 deselected, exit 0), como comprobaste que muerde y sana.

El patrón: puertas escalonadas

La puerta smoke brilla de verdad cuando la combinas con la suite completa en un pipeline escalonado. La idea: no corras todo de golpe; corre primero lo rápido y crítico, y solo si eso pasa, corre lo lento y exhaustivo. En un workflow de CI se vería así (contenido, recuerda: no hay runner aquí):

# .github/workflows/tests.yml (extracto, escalonado)
jobs:
  smoke:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v5
        with:
          python-version: "3.14"
      - run: pip install -r requirements.txt
      - name: Smoke gate (critical tests only)
        run: python -m pytest -m smoke

  full-suite:
    needs: smoke          # solo corre si la puerta smoke paso
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v5
        with:
          python-version: "3.14"
      - run: pip install -r requirements.txt
      - name: Full suite with coverage gate
        run: python -m pytest --cov=reservo --cov-fail-under=80

La clave es needs: smoke en el segundo job: le dice al CI "no corras la suite completa hasta que la puerta smoke pase". Así, si alguien rompe el precio pro, el CI se detiene en la puerta smoke en segundos, sin gastar los minutos de la suite completa y la matriz de versiones (módulo 4). Es feedback rápido sobre lo crítico —fallas temprano y barato— y análisis a fondo solo cuando lo básico está en pie. El chequeo de "¿enciende?" antes de meter el coche al foso, traducido a jobs de CI.

Un matiz honesto: escalonar tiene un costo —el segundo job espera al primero, así que en el caso feliz (todo verde) el pipeline es un poco más lento que si ambos corrieran en paralelo—. El trade-off paga cuando los fallos críticos son relativamente frecuentes o la suite completa es cara (matriz grande, muchos tests): ahí, atrapar el desastre en la puerta smoke ahorra mucho. Si tu suite completa es de segundos, escalonar quizá no valga la pena y corras todo en paralelo. Como toda decisión de CI, depende de tus números —tema de la lección 6—.

Errores comunes

Marcar demasiados tests como smoke. Qué pasa: alguien marca veinte o treinta tests como smoke "por si acaso", y la puerta smoke tarda casi lo mismo que la suite completa. Por qué pasa: es tentador marcar todo lo que "parece importante", y casi todo lo parece. Cómo detectarlo: si tu puerta smoke corre en un tiempo comparable al de la suite entera, dejó de ser un chequeo rápido. Cómo corregirlo: los smoke son el "¿enciende y frena?" —los poquísimos tests cuyo fallo debería frenar todo de inmediato—, no una segunda suite. Para Reservo son tres: precio básico, descuento pro, reembolso completo. Si dudas si un test es smoke, probablemente no lo es: el smoke es un filtro brutal de lo esencial, no un resumen de lo importante.

No registrar el marcador y convivir con la advertencia. Qué pasa: alguien usa @pytest.mark.smoke sin registrarlo en pytest.ini, y cada corrida escupe PytestUnknownMarkWarning: Unknown pytest.mark.smoke - is this a typo?. Con el tiempo, el equipo ignora las advertencias, y el día que alguien de verdad escribe mal un marcador (@pytest.mark.smoek), la advertencia que lo delataría se pierde entre el ruido. Por qué pasa: registrar el marcador es un paso fácil de saltar, y la advertencia no rompe nada... hasta que esconde un error real. Cómo detectarlo: si ves PytestUnknownMarkWarning en tu salida, tienes marcadores sin registrar. Cómo corregirlo: registra todos tus marcadores en pytest.ini con su descripción. Un marcador registrado no genera ruido y documenta su propósito; además, pytest --strict-markers puede convertir un marcador no registrado en un error (no solo advertencia), atrapando los dedazos de inmediato.

Confundir la puerta smoke con la suite completa. Qué pasa: un equipo pone solo la puerta smoke en el CI y cree que con eso "los tests corren en cada push", sin correr nunca la suite completa. Por qué pasa: la smoke es rápida y verde, y da una falsa sensación de cobertura total. Cómo detectarlo: pregúntate "si un caso borde se rompe —un solape raro, un reembolso en el límite exacto de 48 h—, ¿lo atraparía mi CI?". Si solo corres smoke, la respuesta es no: los smoke solo cubren lo esencial. Cómo corregirlo: la smoke es la primera puerta, no la única. Detrás de ella va la suite completa (con su puerta de cobertura). Escalonadas, no una en lugar de la otra: el smoke atrapa los desastres rápido; la suite completa atrapa todo lo demás.

Ejercicios

Ejercicio 1 — Predice la selección. La suite de Reservo tiene 13 tests, 3 marcados smoke. Para cada comando, predice cuántos tests se seleccionan y cuántos se deseleccionan, y el exit code si todos los que corren pasan: (a) pytest -m smoke. (b) pytest -m "not smoke". (c) pytest (sin -m). (d) pytest -m smoke con el descuento pro roto (20→25).

Ver solución
  • (a) pytest -m smoke: selecciona 3, deselecciona 10. Corre solo los smoke; si pasan, exit 0. (3 passed, 10 deselected.)
  • (b) pytest -m "not smoke": selecciona 10, deselecciona 3. Corre todo menos los smoke; si pasan, exit 0. (10 passed, 3 deselected.) Es el complemento exacto de (a).
  • (c) pytest sin -m: selecciona 13, deselecciona 0. Sin filtro de marcador, corre la suite entera; si todo pasa, exit 0. (13 passed.) El marcador no cambia qué corre cuando no filtras por él.
  • (d) pytest -m smoke con el descuento roto: selecciona 3, deselecciona 10, pero ahora test_pro_three_hours falla (assert 5625 == 6000): 1 failed, 2 passed, 10 deselected, exit 1. La puerta smoke muerde: caza el bug del precio pro en los 3 tests críticos, sin correr los otros 10.

La regla: -m smoke corre solo los marcados, -m "not smoke" corre solo los no marcados, y sin -m corre todos. El exit code depende de si los que corren pasan; en (d), como un smoke falla, la puerta rompe el build.

Ejercicio 2 — Elige los smoke de una feature nueva. Reservo agrega la cancelación con reembolso (cancel_with_refund, de las lecciones anteriores). Tiene tests para: (i) cancelar con reembolso completo libera el horario, (ii) cancelar dos veces lanza error, (iii) el texto del correo dice "full refund" en el tramo correcto, (iv) cancelar una reserva ya pasada. ¿Cuál(es) marcarías como smoke y por qué? ¿Cuál(es) dejarías solo en la suite completa?

Ver solución

Smoke: (i) cancelar con reembolso completo libera el horario. Es el camino feliz esencial de la feature —lo que hace el 95% de las cancelaciones reales— y toca dos cosas críticas a la vez: que el dinero se devuelve bien (número-ancla) y que el horario se libera (disponibilidad). Si esto se rompe, la cancelación está catastróficamente rota; es exactamente un "hay humo, no sigas".

Suite completa (no smoke): (ii), (iii) y (iv). Son importantes y deben existir, pero son casos borde o secundarios, no el corazón:

  • (ii) cancelar dos veces lanza error protege contra un mal uso, no contra el flujo principal. Importa, pero su fallo no es un desastre de "no enciende".
  • (iii) el texto del correo es un detalle de presentación; un texto mal no cobra ni devuelve mal dinero (aunque conviene probarlo, no es crítico de negocio).
  • (iv) cancelar una reserva pasada es un caso borde: raro, y su fallo no rompe el flujo normal.

El criterio: un smoke es el camino feliz esencial cuyo fallo significa "la feature no funciona en absoluto". (i) lo es; (ii)-(iv) son la revisión a fondo. Marcar los cuatro como smoke inflaría la puerta y la haría lenta sin ganar protección real sobre lo crítico. Recuerda: el smoke es un filtro brutal, no un resumen.

Ejercicio 3 — Diseña el pipeline escalonado. Tu suite de Reservo tarda: smoke 0.02 s, suite completa con cobertura 0.03 s, y una matriz de tres versiones de Python (módulo 4) que multiplica la suite completa. Un compañero propone: "Corramos smoke, suite completa y matriz, los tres en paralelo, para terminar cuanto antes." Otro dice: "Escalonémoslos: smoke primero, y la matriz solo si smoke pasa." ¿Cuál tiene razón para Reservo, y cambiaría tu respuesta si la matriz tardara 12 minutos?

Ver solución

Para Reservo tal como está (todo en centésimas de segundo), el paralelo del primer compañero tiene razón. Escalonar tiene un costo: el job que espera (needs: smoke) no arranca hasta que el anterior termina, así que en el caso feliz el pipeline es más lento. Ese costo solo se justifica si lo que evitas —correr la suite cara cuando smoke ya falló— es caro. Con una matriz que tarda centésimas de segundo, no hay nada caro que evitar: correr los tres en paralelo termina antes y el "ahorro" de escalonar es inexistente. Escalonar aquí sería pagar latencia extra sin comprar nada.

Si la matriz tardara 12 minutos, la respuesta se invierte: escalonar (el segundo compañero) gana. Ahora sí hay algo caro que evitar. Si alguien rompe el descuento pro, la puerta smoke lo caza en 0.02 s; escalonada con needs: smoke, la matriz de 12 minutos nunca arranca, y ahorras esos 12 minutos (× las celdas de la matriz, × cada push roto). El costo de escalonar —esperar 0.02 s a que smoke pase en el caso feliz— es despreciable frente a los 12 minutos que ahorras en el caso roto. La regla: escalona cuando lo que va detrás de la puerta es caro y los fallos que la puerta atrapa son frecuentes; corre en paralelo cuando todo es barato. El número —cuánto tarda la suite cara— decide, no el reflejo de "escalonar siempre es mejor".

Resumen y siguiente paso

En esta lección montaste una puerta que no mira cobertura, demostrando que la receta de la lección 2 —métrica, umbral, consecuencia— aplica a cualquier métrica. La puerta smoke corre solo un subconjunto de tests críticos (el "¿enciende y frena?" de Reservo) y exige que pasen antes de mergear. La armaste en tres pasos: registrar el marcador smoke en pytest.ini (para que sea oficial y no genere advertencias), marcar los tests-ancla con @pytest.mark.smoke (precio básico, descuento pro, reembolso completo —los pocos esenciales, no todos—), y seleccionarlos con pytest -m smoke.

Lo viste ejecutado de verdad: pytest -m smoke seleccionó 3 tests y deseleccionó 10 (exit 0, verde); y cuando rompiste el descuento pro (20→25), la puerta smoke se puso roja en 0.03 s —1 failed, 2 passed, 10 deselected, assert 5625 == 6000, exit 1— cazando el desastre sin esperar a los otros 10 tests. Y viste el patrón que habilita: puertas escalonadas con needs: smoke, la smoke rápida primero y la suite completa (con su puerta de cobertura) después, para feedback veloz sobre lo crítico —con el matiz honesto de que escalonar paga solo cuando lo que va detrás es caro—.

Antes de avanzar deberías poder: registrar y marcar un test con un marcador propio; correr solo un subconjunto con pytest -m smoke y su complemento con -m "not smoke"; explicar por qué un smoke debe ser pocos tests esenciales; y diseñar un pipeline escalonado con needs, sabiendo cuándo escalonar paga y cuándo no.

Lo que sigue, en la lección 6, es dar un paso atrás del "cómo" al "cuándo": el juicio. Ya sabes montar puertas de cobertura y de marcador; la pregunta ahora es cuándo una puerta ayuda —frena la erosión, hace explícita una decisión, protege lo crítico— y cuándo estorba —un umbral demasiado alto que bloquea trabajo legítimo, una métrica que no mide lo que importa—. Es la lección que te vuelve alguien que decide qué puertas poner, no solo alguien que sabe ponerlas.

Recursos

  • Marcadores en pytest: cómo registrarlos y usarlos — la referencia de @pytest.mark, cómo registrarlos en pytest.ini, y --strict-markers para convertir un marcador no registrado en error. La base de la puerta por marcador.
  • Seleccionar tests por marcador con -m — cómo pytest -m smoke y pytest -m "not smoke" filtran la suite, con ejemplos de expresiones de marcador más complejas (-m "smoke and not slow"). La bandera que arma la puerta.
  • Dependencias entre jobs con needs — GitHub Actions — cómo needs: smoke hace que un job espere a otro, la pieza que arma el pipeline escalonado. Léela para el mini-proyecto.
  • Smoke testing (concepto) — el marcador es la herramienta; la idea del smoke test —probar primero que lo esencial no está catastróficamente roto— es más vieja que pytest y aplica en cualquier stack. Piensa qué tres tests serían el "¿enciende?" de tu propio proyecto.