Módulo 5: Rápido y en paralelo — caché y paralelismo

5. Dividir la suite: rápida y lenta

Descripción

El paralelismo de la lección 4 reparte todos los tests entre workers, y eso ya acelera mucho. Pero hay una optimización anterior, más fina, que el paralelismo no reemplaza: no todos los tests valen lo mismo ni cuestan lo mismo. En Reservo, once tests vuelan —precios, reembolsos, disponibilidad, todo en centésimas de segundo— y solo doce pesan: los del reporte mensual, con su medio segundo de espera cada uno. Cuando corres la suite entera, esos doce lentos dominan el reloj y te hacen esperar seis segundos por un veredicto que, para los otros once, estaba listo en 0.01. Dividir la suite arregla eso: separas lo rápido de lo lento y decides qué corre primero y qué corre aparte.

Al terminar vas a saber marcar los tests lentos con @pytest.mark.slow, seleccionarlos con -m "not slow" (para el feedback veloz: solo los rápidos, en centésimas) y -m slow (para la parte cara, aparte), y encontrar a los culpables de la lentitud con --durations, que lista los tests más lentos por tiempo real. Vas a ver la estrategia de dos velocidades en CI —un job de rápidos que da veredicto en segundos y un job de lentos que corre en paralelo— y vas a asomarte a la idea de repartir la suite entre varios jobs (sharding). Todo esto se ejecuta de verdad: los tiempos y conteos salen de correr la suite de Reservo en local.

Conexión con el módulo: esta lección organiza lo que la lección 4 paraleliza. Marcar y dividir la suite es lo que te permite aplicar el paralelismo donde duele (los lentos) y el feedback instantáneo donde importa (los rápidos, primero). Se apoya en el marcador @pytest.mark.slow que Reservo ya traía en test_reports.py desde la lección 1, y prepara el terreno para la lección 6, que explicará por qué esos tests, para poder repartirse, deben estar aislados. Una nota de frontera: aquí dividimos por velocidad (rápido/lento) para acelerar; dividir por tipo de test (unitario, integración) como decisión de estrategia es tema de otra guía. Nuestro criterio es uno: el tiempo.

El carril rápido del supermercado

En cualquier supermercado hay una caja con un letrero: "10 artículos o menos". No existe por capricho: existe porque mezclar en una sola fila al que lleva tres cosas con el que lleva un carrito lleno castiga al primero. El de las tres cosas esperaría veinte minutos detrás de tres carritos, por un cobro que toma quince segundos. Separar los flujos —un carril rápido para pocos artículos, las cajas normales para las compras grandes— hace que el feedback rápido llegue rápido, sin que la compra pesada lo estorbe.

Tu suite de tests tiene exactamente esa mezcla. Los tests rápidos —los unitarios de Reservo, que verifican una fórmula de precio o un cálculo de reembolso— son "tres artículos": deberían darte un veredicto en un parpadeo. Los tests lentos —los del reporte mensual, que esperan a una fuente de datos— son "el carrito lleno": tardan, y está bien que tarden, pero no deberían hacer esperar a los rápidos. Cuando corres todo junto, metiste a los tres artículos detrás del carrito: esperas seis segundos por un resultado que, para los once rápidos, estaba en 0.01.

Dividir la suite es poner el letrero de "carril rápido". Marcas cuáles tests son el carrito lleno (@pytest.mark.slow), y a partir de ahí puedes correr solo los rápidos cuando quieres feedback inmediato —mientras programas, en cada guardado— y dejar los lentos para una corrida aparte —completa, en paralelo, cuando toca—. El veredicto urgente llega urgente; el pesado, cuando puede.

No todos los tests cuestan igual. Marcar los lentos y separarlos te deja correr los rápidos primero (feedback en centésimas) y los lentos aparte (en paralelo, cuando toca). El carril rápido no borra la compra pesada: la pone en su propia fila.

Marcar los tests lentos

Un marcador de pytest es una etiqueta que le pones a un test con un decorador. Reservo ya marcó sus doce tests lentos en test_reports.py desde la lección 1:

import pytest


@pytest.mark.slow
def test_report_january_basic_hours():
    total = _slow_report_total([price_cents(ROOM, BASIC, 3) for _ in range(4)])
    assert total == 30000

El @pytest.mark.slow de encima no cambia lo que el test hace: sigue verificando que cuatro reservas basic de 3 h suman 30000 centavos. Lo que hace es pegarle una etiqueta —"este test es lento"— que después puedes usar para seleccionarlo o excluirlo. slow no es una palabra mágica de pytest; es un nombre que elegimos. Podría llamarse heavy o integration; usamos slow porque describe con precisión por qué lo separamos: por su tiempo.

