Módulo 2: Tu primer pipeline — correr pytest en CI
6. Correr pytest y el exit code que pinta el job
Descripción
Todo lo anterior fue preparación: el runner tiene tu código, el Python correcto y las dependencias instaladas. Esta lección es aquello para lo que se preparó todo: correr la suite. Es el corazón del módulo, y contiene la idea más importante que te llevas de él: el CI descubre y corre tus tests exactamente igual que tú en tu terminal, y un único número —el exit code que pytest devuelve al terminar— es lo que GitHub lee para pintar el job de verde o de rojo. Al terminar vas a entender el step run: pytest, vas a ver la salida verde real de la suite de Reservo (corrida de verdad, 11 passed), vas a ver una corrida roja real con su exit code, y vas a saber qué significa cada código de salida.
Aquí la regla del módulo brilla con toda su fuerza. La salida de pytest que verás es real —corrimos la suite de Reservo con Python 3.14.0 y pytest 9.1.1—, y es idéntica a la que el step run: pytest produciría en el runner, porque es el mismo comando sobre la misma suite. El log de CI que ilustra dónde vive esa salida es "así se vería"; pero lo que ese log contendría es exactamente lo que corrimos. Esta lección es el mejor ejemplo de por qué la honestidad del módulo funciona: no necesitamos un CI real para mostrarte lo que el CI vería, porque el CI ve lo mismo que tu terminal.
Conexión con el módulo: este step cierra la cadena que armaron las lecciones 4 y 5 —corre sobre el código de checkout, el Python de setup-python, y las dependencias de pip install—. El exit code que aquí entiendes es lo que la lección 7 lee en el log para poner el ✓ o el ✗ del step y del job. Frontera importante: aquí provocamos un rojo para ver cómo se ve un fallo y su exit code, pero reproducir un fallo que ocurre en CI y no en tu local —la brecha de entorno— es el módulo 3. Aquí el rojo lo causamos a propósito y es el mismo en los dos lados; el módulo 3 estudia los rojos que solo pasan en CI.
El árbitro que solo levanta el pulgar o lo baja
Imagina un árbitro de un deporte que, al final de cada jugada, no explica nada: solo levanta el pulgar (válida) o lo baja (falta). Todo el análisis —qué pasó, quién tocó a quién, en qué minuto— ocurre en su cabeza y en la repetición; pero lo que el marcador y los jugadores reciben es un solo bit: pulgar arriba o pulgar abajo. Ese gesto binario es suficiente para que el juego siga: el marcador no necesita el análisis completo para sumar o no sumar el punto; necesita solo el veredicto.
pytest es el analista y el árbitro a la vez. En su salida —el texto que imprime— está todo el análisis: qué tests corrió, cuáles pasaron, cuál falló y con qué valor. Pero al terminar, además de imprimir todo eso, hace el gesto del árbitro: devuelve al sistema operativo un exit code, un solo número que resume el veredicto. 0 es pulgar arriba (todo pasó); cualquier otro número es pulgar abajo (algo salió mal). Y GitHub Actions, como el marcador, no lee el análisis completo para decidir el color del job: lee el gesto. Si pytest devuelve 0, el step es verde; si devuelve algo distinto de 0, el step es rojo, y el job entero se pinta de rojo.
Esa es la mecánica exacta de cómo "el CI sabe si tus tests pasaron": no interpreta el texto, no cuenta los passed. Corre pytest, y mira el número que pytest deja al salir. Todo el resto —el log bonito, las palomitas— es presentación de ese único bit. Entender esto es entender cómo funciona el CI por dentro.
El step, en una línea
- run: pytest
Eso es todo. Un step de tipo run (un comando de terminal, lección 2) que ejecuta pytest, palabra por palabra el mismo comando que corres en tu máquina. No hay una versión "de CI" de pytest ni una configuración mágica: es el pytest que instalaste en la lección 5, corriendo sobre el código que trajiste en la 4, con el Python de la 4. En el runner, este comando se ejecuta parado en la raíz de tu proyecto (donde checkout dejó el código), así que pytest hace su descubrimiento habitual: busca archivos test_*.py, encuentra las funciones test_, y las corre. Exactamente como en local.
En un proyecto real, muchos escriben pytest a secas en el run: porque, a diferencia de una terminal interactiva, el runner acaba de instalar pytest en el único Python activo y no hay ambigüedad de cuál se ejecuta. Cuando corras la suite tú, en tu terminal, la forma sin sorpresas sigue siendo python3 -m pytest (por la razón de la guía de fundamentos: garantiza el pytest de este Python). Las dos corren la misma suite; en el CI el entorno controlado hace que pytest a secas sea seguro.
Ejemplo trabajado: la corrida verde, de verdad
Corramos la suite de Reservo tal como el step run: pytest la correría. Esto se ejecutó de verdad en un entorno con Python 3.14.0 y pytest 9.1.1, sobre las tres carpetas de tests de Reservo (precios, reembolsos, disponibilidad).
python3 -m pytest
Qué esperar:
============================= test session starts ==============================
platform linux -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/runner/work/reservo/reservo
collected 11 items
test_availability.py .... [ 36%]
test_pricing.py .... [ 72%]
test_refunds.py ... [100%]
============================== 11 passed in 0.03s ==============================
Léela con lo que ya sabes de leer corridas de pytest, y fíjate en lo que es idéntico a tu local y en lo que es propio del runner:
platform linux— en el runner deubuntu-latestla plataforma eslinux. En tu Mac diríadarwin, en Windowswin32. Es de las poquísimas cosas que cambian entre tu máquina y el CI, y es una de las razones por las que un test puede pasar en un lado y fallar en otro (tema del módulo 3). El resto de la ficha —Python 3.14.0, pytest-9.1.1— es idéntico porquesetup-pythonypip installlo fijaron así a propósito.rootdir: /home/runner/work/reservo/reservo— la carpeta de trabajo del runner, dondecheckoutdejó el código. En tu máquina sería tu ruta local; el número que importa está debajo.collected 11 items— el descubrimiento en acción, igual que en local: pytest encontró los 11 tests de Reservo (4 de disponibilidad, 4 de precios, 3 de reembolsos) sin que le dijeras cuáles. El CI no mantiene ninguna lista de tests; los descubre en cada corrida, como tú.- La línea de progreso —
test_availability.py ....,test_pricing.py ....,test_refunds.py ...— un punto por test que pasa, archivo por archivo. Once puntos, once tests verdes. 11 passed in 0.03s— el veredicto. Once tests, todos pasaron, en tres centésimas de segundo (Reservo es lógica pura, vuela).
Ahora la pieza que conecta con el CI, la que no se ve en el texto pero es la que GitHub lee. Cuando pytest termina esta corrida verde, devuelve exit code 0. Podemos comprobarlo en la terminal preguntando por el código de salida del último comando:
python3 -m pytest -q > /dev/null; echo "exit code: $?"
exit code: 0
0 es el pulgar arriba. En el runner, GitHub ve ese 0 y pinta el step run: pytest de verde, y como es el último step del job, el job entero queda verde. Esa es toda la conexión entre "los tests pasaron" y "el pipeline está verde": pytest devolvió 0, GitHub lo leyó. Sin interpretar el texto, sin contar los passed.
Ejemplo trabajado: la corrida roja y su exit code
Para ver el otro lado del pulgar, provoquemos un fallo —de la misma forma honesta que la guía de fundamentos: rompemos el código a propósito—. En reservo/pricing.py, cambiamos el descuento pro del 20% al 25% (un bug plausible: alguien "mejora" la promoción sin avisar). Esto también se ejecutó de verdad:
============================= test session starts ==============================
platform linux -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/runner/work/reservo/reservo
collected 11 items
test_availability.py .... [ 36%]
test_pricing.py .F.. [ 72%]
test_refunds.py ... [100%]
=================================== FAILURES ===================================
_____________ test_pro_member_gets_twenty_percent_off_the_subtotal _____________
def test_pro_member_gets_twenty_percent_off_the_subtotal():
> assert price_cents(FOCUS, BETO, 3) == 6000
E AssertionError: assert 5625 == 6000
E + where 5625 = price_cents(Room(id='r1', name='Focus', capacity=1, hourly_cents=2500), Member(id='m2', name='Beto', tier='pro'), 3)
test_pricing.py:17: AssertionError
=========================== 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 =========================
Y su exit code:
python3 -m pytest > /dev/null 2>&1; echo "exit code: $?"
exit code: 1
Lee la corrida y el número juntos, porque es donde todo cuaja:
- La línea de progreso
test_pricing.py .F..ya dibuja el daño: unaFentre los puntos, el test de descuento pro que falló. Los otros diez tests siguen verdes (los tres archivos, menos ese uno). - El diagnóstico es real y preciso:
assert 5625 == 6000. Con 25% de descuento,7500 − 1875 = 5625, no los 6000 esperados. pytest te muestra el valor exacto que salió mal, igual en CI que en local —el assert rewriting no cambia porque estés en la nube—. - El veredicto
1 failed, 10 passedy, la pieza clave, el exit code 1. Cualquier fallo hace que pytest devuelva 1 (pulgar abajo). En el runner, GitHub ve ese1, pinta el steprun: pytestde rojo con un ✗, y el job entero se pinta de rojo. El push (o el pull request) queda marcado en rojo, y ahí está la alarma que el módulo 1 prometía: el pipeline te avisa, sin que nadie tuviera que acordarse de correr los tests.
Una vez visto, restauramos el código (vuelta al 20%) y la suite regresa a 11 passed y exit code 0. Ese ciclo —verde da 0, rojo da algo distinto de 0— es la máquina entera del CI. El color del job es una función de un número.
Los códigos de salida de pytest que vas a encontrar
pytest no devuelve solo 0 o 1; tiene un pequeño catálogo de códigos, y vale la pena conocer los tres que más aparecen, porque cada uno cuenta una historia distinta al CI:
| Exit code | Qué significa | Cómo se ve el job en CI |
|---|---|---|
| 0 | Todos los tests pasaron. | Verde ✓ |
| 1 | Al menos un test falló. | Rojo ✗ |
| 5 | No se recogió ningún test (no tests ran). | Rojo ✗ |
El 0 y el 1 ya los viste. El 5 es traicionero y merece atención: significa que pytest corrió pero no encontró ningún test que correr. Lo comprobamos de verdad corriendo pytest en una carpeta vacía:
============================= test session starts ==============================
platform linux -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/runner/work/reservo/reservo
collected 0 items
============================ no tests ran in 0.00s =============================
exit code: 5
Por qué el 5 importa tanto en CI: si pytest devolviera 0 cuando no encuentra tests, un workflow con los tests mal ubicados o mal nombrados pasaría en verde sin haber probado nada —la peor mentira posible, un pipeline que dice "todo bien" sin haber revisado nada—. pytest evita esa trampa devolviendo 5, distinto de 0, así que el job se pinta de rojo y te obliga a mirar. Si alguna vez ves un CI rojo con "no tests ran" y exit code 5, no es que tus tests fallaran: es que el CI no encontró tus tests (ruta equivocada, nombres mal puestos, un checkout ausente). El 5 es pytest protegiéndote de un falso verde.
Errores comunes
Creer que el CI "lee" cuántos tests pasaron en el texto (de modelo mental equivocado). Qué pasa: alguien imagina que GitHub parsea la línea 11 passed para decidir el color, y se confunde cuando algo no cuadra. Por qué pasa: el texto es lo visible, así que uno asume que es lo que se lee. Cómo detectarlo: si intentas explicar por qué un job está rojo hablando del texto en vez del exit code, tienes el modelo al revés. Cómo corregirlo: recuerda que GitHub lee el exit code, no el texto. El texto es para el humano; el número es para la máquina. Un step es verde si su comando devolvió 0, y rojo si devolvió cualquier otra cosa —da igual qué diga el texto—.
Confundir un exit code 5 (no encontró tests) con un fallo de tests (de diagnóstico erróneo). Qué pasa: el CI está rojo, alguien ve "no tests ran", y se pone a buscar qué test falló —cuando no falló ninguno, el problema es que no encontró ninguno—. Por qué pasa: rojo se asocia automáticamente con "un test falló", pero el 5 es un rojo de otra naturaleza. Cómo detectarlo: collected 0 items y no tests ran con exit code 5 significan "no hallé tests", no "un test se rompió". Cómo corregirlo: cuando veas el 5, revisa por qué el CI no encontró tus tests —¿el checkout trajo el código?, ¿los nombres siguen la convención test_*?, ¿pytest corre desde la carpeta correcta?—. Es un problema de descubrimiento, no de lógica.
Suprimir el exit code y volver el job verde a la fuerza (de silenciar la alarma). Qué pasa: alguien, harto de ver rojo, escribe run: pytest || true —el || true fuerza que el comando "termine bien" pase lo que pase—, y el job queda verde aunque los tests fallen. Por qué pasa: se confunde "quiero que el CI deje de molestar" con "quiero arreglar el problema". Cómo detectarlo: si tu step de pytest tiene un || true, un continue-on-error, o cualquier cosa que ignore el código de salida, tu CI ya no protege nada —siempre estará verde—. Cómo corregirlo: nunca silencies el exit code de pytest; ese número es la protección. Un CI que siempre está verde es exactamente igual de útil que no tener CI. Si el rojo molesta, se arregla la causa (el test o el código), no se apaga la alarma.
Ejercicios
Ejercicio 1 — Predice el color del job. Para cada una de estas tres corridas de pytest en el runner, di qué exit code devuelve y de qué color queda el job en CI (verde o rojo), y por qué: (a) 11 passed in 0.03s; (b) 2 failed, 9 passed in 0.04s; (c) no tests ran in 0.00s.
Ver solución
- (a)
11 passed→ exit code 0 → job verde. Todos los tests pasaron; pytest devuelve 0 (pulgar arriba) y GitHub pinta el step de verde. - (b)
2 failed, 9 passed→ exit code 1 → job rojo. Basta que un test falle para que pytest devuelva 1; que dos fallen y nueve pasen no cambia el veredicto binario: hubo fallos, el número es 1, el job es rojo. - (c)
no tests ran→ exit code 5 → job rojo. pytest no encontró ningún test; devuelve 5 (distinto de 0) precisamente para que el job sea rojo y no un falso verde. Es un rojo de "no encontré tests", distinto en causa al de (b), pero rojo al fin.
El hilo: el color depende solo de si el exit code es 0 o no. 0 es el único verde; 1 y 5 (y cualquier otro no-cero) son rojo. El texto explica por qué, pero el número decide el color.
Ejercicio 2 — Diagnostica el falso peligro. Un compañero abre su CI y lo ve rojo. El log del step de pytest muestra esto. Está convencido de que rompió algún test y lleva media hora revisando su lógica de precios. ¿Qué le dirías?
collected 0 items
============================ no tests ran in 0.00s =============================
Exit code: 5
Ver solución
Le diría que deje de buscar un test roto, porque ninguno se rompió: el exit code 5 y el collected 0 items significan que pytest no encontró ningún test que correr. No es un fallo de lógica; es un fallo de descubrimiento. Su lógica de precios puede estar perfecta —pytest ni siquiera llegó a ejecutarla, porque no halló tests—.
Las causas típicas del 5, en orden de probabilidad para revisar:
- El
checkoutno trajo el código (o falta el step), así que la carpeta de tests no está en el runner.rootdiry la ausencia de archivos lo delatarían. - Los tests están mal ubicados o mal nombrados: archivos que no empiezan con
test_, funciones que no empiezan contest_, o una carpeta que pytest no está mirando. - pytest corre desde la carpeta equivocada, sin los tests debajo.
La corrección depende de cuál sea, pero el diagnóstico correcto ahorra la media hora: rojo con exit code 5 no es "un test falló", es "no encontré tests". Revisar la lógica de un test que ni siquiera corrió es buscar en el lugar equivocado. (Que el CI sea rojo aquí en vez de un engañoso verde es, de hecho, pytest protegiéndolo: un pipeline que pasara en verde sin haber probado nada sería mucho peor.)
Ejercicio 3 — Explica por qué no se silencia. Un compañero, cansado de que el CI se ponga rojo mientras trabaja, propone cambiar el step a run: pytest || true "para que el pipeline no bloquee mientras arreglo las cosas". Explícale con la mecánica del exit code por qué eso vacía de sentido al CI, y qué debería hacer en su lugar.
Ver solución
El || true hace que el step siempre termine con exit code 0, pase lo que pase con los tests: el pytest puede devolver 1 (fallos) o 5 (sin tests), pero el || true lo reemplaza por un 0 antes de que GitHub lo lea. Y como GitHub decide el color del job solo por el exit code, el step —y el job— quedará verde para siempre, aunque la mitad de los tests estén rotos.
Eso vacía de sentido al CI porque su único trabajo es avisarte cuando algo se rompe, y avisa poniéndose rojo. Un CI que no puede ponerse rojo no avisa de nada: es exactamente igual de útil que no tener CI, con el agravante de que da una falsa sensación de seguridad —el verde miente—. La alarma de humo desconectada se ve igual que la que funciona, hasta el incendio.
Qué debería hacer en su lugar: dejar que el CI se ponga rojo y arreglar la causa. Si un test falla, arregla el test o el código; si el rojo es porque está a media faena, esa es justamente la señal de que ese cambio todavía no está listo para compartir —el punto del CI es que no se comparta código roto—. Si quiere iterar rápido sin esperar al CI, corre los tests en local (python3 -m pytest -k "lo-que-arreglo") mientras trabaja, y sube cuando estén verdes. La solución nunca es silenciar el número que es la protección; es atender lo que el número te dice.
Resumen y siguiente paso
En esta lección llegaste al corazón del módulo: el step run: pytest y el exit code. Entendiste la idea central —el CI descubre y corre tu suite exactamente igual que tu terminal: mismo comando, misma suite, mismo descubrimiento— y viste la salida verde real de Reservo (11 passed, corrida de verdad con Python 3.14.0 y pytest 9.1.1). Sobre todo, entendiste la mecánica por la que "el CI sabe cómo te fue": pytest, como un árbitro, devuelve un exit code al terminar, y GitHub lo lee para pintar el job. 0 es todo verde; 1 es al menos un test falló (lo viste real, con el bug del 25% y su assert 5625 == 6000); 5 es no encontró tests —un rojo que te protege de un falso verde—. El color del job es una función de un solo número, no del texto.
Antes de avanzar deberías poder: escribir el step que corre pytest; explicar cómo el exit code determina el color del job; nombrar qué significan 0, 1 y 5; distinguir un rojo de "test falló" (1) de uno de "no encontré tests" (5); y argumentar por qué silenciar el exit code destruye el valor del CI.
Ya tienes el workflow completo de punta a punta —cuándo, dónde, y todos los pasos del qué—. Lo que falta es aprender a leer lo que produce cuando corre. En la lección 7 abrimos el log de CI: cómo se organiza (jobs que contienen steps colapsables, un ✓ o ✗ por cada uno), dónde vive exactamente la salida de pytest dentro del step que la generó, cómo el ✗ de un step corresponde al exit code no-cero que acabas de entender, y cómo se cuelga el badge de estado en el README —esa palomita verde que le dice al mundo, de un vistazo, que la suite pasa—. Es la lección que te enseña a interpretar el resultado del pipeline que construiste.
Recursos
- Cómo invocar pytest (documentación de pytest) — la referencia oficial de cómo se corre pytest y, al final, la sección "Possible exit codes" con la lista completa de códigos de salida (0 a 5). La fuente exacta de la tabla de esta lección.
- Get Started de pytest — el flujo básico de correr pytest y leer su salida, la misma que el CI produce. Útil para afianzar la lectura de una corrida verde y una roja.
- Variables de entorno y estado de los jobs (documentación de GitHub Actions) — cómo GitHub Actions determina el éxito o fallo de un step y un job a partir del código de salida de sus comandos. El lado "GitHub" de la mecánica que esta lección explicó desde el lado "pytest".