Módulo 2: Tu primer pipeline — correr pytest en CI
8. Mini-proyecto: el `tests.yml` que corre la suite de Reservo
Descripción
Este es el capstone del módulo. En las siete lecciones anteriores fuiste juntando piezas —la anatomía del YAML, los disparadores, los steps que preparan el terreno, instalar dependencias, correr pytest con su exit code, y leer el log—. Ahora las usas todas juntas, tú, de principio a fin, para producir algo entregable: el tests.yml completo que corre la suite de Reservo en cada push, más la prueba de que hace lo que dice. No vas a aprender un concepto nuevo; vas a demostrar que ya sabes montar el primer pipeline de un proyecto.
El entregable tiene tres partes, y las construimos juntas: (1) el workflow completo, con steps nombrados, escrito y explicado decisión por decisión; (2) la paridad local —corres en tu máquina, de verdad, los mismos comandos que el CI ejecutaría, y pegas la salida real para comprobar que el pipeline y tu terminal hacen lo mismo—; y (3) una nota corta de qué cubre tu pipeline y qué queda explícitamente para los módulos siguientes. Esa última parte —decir qué no hace tu workflow todavía y por qué— es tan importante como el YAML: saber el alcance de lo que montaste es parte del oficio.
Conexión con el módulo: esta lección no introduce nada; integra. Cada decisión que tomes aquí —qué eventos en on:, qué versión en setup-python, por qué instalar desde requirements.txt, cómo nombrar los steps— viene de una lección anterior, y la idea es que las apliques sin que te las recuerden. Es también el puente al resto de la guía: la nota de "qué queda para después" apunta directo al módulo 3 (reproducir un fallo de CI, entornos deterministas), al módulo 4 (la matriz de versiones), al módulo 5 (caché y velocidad) y al módulo 6 (puertas de cobertura). Aquí montas el pipeline base; esos módulos lo hacen más robusto, más rápido y más exigente.
El examen práctico de manejo, no el escrito
Piensa en sacar la licencia de conducir. Hay un examen escrito —te preguntan qué significa una señal, a qué distancia se frena— y hay un examen práctico, donde te subes al carro y manejas de verdad con el instructor al lado. Los dos importan, pero son distintos: el escrito prueba que sabes las reglas; el práctico prueba que puedes aplicarlas todas a la vez, sin que nadie te diga cuál toca en cada momento.
Las lecciones 1 a 7 fueron el examen escrito: cada una te enseñó una pieza y te la tomó por separado. Este mini-proyecto es el examen práctico: te subes al carro y escribes un workflow de verdad, tomando tú las decisiones que antes te venían dadas. ¿Qué eventos disparan? ¿Qué versión de Python? ¿Instalo a mano o desde el archivo? ¿Nombro los steps? Nadie te lo dice; lo decides con lo que aprendiste. Y como en el examen práctico, el objetivo no es la perfección teórica sino la competencia real: al final, tener un tests.yml que corre la suite de Reservo en cada push, que se lee bien, y que —comprobado con la paridad local— hace exactamente lo que dice.
El proyecto que vas a proteger
Recordemos qué tiene Reservo, porque el workflow se construye alrededor de su estructura. Es un proyecto de Python puro (lógica de reservas de salas, dinero en centavos int) con su código en un paquete reservo/ y su suite repartida en tres archivos de test:
reservo/ ← el código del dominio
├── models.py (Room, Member, Booking con price_cents)
├── pricing.py (price_cents)
├── refunds.py (refund_cents)
├── calendar.py (Calendar)
└── schedule.py (overlaps, is_available, book, cancel)
test_pricing.py ← 4 tests de precios
test_refunds.py ← 3 tests de reembolso
test_availability.py ← 4 tests de solape y disponibilidad
requirements.txt ← la lista de dependencias
Su requirements.txt, ya lo conoces de la lección 5, es una línea —Reservo solo necesita pytest para probarse—:
# requirements.txt
pytest==9.1.1
Tu trabajo es escribir el workflow que, en cada push, arranca una máquina limpia y corre esos once tests.
Paso 1: escribe el workflow completo
Crea el archivo en la ruta exacta —.github/workflows/tests.yml— y escribe esto, con steps nombrados para que el log se lea bien (lección 7):
# .github/workflows/tests.yml
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run the test suite
run: pytest
Repasa cada decisión, todas de las lecciones anteriores, porque son las que tomarías tú en un proyecto real:
name: tests(lección 2) — la etiqueta en la pestaña Actions. Descriptiva y estable.on: [push, pull_request](lección 3) — el par estándar: feedback temprano en cada push a tu rama, y guardián del umbral en cada pull request haciamain. Los dos timbres.runs-on: ubuntu-latest(lección 2) — una máquina Linux limpia; suficiente y barata para una suite de Python puro.Check out the codeconactions/checkout@v5(lección 4) — primero de todo, porque nada se puede hacer sin el código en el runner. Recuerda pinnear la mayor y revisar la vigente en la página de la acción.Set up Pythonconpython-version: "3.14"(lección 4) — la versión de la guía, entre comillas para que YAML no la lea como número. Va antes de instalar, para que pip y pytest corran sobre este Python.Install dependencies(lección 5) — el|agrupa los dos comandos: actualizar pip (higiene) e instalar desderequirements.txt(una sola fuente de verdad, versiones pinneadas).Run the test suiteconpytest(lección 6) — el corazón: descubre y corre los once tests igual que en local, y su exit code pinta el job.
Ese es el workflow entero. Catorce líneas de contenido que convierten "corro los tests cuando me acuerdo" en "los tests se corren solos en cada cambio". Recuerda la regla del módulo: este archivo es contenido que escribiste y entiendes; no vamos a levantar un runner. Lo que sí vamos a hacer —y es la mitad más valiosa del entregable— es probar en local que hace lo que dice.
Paso 2: la paridad local (esto corre de verdad)
Aquí está la idea que sostiene todo el módulo: lo que el CI le hace a tu suite es lo mismo que le hace tu máquina. Puedes comprobarlo ejecutando, en tu terminal, exactamente los mismos pasos que el workflow ejecutaría en el runner. Si obtienes la misma salida verde, tienes evidencia directa de que tu pipeline hará lo correcto. A esto se le llama paridad local: correr localmente lo mismo que el CI, para no tener sorpresas.
Sigamos los steps del workflow, uno por uno, en local. Cada comando de abajo se ejecutó de verdad con Python 3.14.0 y pytest 9.1.1; las salidas son reales.
El step Check out the code en local es, simplemente, estar parado en tu proyecto ya clonado (tú ya tienes el código; el runner lo trae, tú lo tienes). No hay comando que correr.
El step Set up Python en local es tener activo el Python correcto. Lo verificas y, como en la guía de fundamentos, trabajas en un entorno virtual limpio:
python3 -m venv .venv
source .venv/bin/activate
python3 --version
Python 3.14.0
Mismo Python que setup-python: "3.14" dejaría en el runner. Paridad en la versión: confirmada.
El step Install dependencies en local son los dos mismos comandos del run::
python3 -m pip install --upgrade pip
pip install -r requirements.txt
Qué esperar (salida real de la instalación desde requirements.txt):
Collecting pytest==9.1.1 (from -r requirements.txt (line 1))
Using cached pytest-9.1.1-py3-none-any.whl.metadata (7.6 kB)
Collecting iniconfig>=1.0.1 (from pytest==9.1.1->-r requirements.txt (line 1))
Collecting packaging>=22 (from pytest==9.1.1->-r requirements.txt (line 1))
Collecting pluggy<2,>=1.5 (from pytest==9.1.1->-r requirements.txt (line 1))
Collecting pygments>=2.7.2 (from pytest==9.1.1->-r requirements.txt (line 1))
Installing collected packages: pygments, pluggy, packaging, iniconfig, pytest
Successfully installed iniconfig-2.3.0 packaging-26.2 pluggy-1.6.0 pygments-2.20.0 pytest-9.1.1
Successfully installed ... pytest-9.1.1: las mismas dependencias que el runner instalaría desde la misma lista. Paridad en las dependencias: confirmada.
El step Run the test suite en local es el pytest del workflow (en tu terminal, la forma sin ambigüedad es python3 -m pytest):
python3 -m pytest
Qué esperar (salida real de la suite de Reservo):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/ana/reservo
collected 11 items
test_availability.py .... [ 36%]
test_pricing.py .... [ 72%]
test_refunds.py ... [100%]
============================== 11 passed in 0.03s ==============================
Y el exit code que el CI leería para pintar el job:
python3 -m pytest -q > /dev/null; echo "exit code: $?"
exit code: 0
Ahí está la paridad completa. La única diferencia entre esta corrida y la del runner es la línea platform: aquí darwin (macOS), en el runner linux (Ubuntu). Todo lo demás —Python 3.14.0, pytest 9.1.1, collected 11 items, 11 passed, exit code 0— es idéntico, porque es el mismo comando sobre la misma suite con las mismas dependencias. Esa coincidencia es la prueba de que tu tests.yml hará lo correcto: acabas de correr, con tus manos, lo mismo que el CI correrá solo. (Esa diferencia de plataforma, darwin vs linux, es justo la clase de detalle que a veces hace que un test pase en un lado y falle en otro —y reproducir y cerrar esa brecha es el módulo 3—.)
Paso 3: comprueba que el pipeline muerde (el rojo a propósito)
Un pipeline que solo has visto en verde tiene un problema silencioso: no sabes si se pondría rojo cuando debe. Igual que en la guía de fundamentos comprobabas que tus tests muerden rompiendo el código a propósito, aquí compruebas que tu pipeline muerde. Rompe una regla de Reservo —el descuento pro del 20% al 25% en reservo/pricing.py— y corre los mismos comandos. Qué esperar (real):
test_pricing.py .F.. [ 72%]
...
=========================== short test summary info ============================
FAILED test_pricing.py::test_pro_member_gets_twenty_percent_off_the_subtotal - AssertionError: assert 5625 == 6000
========================= 1 failed, 10 passed in 0.03s =========================
python3 -m pytest > /dev/null 2>&1; echo "exit code: $?"
exit code: 1
Exit code 1: en el runner, ese número pintaría el step Run the test suite de rojo con un ✗, y el job entero de rojo (lección 6 y 7). Confirmaste que tu pipeline no solo pasa cuando todo está bien, sino que falla cuando algo se rompe —que es todo el punto de tenerlo—. Ahora restaura el código (vuelve al 20%) y confirma que regresa a verde:
python3 -m pytest -q > /dev/null; echo "exit code: $?"
exit code: 0
De vuelta en 0. Este ciclo —verde da 0, rojo da 1, restauro, vuelve a 0— es la máquina del CI comprobada en tu propia terminal. Un pipeline que nunca viste ponerse rojo es un pipeline en el que no deberías confiar del todo.
Paso 4: cuelga el badge
Con el workflow en su sitio, agrega el badge al README para que la salud de la suite sea visible de un vistazo (lección 7). Suponiendo tu usuario ana-dev y el repo reservo:
[](https://github.com/ana-dev/reservo/actions/workflows/tests.yml)
Renderizado, mostrará una etiquetita tests | passing en verde que se actualiza sola con cada corrida en main, enlazada a la pestaña de corridas del workflow. Es la portada pública del estado del proyecto.
Paso 5: la nota de alcance
El último entregable no es código: es una nota corta —tres o cuatro líneas— de qué hace tu pipeline y qué no hace todavía, a propósito. Saber el alcance de lo que montaste, y decirlo, evita que alguien (o tú, en un mes) crea que el pipeline cubre más de lo que cubre. Una nota de ejemplo:
Alcance de
tests.yml. Corre la suite completa de Reservo (11 tests: precios, reembolsos, disponibilidad) en cada push y en cada pull request, sobre Python 3.14 en Ubuntu, instalando desderequirements.txt. El job se pone rojo si algún test falla (exit code ≠ 0). Queda para módulos siguientes: reproducir localmente un fallo que solo ocurra en CI y fijar entornos deterministas (módulo 3); correr en varias versiones de Python y sistemas operativos con una matriz (módulo 4); acelerar con caché de dependencias y paralelismo (módulo 5); y exigir un umbral de cobertura que rompa el build (módulo 6). Este pipeline es el piso, no el techo.
Fíjate en lo que hace esa nota: reconoce que el pipeline es el más simple que funciona —una versión de Python, un sistema operativo, sin caché ni puertas de cobertura— y dice por qué está bien que lo sea por ahora (esas mejoras vienen en módulos específicos). Eso es honestidad de ingeniería. Un pipeline que se presenta como "completo" cuando es el básico engaña; uno que dice "hace esto, dejé aquello para después, por esta razón" es confiable y deja claro el camino de mejora.
Errores comunes
Entregar el workflow sin haber corrido nada en local (de pipeline no comprobado). Qué pasa: alguien escribe un tests.yml que se ve correcto, lo sube, y descubre en el CI real que algo no cuadra —una versión mal escrita, un requirements.txt incompleto— que una corrida local habría revelado en segundos. Por qué pasa: el YAML "se ve bien" y uno confía en la vista. Cómo detectarlo: si no has corrido los comandos del workflow en tu máquina, no sabes si funcionan. Cómo corregirlo: haz siempre la paridad local del paso 2 —los mismos comandos, en tu terminal, con su salida verde— antes de confiar en el pipeline. El CI no es el lugar para descubrir que tu requirements.txt estaba mal; tu terminal sí.
Presentar el pipeline básico como "CI completo" (de falso alcance). Qué pasa: alguien monta este workflow de una versión y un sistema operativo y anuncia "ya tengo CI, está todo cubierto", cuando le falta la matriz, el caché, y las puertas de cobertura. Por qué pasa: montar el pipeline base se siente como terminar, porque es el salto grande (de nada a algo). Cómo detectarlo: intenta listar lo que tu pipeline no hace; si te salen varias cosas en treinta segundos (¿corre en otra versión de Python?, ¿exige cobertura?, ¿cachea?), no es completo. Cómo corregirlo: escribe la nota de alcance del paso 5. Nombrar lo que falta no es admitir una falla; es dejar claro el mapa de mejora, y esas mejoras son literalmente los módulos que siguen.
Copiar el YAML sin entender cada step, y quedar indefenso cuando falle (de copia a ciegas). Qué pasa: alguien pega un tests.yml de internet, funciona por suerte, y el día que se pone rojo por una razón de infraestructura (un checkout ausente, una versión mal puesta) no sabe ni por dónde empezar. Por qué pasa: copiar es más rápido que entender, hasta que hay que arreglar. Cómo detectarlo: si no puedes explicar qué hace cada step y por qué está en ese orden, copiaste sin entender. Cómo corregirlo: para cada step de tu workflow, sé capaz de decir qué prepara y qué pasaría sin él —justo lo que repasamos en el paso 1—. Un workflow que entiendes es uno que puedes arreglar; uno que copiaste es una caja negra que te va a bloquear el día que falle.
Ejercicios
Ejercicio 1 — Detecta los tres defectos. Un compañero te pasa este tests.yml "que no le funciona bien". Tiene tres problemas de lo que aprendiste en el módulo. Encuéntralos y corrígelos.
name: tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: 3.10
- run: pip install -r requirements.txt
- run: pytest
Ver solución
Los tres defectos:
- Falta el
checkout. No hay ningúnactions/checkout, así que el código nunca llega al runner:pip install -r requirements.txtno encontrará el archivo ypytestno hallará tests (exit code 5). Es el defecto más grave. Se corrige agregando el checkout como primer step. python-version: 3.10sin comillas. YAML lo lee como el número 3.1 (el cero final se pierde), pidiendo Python 3.1 en vez de 3.10. Se corrige con comillas:python-version: "3.10".- Falta actualizar pip / la higiene de instalación, y —más sutil— el
on:solo tienepush, sinpull_request, así que pierde el guardián del umbral en los pull requests. (Según cómo se cuente, el "tercer defecto" es cualquiera de los dos; ambos valen.)
Corregido:
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run the test suite
run: pytest
El más importante de detectar es el checkout ausente: sin él, el pipeline no prueba nada y (gracias al exit code 5 de pytest) al menos se pone rojo en vez de mentir en verde.
Ejercicio 2 — Justifica la paridad. Un compañero dice: "¿Para qué corro los comandos en local si de todas formas el CI los va a correr? Es trabajo doble." Respóndele explicando qué te da la paridad local que esperar al CI no te da, usando la idea central del módulo.
Ver solución
La paridad local no es trabajo doble; es feedback más rápido y más barato sobre el propio pipeline. Tres razones:
- Velocidad de diagnóstico. Si tu
requirements.txttiene un error o te falta una dependencia, en local lo descubres en segundos, en tu terminal, con todo el contexto a mano. Esperar al CI significa: hacer commit, push, esperar a que el runner arranque, instale y corra, leer el log, y recién ahí enterarte —un ciclo de minutos por cada intento—. Para errores tontos (una versión mal escrita), el local es órdenes de magnitud más rápido. - Confianza antes de subir. Correr los mismos comandos en local y verlos en verde es evidencia directa de que el pipeline hará lo correcto, antes de que nadie más vea tu código. Subes con confianza, no con esperanza.
- La idea central del módulo lo hace válido. "Lo que el CI le hace a tu suite es lo mismo que le hace tu máquina": mismo comando, misma suite, mismas dependencias. Por eso la salida local predice la del CI (salvo detalles como la plataforma). No es que corras algo distinto "por si acaso"; corres lo mismo, y por eso el resultado local te dice qué esperar del CI.
En una frase: la paridad local convierte el CI de "el lugar donde descubro que algo estaba mal" en "la confirmación automática de algo que ya sé que está bien". No es doble trabajo; es mover el descubrimiento al lugar más barato.
Ejercicio 3 — Escribe la nota de alcance de un cambio. Imagina que amplías el pipeline para que, además de correr en Python 3.14, corra también en 3.12 y 3.13 (una matriz, que es el módulo 4). Reescribe la nota de alcance del paso 5 reflejando ese cambio: qué cubre ahora y qué sigue quedando para después.
Ver solución
Una nota razonable tras agregar la matriz de versiones:
Alcance de
tests.yml. Corre la suite completa de Reservo (11 tests) en cada push y pull request, ahora sobre tres versiones de Python (3.12, 3.13 y 3.14) en Ubuntu, instalando desderequirements.txt. El job se pone rojo si algún test falla en cualquiera de las versiones (así atrapamos incompatibilidades específicas de una versión). Queda para módulos siguientes: reproducir un fallo que solo ocurra en CI y fijar entornos deterministas (módulo 3); acelerar la matriz con caché de dependencias y paralelismo, que ahora importa más porque corremos la suite tres veces (módulo 5); y exigir un umbral de cobertura que rompa el build (módulo 6). También sigue corriendo solo en un sistema operativo (Ubuntu); ampliar a Windows/macOS es otra dimensión de la matriz, a evaluar según dónde corran nuestros usuarios.
Lo importante de esta nota: refleja con precisión lo que cambió (tres versiones en vez de una, y qué implica —rojo si falla en cualquiera—) y actualiza lo que queda para después. Fíjate que agregar la matriz hace más relevante el módulo 5 (caché/velocidad), porque ahora la suite corre tres veces y el costo empieza a importar —la nota lo reconoce explícitamente—. Y mantiene la honestidad sobre otra dimensión aún sin cubrir (un solo sistema operativo). Una buena nota de alcance evoluciona con el pipeline: dice dónde está hoy y hacia dónde puede crecer.
Resumen y siguiente paso
En este mini-proyecto integraste el módulo entero produciendo un entregable real: el tests.yml que corre la suite de Reservo en cada push. Escribiste el workflow completo con steps nombrados, justificando cada decisión con la lección de la que viene —on: [push, pull_request], runs-on: ubuntu-latest, checkout primero, setup-python con "3.14" entre comillas, instalar desde requirements.txt, pytest—. Estableciste la paridad local: corriste en tu terminal, de verdad, los mismos pasos que el CI ejecutaría, y viste la misma salida verde (11 passed, exit code 0), con la única diferencia esperable en la plataforma. Comprobaste que el pipeline muerde, provocando un rojo real (exit code 1) y restaurándolo. Colgaste el badge. Y escribiste la nota de alcance: qué hace tu pipeline y qué queda, a propósito, para los módulos siguientes.
Con esto cierras el módulo 2. Mira todo lo que puedes hacer ahora que no podías al empezar: escribir desde cero un workflow de GitHub Actions y explicar cada línea; elegir los disparadores con criterio; preparar el runner con checkout y setup-python; instalar dependencias desde una lista pinneada; entender cómo el exit code de pytest pinta el job; leer un log de CI yendo directo al fallo; y colgar el badge. Montaste tu primer pipeline —el que convierte "corro los tests cuando me acuerdo" en "los tests se corren solos en cada cambio"—, y comprobaste con tus manos que hace lo que dice.
Lo que sigue es lo que ocurre cuando ese pipeline, un día, te da una sorpresa. Hasta ahora, cuando corriste la suite en local y en el CI (conceptualmente), obtuviste lo mismo. Pero recuerda esa única diferencia que viste en la línea platform: tu máquina es darwin, el runner es linux. Tarde o temprano, un test va a pasar en tu máquina y fallar en el CI —o al revés—, y esa brecha de entorno es desconcertante la primera vez. El módulo 3 se dedica entera a ella: por qué el CI en rojo y tu local en verde no se contradicen, cómo las dependencias pinneadas y las instalaciones deterministas cierran la brecha, y cómo reproducir en tu máquina el fallo que solo veías en CI. Ya sabes montar el pipeline; ahora vas a aprender a confiar en él cuando su veredicto no coincide con el tuyo.
Recursos
- Build and test Python (documentación de GitHub Actions) — la guía oficial de GitHub para probar proyectos de Python, con el workflow de ejemplo completo que integraste aquí. El primer lugar a consultar cuando montes un pipeline real.
- Cómo invocar pytest (documentación de pytest) — la referencia de las formas de correr pytest (completa, filtrada, silenciosa) que usaste en la paridad local, y la sección de exit codes que conecta con el color del job. La referencia del día a día.
- Guía de inicio rápido de GitHub Actions — el tutorial oficial de "crea tu primer workflow", que recorre el mismo camino que este mini-proyecto desde el lado de GitHub. Útil si quieres montarlo en un repositorio real tuyo, paso a paso, con capturas de la pestaña Actions.