Los marcadores propios conviene registrarlos, para que pytest sepa que existen y no te advierta de un posible typo. Reservo lo registra en pyproject.toml:

[tool.pytest.ini_options]
markers = [
    "slow: marks tests that take a noticeable amount of time (deselect with '-m \"not slow\"')",
]

Registrar el marcador tiene dos beneficios. Uno: si escribes @pytest.mark.slwo (con un typo), pytest te avisa "marcador desconocido slwo", en vez de crear silenciosamente una etiqueta nueva que no seleccionará nada. Dos: pytest --markers lista tus marcadores con su descripción, así que cualquiera en el equipo ve qué significa slow y cómo excluirlo. La descripción entre comillas es documentación viva.

Seleccionar por marcador: -m

Con los tests marcados, la bandera -m filtra la suite por marcador. Es una expresión, así que puedes pedir un marcador, su negación, o combinaciones.

Solo los rápidos —el carril rápido, todo menos lo lento— con -m "not slow":

python -m pytest -m "not slow"

Qué esperar (salida real):

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m5
configfile: pyproject.toml
testpaths: tests
plugins: xdist-3.8.0
collected 23 items / 12 deselected / 11 selected

tests/test_availability.py ....                                          [ 36%]
tests/test_pricing.py ....                                               [ 72%]
tests/test_refunds.py ...                                                [100%]

====================== 11 passed, 12 deselected in 0.01s =======================

Léelo despacio: 11 passed, 12 deselected in 0.01s. pytest recolectó los 23 tests, deseleccionó los 12 marcados slow, y corrió solo los 11 rápidos —en una centésima de segundo—. Ahí está el carril rápido: mientras programas, corres -m "not slow" y sabes en un parpadeo si rompiste una regla de precios o de reembolsos, sin esperar los seis segundos del reporte. deselected significa "existen, pero esta vez no los corrimos" —no fallaron, no se saltaron por una condición como el skipif del módulo 4; simplemente los dejamos fuera del filtro—.

Solo los lentos —la compra pesada, aparte— con -m slow:

python -m pytest -m slow
====================== 12 passed, 11 deselected in 6.11s =======================

Los doce lentos, sin los rápidos. Esta es la corrida cara, la que quieres paralelizar. Combinada con -n auto de la lección 4, esos 6.11 s bajan a 1.17 s. Y ese es justo el punto: paralelizas la parte lenta, porque es donde el paralelismo paga; la parte rápida ya está en 0.01 s y no necesita workers.

Encontrar a los culpables: --durations

Antes de marcar algo como lento, conviene medir quién es lento de verdad, en vez de adivinar. La bandera --durations=N le dice a pytest "al terminar, muéstrame los N tests más lentos con su tiempo". Es el medidor del sumidero 2 que la lección 2 prometió.

python -m pytest --durations=8

Qué esperar (salida real, recortada al bloque de duraciones):

============================= slowest 8 durations ==============================
0.51s call     tests/test_reports.py::test_report_january_basic_hours
0.51s call     tests/test_reports.py::test_report_october_basic_two_hours
0.51s call     tests/test_reports.py::test_report_november_pro_two_hours
0.51s call     tests/test_reports.py::test_report_december_year_end
0.51s call     tests/test_reports.py::test_report_september_pro_hour
0.51s call     tests/test_reports.py::test_report_april_single_basic
0.51s call     tests/test_reports.py::test_report_july_two_pro
0.51s call     tests/test_reports.py::test_report_june_basic_long
23 passed in 6.10s

La lista es inequívoca: los ocho tests más lentos son todos de test_reports.py, cada uno con 0.51s call (el medio segundo del sleep, más una pizca). El call indica que el tiempo se fue en ejecutar el test (hay también setup y teardown, que aquí son despreciables). Si tuvieras un test lento escondido entre los "rápidos", --durations lo delataría al instante, y sabrías a cuál pegarle el @pytest.mark.slow. La regla del módulo, otra vez: mide antes de optimizar. --durations es cómo mides.

La estrategia de dos velocidades en CI

Dividir la suite no es solo comodidad local; es una estructura para el pipeline. La idea: dos jobs con propósitos distintos.

Un job rápido que corre solo los tests veloces y da un veredicto casi instantáneo:

- name: Fast tests
  run: pytest -m "not slow"

Y un job lento que corre los pesados, en paralelo:

- name: Slow tests
  run: pytest -m slow -n auto

¿Qué gana el equipo con esto? Feedback en capas. El job rápido termina en segundos y ya te dice si rompiste algo básico —una fórmula de precio, un cálculo de reembolso—; no tienes que esperar a que corra el reporte mensual para enterarte de que quebraste price_cents. El job lento corre en paralelo con -n auto y da el veredicto completo un poco después. Si el rápido falla, lo sabes de inmediato y ni hace falta esperar al lento. Es el carril rápido del supermercado aplicado al CI: el resultado urgente llega urgente, el pesado cuando puede, y los dos corren sin estorbarse.

