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:

  1. GitHub spawnea un runner Ubuntu
  2. Clona el repo (actions/checkout)
  3. Instala Python 3.12 (actions/setup-python)
  4. Instala el proyecto y deps de test
  5. 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 patch
  • cache: "pip" — habilita caching automático de pip
  • cache-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_URL apunta 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, no KEYS)
  • 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

  1. Crea .github/workflows/test.yml con el workflow recomendado
  2. Configura el trigger (push + PR)
  3. Si usas Postgres, agrega service con health check
  4. Configura caching
  5. Push a una branch nueva, abre PR, verifica que el workflow corre
  6. 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@v5 con cache: pip para 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.yml bá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

  1. GitHub Actions docs — referencia oficial
  2. actions/checkout — referencia
  3. actions/setup-python — referencia
  4. actions/cache — caching avanzado
  5. Marketplace de Actions — actions third-party
  6. Codecov Action — para coverage upload
  7. Workflow syntax — referencia YAML completa