Módulo 5: Coverage, TDD y CI
GitHub Actions: workflow básico
Tienes tests + coverage + TDD. Pero todo eso depende de disciplina humana: alguien tiene que correr pytest antes de pushear. En equipos reales, eso falla:
- Developer apurado pushea sin correr tests
- Tests pasaban en local pero CI usa Python 3.12 y prod tiene 3.11 → bug
- Coverage drop pasa desapercibido por días
CI (Continuous Integration) automatiza esto. Cada git push y cada Pull Request ejecuta el test suite automáticamente. Si los tests fallan, el merge se bloquea (cap 07). El sistema reemplaza la disciplina manual.
GitHub Actions es la opción default para repos en GitHub. Free tier generoso (2,000 minutos/mes para projects privados, ilimitado para público). Configuración en YAML.
En esta cápsula vas a aprender la estructura de un workflow (triggers, jobs, steps), configurar services (Postgres como container) para integration tests, caching de dependencies para velocidad, y test matrix (múltiples Python versions). Al cerrar, vas a tener un workflow funcional que corre en cada push.
Anatomía de un workflow
.github/workflows/test.yml ← path obligatorio
(también acepta .yaml)
ESTRUCTURA:
name: <human readable>
on: <triggers (push, pull_request, schedule, etc)>
jobs:
<job_name>:
runs-on: <runner OS>
services: <containers paralelos>
steps:
- <action>
- <run command>
Workflow mínimo
# .github/workflows/test.yml
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[test]"
- run: pytest
Esto ya funciona. En cada push y PR:
- GitHub spawnea un runner Ubuntu
- Clona el repo (
actions/checkout) - Instala Python 3.12 (
actions/setup-python) - Instala el proyecto y deps de test
- Corre
pytest
Si algún step falla, el job falla y CI marca el commit/PR como red.
Triggers comunes
on:
push:
branches: [main, develop] # solo push a estas branches
pull_request:
branches: [main] # solo PRs a main
schedule:
- cron: '0 6 * * *' # daily at 06:00 UTC
workflow_dispatch: # trigger manual via UI
Configuración estándar
on:
# Push a cualquier branch
push:
# Pull requests targeting main
pull_request:
branches: [main]
# Permitir trigger manual
workflow_dispatch:
Esta combinación es la default recomendada:
- Cada push valida la branch
- Cada PR valida antes del merge
- Manual override disponible si necesitas re-run
Workflow completo recomendado
# .github/workflows/test.yml
name: tests
on:
push:
pull_request:
branches: [main]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: |
pip install --upgrade pip
pip install -e ".[test]"
- name: Run linting
run: |
pip install ruff
ruff check src tests
- name: Run tests with coverage
env:
TEST_DATABASE_URL: postgresql://postgres:test@localhost:5432/test_db
run: pytest --cov=app --cov-branch --cov-report=xml --cov-report=term
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
fail_ci_if_error: false
Sección por sección:
runs-on
runs-on: ubuntu-latest
GitHub-hosted runner. Otras opciones:
ubuntu-22.04,ubuntu-20.04(versiones específicas)macos-latest(más caro, más lento)windows-latest(también disponible)
Para Python: ubuntu-latest es default. Más rápido que macOS, Windows.
services: Postgres como container
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
...
Cómo funciona: GitHub spawnea un container Postgres en paralelo al runner. El runner puede conectarse a localhost:5432.
Health checks evitan que el step empiece antes de que Postgres esté ready. Sin esto, el primer test falla con "connection refused".
Otras services comunes:
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
rabbitmq:
image: rabbitmq:3
ports:
- 5672:5672
actions/checkout@v4: clonar el repo
- uses: actions/checkout@v4
Clona el repo al runner. Siempre primer step. Sin esto, no hay código para testear.
# Variantes
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history (default es shallow clone)
token: ${{ secrets.GITHUB_TOKEN }} # para repos privados
actions/setup-python@v5: instalar Python
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: pyproject.toml
python-version— version exacta o "3.12.x" para latest patchcache: "pip"— habilita caching automático de pipcache-dependency-path— file que invalida el cache cuando cambia
Sin caching, cada run reinstala desde 0 (slow). Con caching, instala en 5-10 segundos.
pip install
- run: |
pip install --upgrade pip
pip install -e ".[test]"
pip install -e ".[test]" — instala el proyecto en modo editable + extras de test. Equivalente a:
pip install -e .
pip install pytest pytest-cov ... # de la sección [project.optional-dependencies] test
Linting (opcional pero recomendado)
- name: Run linting
run: |
pip install ruff
ruff check src tests
ruff es el linter Python moderno (rápido, comprehensive). Recomendación: lint en CI antes de tests. Catches typos rápido.
Tests
- name: Run tests with coverage
env:
TEST_DATABASE_URL: postgresql://postgres:test@localhost:5432/test_db
run: pytest --cov=app --cov-branch --cov-report=xml --cov-report=term
env— variables de entorno para este step.TEST_DATABASE_URLapunta al Postgres service.--cov-report=xml— para upload a Codecov (próximo step)--cov-report=term— output en logs del CI
Upload coverage (opcional)
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
Sube coverage.xml a Codecov. Codecov es free para projects open source. Tracking de trends, comments en PRs.
Caching: el factor velocity
Sin caching, cada run instala todo desde cero:
Without caching:
- Setup Python: 5s
- pip install -e ".[test]": 60s ← SLOW
- Tests: 10s
- Total: ~75s
With caching (pip cached):
- Setup Python: 5s (con cache hit)
- pip install -e ".[test]": 8s ← FAST
- Tests: 10s
- Total: ~23s
3x más rápido. En suite con muchos tests + 100 PRs/mes, ahorras horas.
Cache hit invalidation
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: pyproject.toml
Cache se invalida cuando pyproject.toml cambia. Si solo cambias código, cache hit. Si cambias dependencies, cache miss → reinstall.
Cache custom (avanzado)
Para casos donde necesitas más control:
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
actions/cache@v4 es la action manual de caching. Generalmente actions/setup-python@v5 con cache: pip es suficiente.
Test matrix: múltiples Python versions
Si tu proyecto soporta múltiples Python versions, testá en todas en paralelo:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[test]"
- run: pytest
3 jobs en paralelo: Python 3.11, 3.12, 3.13. Si solo 3.13 falla, sabes exactamente qué versión rompe.
Matrix multidimensional
strategy:
matrix:
python-version: ["3.11", "3.12"]
os: [ubuntu-latest, macos-latest]
# 2x2 = 4 jobs en paralelo
Cuándo usar:
- Library open source que soporta múltiples versions
- Cross-platform tools
Cuándo NO:
- Backend service de producción (decidir UNA version, optimizar para esa)
Matrix con fail-fast off
strategy:
fail-fast: false # ← no cancelar otros jobs si uno falla
matrix:
python-version: ["3.11", "3.12", "3.13"]
Sin fail-fast: false, si el job de 3.11 falla, los de 3.12 y 3.13 se cancelan. Para debugging es preferible verlos todos.
Variables de entorno y secrets
Variables públicas
env:
APP_ENV: test
LOG_LEVEL: ERROR
jobs:
test:
env:
OVERRIDE_THIS: value # job-level
steps:
- run: echo $APP_ENV
env:
STEP_ONLY: value # step-level
Scoping: workflow > job > step. El más específico gana.
Secrets (passwords, API keys)
Configuras en GitHub: repo → Settings → Secrets and variables → Actions.
- run: pytest
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
API_KEY: ${{ secrets.STRIPE_TEST_KEY }}
Secrets están encriptados. Aparecen en logs como *** (masked).
Best practices:
- Secret names en CAPS_SNAKE_CASE
- Una key por servicio (ej.
STRIPE_TEST_KEY, noKEYS) - Rotar secrets periódicamente
Logs y artifacts
Ver logs
GitHub UI: Actions → workflow run → job → step. Click en step, ver output.
Patterns útiles:
- name: Run tests verbose
run: pytest -v --tb=short # más output útil
- name: Show failures detail
if: failure() # solo si previous step failed
run: cat .pytest_cache/lastfailed
if: failure() ejecuta el step solo si fallaron. Útil para debug info on-demand.
Upload artifacts
Cuando los tests fallan, quieres más info:
- name: Upload test results
uses: actions/upload-artifact@v4
if: always() # siempre, incluso si tests fallan
with:
name: test-results
path: |
htmlcov/
.pytest_cache/
coverage.xml
actions/upload-artifact permite descargar archivos del run via UI. Útil para:
- Coverage HTML reports
- Test logs detallados
- Screenshots (si hay UI tests)
if: always() — upload incluso si tests fallaron (es cuando más necesitas los artifacts).
Workflow de tests separados por marker
Para projects grandes, separar tests por marker en jobs paralelos:
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[test]"
- run: pytest -m "unit" --cov=app
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports: [5432:5432]
options: --health-cmd pg_isready
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[test]"
- env:
TEST_DATABASE_URL: postgresql://postgres:test@localhost:5432/test_db
run: pytest -m "integration"
e2e:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
ports: [5432:5432]
env:
POSTGRES_PASSWORD: test
options: --health-cmd pg_isready
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- run: pip install -e ".[test]"
- run: pytest -m "e2e"
Beneficios:
- Paralelo: los 3 jobs corren simultáneamente
- Failure isolation: si solo e2e falla, sabes exactamente qué tipo
- Resource optimization: unit no necesita Postgres → service no allocado
Patterns avanzados
Pattern: skip en docs-only changes
on:
pull_request:
paths-ignore:
- 'docs/**'
- '*.md'
- '.github/ISSUE_TEMPLATE/**'
PRs que solo cambian docs no triggern CI. Ahorra minutos en runs innecesarios.
Pattern: concurrency control
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
Si pusheas varios commits seguidos, cancela runs anteriores (solo el último importa).
Pattern: matrix con exclude
strategy:
matrix:
python-version: ["3.11", "3.12"]
os: [ubuntu-latest, macos-latest, windows-latest]
exclude:
- python-version: "3.11"
os: windows-latest # skip esta combinación
Pattern: dependent jobs
jobs:
lint:
runs-on: ubuntu-latest
steps: ...
test:
needs: lint # ← solo corre si lint pasó
runs-on: ubuntu-latest
steps: ...
deploy:
needs: [lint, test] # múltiples deps
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps: ...
needs define dependencias entre jobs. Pipeline lineal: lint → test → deploy.
Pattern: timeout para evitar runs colgados
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15 # ← falla después de 15 min
Defaults a 6 horas. Recomendación: setear timeout razonable.
Triggering selectivo
Trigger solo en PRs
on:
pull_request:
branches: [main]
Trigger en push a main + PRs
on:
push:
branches: [main]
pull_request:
branches: [main]
Trigger ignorando paths
on:
pull_request:
paths-ignore:
- '**.md'
- 'docs/**'
Trigger en tags
on:
push:
tags:
- 'v*' # ej. v1.0.0
Útil para release workflow separado.
Trampas y errores comunes
Trampa 1: hardcoding secrets en YAML
# ❌ Secrets en plaintext
- run: pytest
env:
STRIPE_KEY: sk_test_abc123
Anti-pattern. Secrets en YAML están public si el repo es público. Usar ${{ secrets.X }}:
- run: pytest
env:
STRIPE_KEY: ${{ secrets.STRIPE_TEST_KEY }}
Trampa 2: services sin health check
# ❌ Postgres sin health check
services:
postgres:
image: postgres:16
ports: [5432:5432]
env:
POSTGRES_PASSWORD: test
Tests se ejecutan antes que Postgres esté ready. Primer test falla con "connection refused" inconsistente.
Fix: options con health check.
Trampa 3: pip sin caching
# ❌ Sin caching, cada run reinstala
- uses: actions/setup-python@v5
with:
python-version: "3.12"
Fix: cache: "pip" + cache-dependency-path.
Trampa 4: matrix sin fail-fast: false durante debugging
strategy:
matrix:
python-version: ["3.11", "3.12"]
# default fail-fast = true
Cuando 3.11 falla, 3.12 se cancela. Si quieres ver si solo 3.11 está roto o ambos, fail-fast: false.
Trampa 5: artifacts no uploaded en failure
- name: Upload coverage
uses: actions/upload-artifact@v4
# sin if: always() — solo upload si previous steps passed
Cuando tests fallan, más necesitas los artifacts (coverage, logs). Sin if: always(), no se suben.
Trampa 6: workflow file en path incorrecto
.github/test.yml # ❌ no funciona
.github/workflow/test.yml # ❌ typo: "workflow" singular
.github/workflows/test.yml # ✅ correcto
GitHub Actions solo lee .github/workflows/*.yml.
Trampa 7: pytest-asyncio sin event loop config en CI
Si tu app es async, en CI puede dar errores raros con event loops cuando los tests son paralelos.
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
Fix: configuración explícita.
Ejercicio: workflow para tu proyecto
- Crea
.github/workflows/test.ymlcon el workflow recomendado - Configura el trigger (push + PR)
- Si usas Postgres, agrega service con health check
- Configura caching
- Push a una branch nueva, abre PR, verifica que el workflow corre
- Bonus: agrega test matrix con 2 Python versions
Workflow completo de ejemplo
# .github/workflows/test.yml
name: tests
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13"]
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
cache-dependency-path: pyproject.toml
- name: Install
run: |
pip install --upgrade pip
pip install -e ".[test]"
- name: Lint with ruff
run: |
pip install ruff
ruff check src tests
- name: Run tests
env:
TEST_DATABASE_URL: postgresql://postgres:test@localhost:5432/test_db
run: pytest --cov=app --cov-branch --cov-report=xml --cov-report=term
- name: Upload coverage
if: matrix.python-version == '3.12' # solo upload una vez
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
fail_ci_if_error: false
- name: Upload artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: pytest-results-${{ matrix.python-version }}
path: |
htmlcov/
.pytest_cache/
Resumen y siguiente paso
Lo que aprendiste en esta cápsula:
- Workflow YAML en
.github/workflows/— triggers, jobs, steps - Triggers: push, pull_request, schedule, workflow_dispatch
- Services: Postgres como container con health checks
- Caching:
actions/setup-python@v5concache: pippara velocity - Test matrix: múltiples Python versions en paralelo
- Secrets:
${{ secrets.X }}para credentials seguros - Artifacts: upload coverage HTML / logs para debugging
- Patterns: concurrency, dependent jobs, paths-ignore
Checkpoint antes de avanzar
Antes de continuar a la siguiente cápsula, deberías poder:
- ✅ Escribir un
.github/workflows/test.ymlbásico para tu proyecto - ✅ Configurar service Postgres con health check
- ✅ Habilitar pip caching para velocity
- ✅ Setear test matrix si tu proyecto soporta múltiples Python versions
- ✅ Manejar secrets vía
secrets.X
Puente a la próxima cápsula
Tienes CI funcionando. Pero hay piezas faltantes para que sea production-grade:
- Required checks: PR no se puede mergear si CI rojo
- Coverage badge: README mostrando coverage actual
- Coverage comments: bot comenta en PRs cuando coverage baja
- Codecov integration: tracking de trends a lo largo del tiempo
La cápsula 07 cubre eso. Después de cap 07, tu CI no solo corre — enforce calidad en cada PR.
Recursos
- GitHub Actions docs — referencia oficial
- actions/checkout — referencia
- actions/setup-python — referencia
- actions/cache — caching avanzado
- Marketplace de Actions — actions third-party
- Codecov Action — para coverage upload
- Workflow syntax — referencia YAML completa