Un detalle de honestidad: partir en dos jobs también permite que cada uno cachee y prepare su entorno por separado, y que el pesado use un runner con más núcleos si de verdad lo necesita —eso ya es afinar el trade-off de la lección 7—. La estructura básica, sin embargo, es esta: separa por velocidad, corre lo rápido primero, paraleliza lo lento.

Repartir la suite entre jobs: sharding, en breve

Hay una segunda forma de dividir, complementaria, que conviene nombrar aunque su detalle exceda esta lección. Dividir por velocidad (rápido/lento) organiza por costo. Pero si tienes miles de tests todos de costo parecido, otra táctica es partirlos en trozos (shards) y darle un trozo a cada job del CI, que corren en máquinas distintas a la vez. Es paralelismo a nivel de jobs del CI, no de procesos en una máquina: en vez de -n auto repartiendo entre los núcleos de un runner, tienes cuatro runners corriendo un cuarto de la suite cada uno.

La matriz del módulo 4 ya te dio la mecánica para lanzar varios jobs a la vez; el sharding la usa para repartir la suite en lugar de repetirla en varias versiones. Herramientas como pytest-split reparten los tests en trozos de duración pareja según mediciones previas. No lo desarrollamos aquí —es una técnica para suites muy grandes, y Reservo no la necesita—, pero quédate con la idea: hay dos niveles de paralelismo, dentro de una máquina (xdist, -n) y entre máquinas del CI (sharding de jobs), y se combinan. Para la mayoría de los proyectos, -n auto más la división rápido/lento alcanza y sobra.

Errores comunes

No registrar el marcador y perder el aviso de typos. Qué pasa: alguien usa @pytest.mark.slow sin registrarlo en pyproject.toml, un día escribe @pytest.mark.slow (typo), y ese test no queda marcado como lento —pero pytest no avisa, porque acepta cualquier marcador—. El test lento se cuela en el carril rápido y lo enlentece. Por qué pasa: pytest, sin --strict-markers ni registro, crea marcadores nuevos en silencio. Cómo detectarlo: corre pytest --markers y verifica que tus marcadores estén ahí; un typo no aparecerá. Cómo corregirlo: registra los marcadores en pyproject.toml (como Reservo) para que pytest te avise de un nombre desconocido en vez de aceptarlo callado.

Confundir deselected con skipped o con un fallo. Qué pasa: alguien ve 12 deselected y se preocupa —"¿doce tests no corrieron?, ¿algo está roto?"—. Por qué pasa: deselected es un estado distinto de passed, failed y skipped, y no siempre se explica. Cómo detectarlo y entenderlo: deselected significa "estos tests existen pero tu filtro -m los dejó fuera a propósito", como pedir "10 artículos o menos" y que las compras grandes no entren a ese carril —no fallaron ni se saltaron por una condición, simplemente no eran lo que pediste—. Cómo actuar: si querías correrlos, quita o cambia el filtro; si no, deselected es exactamente lo que buscabas y no hay nada que arreglar.

Adivinar qué es lento en vez de medirlo. Qué pasa: alguien marca como slow los tests que "le parecen" pesados —los que tienen nombres largos, o los del módulo que no le gusta— y deja sin marcar un test que de verdad tarda tres segundos escondido entre los "rápidos", que sigue enlenteciendo el carril rápido. Por qué pasa: la intuición sobre qué es lento suele fallar. Cómo detectarlo: corre --durations=10 y compara la lista real con tus marcas; si un test lento no está marcado, o uno marcado es en realidad instantáneo, tu criterio no coincide con los datos. Cómo corregirlo: marca según lo que mide --durations, no según lo que supones. El medidor manda.

Ejercicios

Ejercicio 1 — Elige el comando. Para cada objetivo, escribe el comando de pytest que lo cumple sobre la suite de Reservo. (a) Feedback instantáneo mientras programas: solo los tests rápidos. (b) La corrida cara de los reportes, paralelizada. (c) Ver los cinco tests más lentos con su tiempo. (d) La suite completa en paralelo (rápidos y lentos juntos).

Ver solución
  • (a) pytest -m "not slow" — deselecciona los marcados slow y corre solo los 11 rápidos (11 passed, 12 deselected in 0.01s).
  • (b) pytest -m slow -n auto — selecciona solo los 12 lentos y los reparte entre los núcleos; los 6.11 s en serie bajan a ~1.17 s.
  • (c) pytest --durations=5 — corre la suite y, al final, lista los cinco tests más lentos con su tiempo (0.51s call ...).
  • (d) pytest -n auto — sin filtro -m, corre los 23 tests, repartidos entre workers (12 workers [23 items], 23 passed in ~1.15s).

