Módulo 7: CI Integration con GitHub Actions
Correr pytest en GitHub Actions
Correr pytest en GitHub Actions
Descripción de la cápsula
En la cápsula anterior configuraste un workflow básico: checkout, setup Python, install deps, run pytest. Pero en proyectos reales necesitas más: ver claramente qué tests fallaron, guardar reportes como artifacts, ejecutar solo unit tests o solo integration tests según el contexto, y manejar variables de entorno como API keys en un entorno seguro.
Esta cápsula profundiza en ejecutar pytest en CI: desglose de los steps, manejo de salida y fallos, publicación de resultados como artifacts, ejecución de categorías específicas de tests (unit, integration, e2e), interpretación de logs en la UI de GitHub, problemas frecuentes y uso de secrets para variables sensibles.
Al final tendrás un pipeline robusto que te da visibilidad clara cuando algo falla y que maneja correctamente las dependencias y configuraciones de tu proyecto.
Desglose de los steps para pytest en CI
Secuencia típica
Step 1: Checkout → Obtener el código
Step 2: Setup Python → Versión correcta
Step 3: Install deps → pytest, pytest-cov, dependencias del proyecto
Step 4: Run pytest → Ejecutar tests
Step 5 (opcional): Upload artifacts → Guardar reportes
Step 1: Checkout
- name: Checkout repository
uses: actions/checkout@v4
Sin checkout, el runner está vacío. Esta acción clona el repo en el directorio de trabajo. Por defecto usa la ref del evento (el commit del push o el head del PR).
Step 2: Setup Python
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
Indica la versión que usas en desarrollo. Si tu proyecto soporta varias versiones, usarás matrix en la cápsula 04.
Step 3: Install dependencies
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
Si tienes dependencias de test separadas:
# requirements.txt
fastapi>=0.100.0
uvicorn
# requirements-dev.txt (o en pyproject.toml [project.optional-dependencies] dev)
pytest
pytest-cov
httpx
Entonces:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
Step 4: Run pytest
- name: Run tests
run: pytest tests/ -v
Por defecto pytest busca tests en test_*.py o *_test.py. El path tests/ indica el directorio. Si tu estructura es diferente (ej. test/ o tests en la raíz), ajusta el path.
Flags útiles para CI:
| Flag | Uso |
|---|---|
-v | Verbose: muestra cada test |
--tb=short | Tracebacks cortos en fallos |
--no-header | Menos ruido en logs |
-x | Detener en el primer fallo (opcional) |
-q | Modo quiet (menos verbose) |
Ejemplo con flags recomendados para CI:
- name: Run tests
run: pytest tests/ -v --tb=short --no-header -q
Manejo de salida y fallos
Qué pasa cuando un test falla
pytest retorna exit code 1 cuando algún test falla. El runner de GitHub Actions interpreta eso como fallo del step. El job falla y el workflow marca la ejecución como failed.
Ver el fallo en los logs
En la UI de GitHub, abre el step "Run tests" que falló. Verás algo como:
============================= test session starts ==============================
tests/test_auth.py::test_login_success PASSED [ 25%]
tests/test_auth.py::test_login_invalid_credentials FAILED [ 50%]
tests/test_items.py::test_list_items PASSED [ 75%]
tests/test_items.py::test_create_item PASSED [100%]
=================================== FAILURES ===================================
_____________ test_login_invalid_credentials _____________
def test_login_invalid_credentials():
response = client.post("/login", json={"user": "x", "pass": "y"})
> assert response.status_code == 401
E AssertionError: assert 200 == 401
E + where 200 = <Response [200 OK]>.status_code
tests/test_auth.py:42: AssertionError
======================== 1 failed, 3 passed in 2.15s =========================
La línea clave es > assert response.status_code == 401. Ahí pytest te muestra el assert que falló y el valor real vs esperado.
Usar -x para fallar rápido
Si tienes 500 tests y el primero que falla rompe una cadena de dependencias, a veces conviene detener en el primer fallo:
- name: Run tests
run: pytest tests/ -v -x
Con -x, pytest se detiene en el primer FAILED y no ejecuta el resto. Ahorra tiempo cuando el primer fallo ya indica un problema grave. Para CI de PRs, muchos prefieren correr todos los tests para ver el panorama completo; -x es útil en desarrollo local o en pipelines muy largos.
Exit code y comportamiento del job
Si pytest retorna 1, el step falla. Los steps siguientes del mismo job no se ejecutan. Si tienes un step "Upload coverage" después de "Run tests", ese upload no correrá si los tests fallaron. Por eso a veces se usa continue-on-error para uploads que quieres ejecutar incluso con fallos (para ver coverage parcial), pero en general quieres que el job falle cuando los tests fallan.
Publicar resultados como artifacts
¿Qué es un artifact?
Un artifact es un archivo o directorio que el workflow guarda después de que el job termina. Puedes descargarlo desde la UI de GitHub. Útil para reportes HTML de coverage, resultados de pytest en formato JUnit XML, o logs.
Subir htmlcov como artifact
- name: Run tests with coverage
run: |
pip install pytest-cov
pytest tests/ -v --cov=src --cov-report=html
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always() # Sube incluso si tests fallan
with:
name: coverage-report
path: htmlcov/
Después de la ejecución, en la página del workflow run verás "Artifacts" con un enlace para descargar coverage-report.zip. Dentro está el contenido de htmlcov/ — descomprimes y abres index.html para ver el reporte.
Subir resultados JUnit XML
pytest puede generar resultados en formato JUnit XML, que GitHub Actions puede parsear para mostrar un resumen de tests en la UI.
- name: Run tests
run: pytest tests/ -v --junitxml=test-results.xml
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: test-results.xml
Esta acción muestra en la UI una tabla con tests pasados/fallados. Requiere instalar la acción; hay alternativas como dorny/test-reporter o simplemente subir el XML como artifact y descargarlo para análisis local.
Artifact solo si hay fallos
- name: Upload logs on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: pytest-output
path: test-results.xml
Solo sube el artifact cuando el job falla. Útil para no acumular artifacts innecesarios en ejecuciones exitosas.
Ejecutar categorías de tests
Marcando tests con markers
En pytest.ini o pyproject.toml:
[tool.pytest.ini_options]
markers = [
"unit: Unit tests (rápidos, sin I/O)",
"integration: Integration tests (DB, API, red)",
"e2e: End-to-end tests (lentos)",
]
En los tests:
import pytest
@pytest.mark.unit
def test_add():
assert add(2, 3) == 5
@pytest.mark.integration
def test_api_create_item():
response = client.post("/items", json={"name": "x"})
assert response.status_code == 201
@pytest.mark.e2e
def test_full_user_flow():
# Test que abre browser, navega, etc.
pass
Correr solo unit en CI rápido
- name: Run unit tests
run: pytest tests/ -v -m unit
Solo ejecuta tests marcados con @pytest.mark.unit. Útil si quieres un job rápido que corre en cada push, y un job más lento con integration/e2e que corre solo en PRs a main.
Correr unit + integration en CI estándar
- name: Run tests
run: pytest tests/ -v -m "unit or integration"
O excluir e2e:
- name: Run tests (excluyendo e2e)
run: pytest tests/ -v -m "not e2e"
Jobs separados para diferentes categorías
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/ -v -m unit
integration:
runs-on: ubuntu-latest
needs: unit # Solo corre si unit pasa
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/ -v -m integration
El job integration solo corre si unit pasa. Ahorra tiempo cuando unit ya falló.
Interpretar logs de fallos en la UI de GitHub
Estructura típica del log
- Salida de pip: Si el step de install falla, verás el error de pip (paquete no encontrado, conflicto de versiones, etc.).
- Salida de pytest: La sesión de tests, cada test con PASSED/FAILED, y al final el resumen.
- Traceback: Cuando un test falla, pytest imprime el traceback. La línea con
>indica el assert que falló.
Errores comunes en los logs
| Mensaje | Causa probable | Acción |
|---|---|---|
ModuleNotFoundError: No module named 'X' | Falta instalar X o el path no incluye el paquete | Añadir X a requirements, o configurar pythonpath |
ImportError: cannot import name 'Y' | Y no existe o está en otro módulo | Revisar imports en el código |
Fixture 'Z' not found | Fixture en conftest que no se cargó o typo | Verificar conftest.py, scope, nombres |
FAILED tests/test_x.py::test_y | El test falló | Ir al traceback, ver assert y valor real |
No module named 'tests' | El directorio tests no está en el path | Ejecutar desde raíz: pytest tests/ |
Collecting ... 0 items | pytest no encontró tests | Verificar que los archivos empiecen con test_ y las funciones con test_ |
Flujo de diagnóstico cuando falla el job
- Abre el workflow run en la pestaña Actions.
- Identifica el job que falló (marca roja).
- Clic en el job y busca el step que falló (el primero con X roja).
- Si es "Install dependencies": revisa el error de pip. Suele ser paquete inexistente o conflicto de versiones.
- Si es "Run tests": lee el traceback. La línea con
>indica el assert. El valor mostrado (E AssertionError: assert X == Y) te dice qué esperabas vs qué obtuviste. - Copia el fragmento relevante (desde
===== FAILURES =====hasta el final del traceback) y úsalo con Claude Code o en una búsqueda para diagnosticar.
Buscar en los logs
En la página del step, usa Ctrl+F (Cmd+F) para buscar:
FAILED— Encuentra rápidamente qué tests fallaron.Erroroerror:— Errores de import o configuración.ModuleNotFoundError— Para diagnosticar problemas de dependencias.
Variables de entorno en CI
Variables de entorno básicas
jobs:
test:
runs-on: ubuntu-latest
env:
DATABASE_URL: "sqlite:///./test.db"
LOG_LEVEL: "WARNING"
PYTHONUNBUFFERED: "1"
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- run: pytest tests/
Los tests pueden leer os.environ["DATABASE_URL"] o usar os.getenv("DATABASE_URL"). Útil para tests de integración que usan una DB de prueba.
Usar GitHub Secrets para valores sensibles
Nunca pongas API keys, tokens o passwords en el YAML. Usa Secrets:
- En GitHub: Settings → Secrets and variables → Actions.
- New repository secret. Nombre:
API_KEY, valor: tu clave. - En el workflow:
jobs:
test:
runs-on: ubuntu-latest
env:
API_KEY: ${{ secrets.API_KEY }}
steps:
- uses: actions/checkout@v4
- run: pytest tests/
Si el secret no existe, secrets.API_KEY será vacío. Asegúrate de crear el secret antes de que corra el workflow.
Secrets para tests que llaman APIs reales
Algunos proyectos tienen tests que llaman a APIs externas (sandbox) y necesitan un token. Configura el secret en el repo y pásalo como env. En los tests, si API_KEY está vacío, puedes saltarte el test:
import os
import pytest
@pytest.mark.skipif(not os.getenv("API_KEY"), reason="API_KEY not set")
def test_external_api():
# Test que usa la API real con el token
pass
Variables de entorno por step
steps:
- name: Run unit tests
run: pytest tests/ -m unit
env:
USE_MOCK: "1"
- name: Run integration tests
run: pytest tests/ -m integration
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
Cada step puede tener su propio bloque env que sobrescribe o amplía el del job.
Práctica guiada: pipeline con artifacts y markers
Sigue estos pasos para construir un pipeline completo con pytest, coverage y artifacts.
1. Crear estructura de tests con markers
# tests/test_calc.py
import pytest
from src.calc import add, multiply
@pytest.mark.unit
def test_add():
assert add(2, 3) == 5
@pytest.mark.unit
def test_multiply():
assert multiply(3, 4) == 12
2. Registrar markers en pyproject.toml
[tool.pytest.ini_options]
markers = ["unit: Unit tests"]
testpaths = ["tests"]
3. Añadir coverage y artifacts al workflow
Modifica el step de pytest para generar coverage y subir el reporte:
- name: Run tests with coverage
run: |
pip install pytest-cov
pytest tests/ -v -m unit --cov=src --cov-report=html --cov-report=term-missing
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: htmlcov/
4. Verificar en GitHub
Haz push, espera a que el workflow termine, y en la página de la ejecución descarga el artifact coverage-report. Descomprime y abre htmlcov/index.html para ver el reporte visual de coverage.
5. Probar con un test que falla
Intencionalmente cambia un assert para que falle. Haz push. Verás que el workflow marca failed, pero el artifact de coverage se subió igual (por if: always()). Los logs del step "Run tests" mostrarán el traceback del test fallido.
Interpretación detallada: de la UI al fix
Cuando un workflow falla, el flujo de diagnóstico es:
- Actions → Run fallida → Clic en la ejecución.
- Job fallido → Clic en el job (ej. "test") para ver los steps.
- Step fallido → Clic en el step con X roja. Suele ser "Run tests" o "Install dependencies".
- Log del step → Scroll hasta el final. El error suele estar ahí. Busca
FAILED,Error,Traceback. - Copia el traceback → Desde
def test_xxxhasta la línea del assert. Ese bloque es lo que necesitas para corregir.
Si el fallo es en "Install dependencies", el problema está en requirements o en la sintaxis del comando pip. Si es en "Run tests", el problema está en el código o en los tests. El traceback te dice el archivo y la línea exacta.
Resumen de flags pytest para CI
| Flag | Propósito |
|---|---|
-v | Verbose: nombre de cada test |
--tb=short | Traceback corto en fallos (menos líneas) |
--tb=line | Solo la línea del error |
-x | Detener en el primer fallo |
-q | Quiet: menos output |
--no-header | Sin banner de sesión |
-m unit | Solo tests con marker unit |
-m "not slow" | Excluir tests marcados slow |
Combinación recomendada para CI: pytest tests/ -v --tb=short — suficiente información sin saturar los logs.
Ejemplo: diagnosticar un ModuleNotFoundError
Si el log muestra:
ModuleNotFoundError: No module named 'src'
Pasos:
- Verifica que el código esté en
src/y que los tests importenfrom src.X import Y. - El runner ejecuta desde la raíz del repo (tras checkout). Si
srcno está en PYTHONPATH, Python no lo encuentra. - Añade
env: PYTHONPATH: .al job, o instala el paquete conpip install -e .si tienes pyproject.toml. - Haz push y verifica. Si el error persiste, puede que la estructura de directorios sea distinta (ej.
app/en lugar desrc/); ajusta el path.
Cuándo usar markers vs jobs separados
| Estrategia | Uso |
|---|---|
| Un job con markers | Proyectos pequeños, todos los tests corren en cada push. pytest -m unit o pytest -m "not e2e". |
| Jobs separados | Unit rápido siempre; integration solo en PR o en schedule. needs: unit para que integration espere. |
| Job de e2e en schedule | E2E lentos (browser, APIs externas) en cron: on: schedule: - cron: '0 2 * * *' (2am diario). |
Para el proyecto del Módulo 7, un solo job con todos los tests suele ser suficiente. Si tu suite tarda más de 5 minutos, considera separar unit (rápido, cada push) de integration (más lento, cada PR).
Workflow completo con pytest en CI
# .github/workflows/tests.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
env:
PYTHONUNBUFFERED: "1"
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Run tests
run: pytest tests/ -v --tb=short --cov=src --cov-report=html --cov-report=term-missing
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: htmlcov/
Este workflow instala deps, corre pytest con coverage, y sube el reporte HTML como artifact. El artifact se genera tanto si los tests pasan como si fallan (if: always()), así puedes inspeccionar el coverage incluso cuando hay fallos.
Ejercicios
Ejercicio 1: Añadir flags de pytest (Básico)
Modifica el step de pytest de tu workflow para usar -v --tb=short. Haz que un test falle intencionalmente, pushea, y verifica que el log muestre un traceback legible con el assert que falló.
Ver solución
- name: Run tests
run: pytest tests/ -v --tb=short
En el test, pon assert False o assert 1 == 2. Pushea. En Actions, abre el step "Run tests". Deberías ver algo como:
> assert False
E AssertionError
El flag --tb=short hace el traceback más compacto. Sin él, pytest muestra el traceback completo con contexto de cada frame.
Ejercicio 2: Publicar htmlcov como artifact (Intermedio)
Añade pytest-cov a tu proyecto. Configura el workflow para generar htmlcov/ y subirlo como artifact. Ejecuta el workflow, descarga el artifact, descomprímelo y abre htmlcov/index.html en el navegador.
Ver solución
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest-cov
- name: Run tests with coverage
run: pytest tests/ --cov=src --cov-report=html
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: htmlcov/
Asegúrate de que --cov=src coincida con la estructura de tu proyecto (puede ser app, my_package, etc.). Después de la ejecución, en la página del workflow run verás "Artifacts" → Descargar "coverage-report". Descomprime y abre index.html.
Ejercicio 3: Ejecutar solo unit tests (Intermedio)
Tu proyecto tiene tests marcados con @pytest.mark.unit y @pytest.mark.integration. Configura el workflow para correr solo los unit tests. Añade un marker unit en pyproject.toml si no lo tienes.
Ver solución
En pyproject.toml:
[tool.pytest.ini_options]
markers = ["unit: Unit tests"]
En el workflow:
- name: Run unit tests
run: pytest tests/ -v -m unit
Si no defines el marker, pytest puede advertir. Definirlo en markers evita la advertencia y documenta el propósito.
Ejercicio 4: Variable de entorno para test (Intermedio)
Tienes un test que usa os.getenv("TEST_MODE") para decidir si usar mocks o una DB real. Añade TEST_MODE=1 como variable de entorno en el job de tests del workflow.
Ver solución
jobs:
test:
runs-on: ubuntu-latest
env:
TEST_MODE: "1"
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- run: pytest tests/
En el test: mode = os.getenv("TEST_MODE", "0") — en CI será "1", localmente "0" por defecto.
Ejercicio 5: Diagnosticar ModuleNotFoundError (Avanzado)
El workflow falla con ModuleNotFoundError: No module named 'src'. Tu proyecto tiene src/my_module/ y los tests hacen from src.my_module import something. ¿Qué puedes cambiar en el workflow o en la estructura para que funcione?
Ver solución
Opciones:
- Añadir el directorio raíz al PYTHONPATH:
env:
PYTHONPATH: .
O en el step de pytest:
- name: Run tests
run: pytest tests/
env:
PYTHONPATH: .
- Instalar el paquete en modo editable: Si tienes
pyproject.tomlcon el paquete configurado:
- run: pip install -e .
Eso añade el paquete al path.
- Ejecutar pytest con el path: Algunos proyectos usan
python -m pytest tests/que puede resolver mejor los imports según la estructura.
Ejercicio 6: Secret para API key (Avanzado)
Tienes un test de integración que llama a una API externa y necesita API_KEY. Configura un secret en el repo (aunque sea un valor fake para la práctica) y úsalo en el workflow. Verifica que el test lo reciba.
Ver solución
- Settings → Secrets and variables → Actions → New repository secret.
- Nombre:
API_KEY, Valor: (tu clave o un valor fake tipotest-key-123).
En el workflow:
jobs:
test:
runs-on: ubuntu-latest
env:
API_KEY: ${{ secrets.API_KEY }}
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- run: pytest tests/
En el test puedes hacer key = os.getenv("API_KEY") y usarla para la llamada. Si el secret no existe, será cadena vacía. Puedes añadir @pytest.mark.skipif(not os.getenv("API_KEY"), reason="No API_KEY") para saltar el test cuando no hay key.
Troubleshooting
Tests pasan local pero fallan en CI
Causa: Diferencias de entorno: versión de Python, dependencias, variables de entorno, timezone, paths.
Solución: Revisa que la versión de Python en el workflow coincida con la local. Si usas pathlib o rutas relativas, verifica que el directorio de trabajo sea el esperado (tras checkout es la raíz del repo). Ejecuta localmente en un venv limpio: pip install -r requirements.txt && pytest tests/ para simular CI.
"Fixture 'X' not found"
Causa: La fixture está definida en un conftest.py que pytest no está descubriendo, o hay un typo en el nombre.
Solución: Asegúrate de que conftest.py esté en tests/ o en la raíz. pytest lo descubre automáticamente. Si la fixture está en un conftest de un subdirectorio, verifica que los tests que la usan estén en ese subdirectorio o en hijos. Comprueba que el nombre de la fixture coincida exactamente.
Dependencias que faltan en CI
Causa: Algún paquete está instalado globalmente o en tu venv local pero no está en requirements.txt (o en pyproject.toml).
Solución: Haz pip freeze en tu venv local y compara con lo que instala el workflow. Todo lo que importas en tests debe estar en requirements. Si usas pytest-cov, httpx, etc., añádelos a requirements-dev o a la sección de test de pyproject.toml.
Import errors con estructura src/
Causa: El paquete src o app no está en el PYTHONPATH cuando pytest corre.
Solución: Añade PYTHONPATH: . al env del job, o instala el paquete con pip install -e .. En proyectos con src/ layout, pip install -e . suele configurar correctamente el path.
Artifact no se genera
Causa: El step que sube el artifact falló antes, o el path no existe, o if impide que corra.
Solución: Verifica que el step anterior genere el directorio/archivo (por ejemplo htmlcov/). Si usas if: success() (implícito sin if), el upload solo corre si el job pasa; si los tests fallan, no sube. Usa if: always() para subir siempre. Revisa que el path sea correcto (ej. htmlcov/ con la barra si es directorio).
Conexión con Proyecto
El pipeline de pytest en CI que construiste aquí es la base del proyecto del Módulo 7. En la cápsula 04 añadirás matrix testing (múltiples versiones de Python) y caching para acelerar builds. En la 05 integrarás coverage en los PRs y branch protection. Los steps que definiste — install, run pytest, upload artifacts — seguirán siendo los bloques centrales.
Resumen
- Los steps típicos para pytest en CI: checkout, setup Python, install deps, run pytest
- Cuando un test falla, pytest retorna 1 y el job falla; los logs muestran el traceback
- Puedes publicar reportes (htmlcov, JUnit XML) como artifacts para descargar
- Usa markers para ejecutar solo unit, integration o e2e según el job
- Las variables de entorno se configuran en
env:del job o del step - Los secrets se usan como
${{ secrets.NOMBRE }}para valores sensibles - Problemas comunes: ModuleNotFoundError, fixture not found, path/PYTHONPATH
Próxima cápsula: Matrix Testing y Caching — múltiples versiones de Python y builds más rápidos.
Recursos Adicionales
- pytest command line options - Flags de pytest
- GitHub Actions: upload-artifact - Subir artifacts
- pytest markers - Documentación de markers
- GitHub Encrypted secrets - Uso de secrets
- pytest JUnit XML - Formato JUnit para resultados
Módulo 7, Cápsula 03 — Testing with Claude Code Guide