La combinación clave: -m decide cuáles tests corren (por velocidad), -n decide cómo corren (en fila o en paralelo). Se combinan libremente: -m slow -n auto es "solo los lentos, en paralelo".

Ejercicio 2 — Diseña los dos jobs. Escribe los dos steps de CI de la estrategia de dos velocidades para Reservo —un job rápido y uno lento— y explica en una frase qué gana el equipo con esta separación en lugar de un solo pytest.

Ver solución

Los dos steps:

- name: Fast tests
  run: pytest -m "not slow"

- name: Slow tests
  run: pytest -m slow -n auto

El job rápido corre solo los 11 tests veloces y da veredicto en centésimas de segundo; el lento corre los 12 del reporte en paralelo con -n auto.

Lo que gana el equipo es feedback en capas: si rompes una regla básica —una fórmula de precio, un reembolso—, el job rápido te lo dice en segundos, sin que tengas que esperar a que corra el reporte mensual. El resultado urgente (¿rompí algo elemental?) llega urgente; el pesado (¿pasa todo, incluidos los reportes?) llega un poco después. Con un solo pytest, cualquier fallo —hasta el más tonto— te hace esperar los seis segundos de los lentos antes de enterarte.

Ejercicio 3 — Lee el --durations y actúa. Corres pytest --durations=5 en un proyecto nuevo y ves esto. ¿Qué tests marcarías como slow y por qué, y qué harías después con la parte lenta?

============================= slowest 5 durations ==============================
2.10s call     tests/test_email.py::test_sends_welcome_email
1.95s call     tests/test_export.py::test_generates_pdf_report
0.88s call     tests/test_email.py::test_sends_reminder
0.01s call     tests/test_pricing.py::test_basic_rate
0.01s call     tests/test_pricing.py::test_pro_rate
Ver solución

Marcaría como slow los tres primeros: test_sends_welcome_email (2.10 s), test_generates_pdf_report (1.95 s) y test_sends_reminder (0.88 s). Son claramente la "compra pesada" —envían correos, generan un PDF—, y juntos suman casi cinco segundos que hacen esperar a los rápidos. Los dos últimos (test_basic_rate, test_pro_rate, en 0.01 s) son el carril rápido y se quedan sin marcar.

Después, con la parte lenta marcada, haría dos cosas: (1) correr los rápidos con pytest -m "not slow" para tener feedback instantáneo mientras programo, y (2) correr los lentos con pytest -m slow -n auto para que los tres pesados se repartan entre workers y corran a la vez en vez de en fila. Así el veredicto básico llega en centésimas y la parte cara se paraleliza donde de verdad ayuda. La clave: medí con --durations antes de decidir qué marcar, en vez de adivinar; los datos señalaron a los tres culpables sin ambigüedad.

Resumen y siguiente paso

En esta lección aprendiste a dividir la suite por velocidad, para que el feedback urgente llegue rápido y la parte cara corra aparte. Lo viste con el carril rápido del supermercado: separar los tres artículos del carrito lleno para que el primero no espere detrás del segundo. Marcaste los tests lentos con @pytest.mark.slow (registrado en pyproject.toml para que pytest avise de typos), los seleccionaste con -m "not slow" para el carril rápido —11 passed, 12 deselected in 0.01s, feedback en una centésima— y con -m slow para la corrida cara, que combinada con -n auto baja de 6.11 s a poco más de uno. Y mediste a los culpables con --durations, que señaló a los doce tests de test_reports.py sin que tuvieras que adivinar.

Viste también la estructura para el CI: la estrategia de dos velocidades —un job rápido que da veredicto en segundos y un job lento en paralelo—, y te asomaste al sharding, el segundo nivel de paralelismo (entre máquinas del CI, no dentro de una), que combina con -n para suites enormes. La regla que atraviesa todo: mide con --durations, marca según los datos, corre lo rápido primero y paraleliza lo lento.

Antes de avanzar deberías poder: marcar y registrar un marcador slow; seleccionar con -m "not slow" y -m slow; distinguir deselected de skipped y de un fallo; usar --durations para encontrar tests lentos; y diseñar la estrategia de dos jobs para un pipeline.

Lo que sigue, en la lección 6, es la condición que hace posible todo el paralelismo de la lección 4 y esta división: el aislamiento. Repartir tests entre workers solo funciona si cada test es independiente —si no depende de lo que otro test dejó atrás—. Vas a ver, ejecutado de verdad, un test con estado compartido que pasa en serie (3 passed) y falla bajo -n (2 failed), porque cada worker es un proceso con su propia memoria; y vas a arreglarlo con una fixture que le da a cada test su propio mundo. El aislamiento no es un lujo del paralelismo: es su precondición.

Recursos