Módulo 2: Testing Automatizado en CI

8. Proyecto — Automated Test Pipeline

Descripción del proyecto

Es hora de integrar todo lo que aprendiste en este módulo en un pipeline de testing profesional. Vas a construir un workflow que corre tests automáticamente en 3 versiones de Python, usa dependency caching para ser rápido, genera JUnit XML reports y coverage reports, y tiene timeouts configurados para proteger contra tests colgados.

El resultado no es un ejercicio académico — es un pipeline de testing real que vivirá en tu repositorio y protegerá tu código en cada push. Este pipeline es la base sobre la cual agregarás AI-specific checks en el Módulo 3.


Objetivo del proyecto

Construir un pipeline de testing automatizado con GitHub Actions que incluya matrix testing, dependency caching, test reports, coverage, y timeouts.


Prerequisitos

  • ✅ Completaste las cápsulas 01-07 de este módulo
  • ✅ Tienes el proyecto del Módulo 1 con tests funcionando
  • ✅ Tienes acceso a la pestaña Actions de tu repo en GitHub

Recap: Lo que construiste en el Módulo 1

Antes de arrancar, asegúrate de tener claro qué tienes hasta ahora. En el proyecto del Módulo 1 construiste:

  • Un repositorio con estructura src/ y tests/
  • src/main.py con funciones greet y classify_sentiment que simulan un servicio AI básico
  • src/utils.py con funciones de utilidad: estimate_tokens, calculate_cost, y validate_prompt
  • Tests unitarios básicos en tests/test_main.py y tests/test_utils.py
  • Un workflow ci.yml simple que corría pytest en una sola versión de Python
  • Un requirements.txt con las dependencias del proyecto

Ese pipeline funcionaba, pero era frágil. No probaba en múltiples versiones de Python, no generaba reports, no tenía caching, y no separaba unit tests de integration tests. En este proyecto vas a tomar esa base y convertirla en un pipeline de testing profesional.


Especificaciones técnicas

Estructura del proyecto

Usas el mismo proyecto del Módulo 1, con estas adiciones:

mi-proyecto-ai/
├── .github/
│   └── workflows/
│       └── ci.yml              ← Actualizado con todo lo nuevo
├── src/
│   ├── __init__.py
│   ├── main.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── unit/                   ← NEW: tests organizados
│   │   ├── __init__.py
│   │   ├── test_main.py
│   │   └── test_utils.py
│   └── integration/            ← NEW: tests que simularían API calls
│       ├── __init__.py
│       └── test_ai_service.py
├── requirements.txt            ← Actualizado
├── requirements-dev.txt        ← NEW: dependencias de desarrollo
├── pyproject.toml              ← Actualizado
└── README.md

Código fuente (del Módulo 1)

Si por alguna razón no tienes estos archivos, aquí está el código que los tests esperan. Si ya los tienes del Módulo 1, verifica que las firmas de las funciones coincidan.

src/main.py

"""Módulo principal del servicio AI."""

POSITIVE_WORDS = {"great", "amazing", "excellent", "wonderful", "love", "fantastic", "awesome"}
NEGATIVE_WORDS = {"terrible", "awful", "bad", "worst", "hate", "horrible", "disappointed"}


def greet(name: str) -> str:
    """Genera un saludo para el usuario del servicio."""
    cleaned = name.strip()
    if not cleaned:
        return "Hello, anonymous! Welcome to the AI service."
    return f"Hello, {cleaned}! Welcome to the AI service."


def classify_sentiment(text: str) -> dict:
    """Clasifica el sentimiento de un texto usando keyword matching.

    En un servicio real esto llamaría a un LLM. Aquí usamos una
    implementación local para que los tests no dependan de APIs externas.
    """
    if not text or not text.strip():
        raise ValueError("Text cannot be empty")

    words = set(text.lower().split())
    pos_count = len(words & POSITIVE_WORDS)
    neg_count = len(words & NEGATIVE_WORDS)

    if pos_count > neg_count:
        sentiment = "positive"
        confidence = min(0.5 + pos_count * 0.15, 1.0)
    elif neg_count > pos_count:
        sentiment = "negative"
        confidence = min(0.5 + neg_count * 0.15, 1.0)
    else:
        sentiment = "neutral"
        confidence = 0.5

    return {
        "text": text,
        "sentiment": sentiment,
        "confidence": confidence,
    }

src/utils.py

"""Funciones de utilidad para el servicio AI."""

MODEL_PRICING = {
    "gpt-4o-mini": {"input": 0.150 / 1_000_000, "output": 0.600 / 1_000_000},
    "gpt-4o": {"input": 2.50 / 1_000_000, "output": 10.00 / 1_000_000},
}


def estimate_tokens(text: str) -> int:
    """Estima la cantidad de tokens en un texto.

    Usa la heurística de ~4 caracteres por token. No es exacto,
    pero es suficiente para estimaciones de costo.
    """
    if not text:
        return 0
    return max(1, len(text) // 4)


def calculate_cost(
    prompt_tokens: int, completion_tokens: int, model: str
) -> dict:
    """Calcula el costo estimado de una llamada a un modelo."""
    if model not in MODEL_PRICING:
        raise ValueError(f"Unknown model: {model}")

    pricing = MODEL_PRICING[model]
    input_cost = prompt_tokens * pricing["input"]
    output_cost = completion_tokens * pricing["output"]

    return {
        "model": model,
        "prompt_tokens": prompt_tokens,
        "completion_tokens": completion_tokens,
        "input_cost": round(input_cost, 8),
        "output_cost": round(output_cost, 8),
        "total_cost": round(input_cost + output_cost, 8),
    }


def validate_prompt(prompt: str, max_tokens: int = 8000) -> dict:
    """Valida un prompt antes de enviarlo a un modelo."""
    issues = []

    if not prompt or not prompt.strip():
        issues.append("Prompt is empty")
    elif len(prompt.strip()) < 10:
        issues.append("Prompt is too short (minimum 10 characters)")

    estimated = estimate_tokens(prompt)
    if estimated > max_tokens:
        issues.append(f"Prompt is too long: ~{estimated} tokens (max {max_tokens})")

    return {
        "valid": len(issues) == 0,
        "issues": issues,
        "estimated_tokens": estimated,
    }

Archivos nuevos y actualizados

requirements-dev.txt

-r requirements.txt
pytest>=8.0
pytest-cov>=5.0
pytest-timeout>=2.3
ruff>=0.3.0

tests/unit/__init__.py

tests/unit/test_main.py

"""Unit tests para el módulo principal."""
import pytest
from src.main import greet, classify_sentiment


class TestGreet:
    def test_greet_with_name(self):
        assert greet("Alice") == "Hello, Alice! Welcome to the AI service."

    def test_greet_empty_string(self):
        result = greet("")
        assert "anonymous" in result

    def test_greet_whitespace_only(self):
        result = greet("   ")
        assert "anonymous" in result

    def test_greet_strips_whitespace(self):
        result = greet("  Bob  ")
        assert "Bob" in result
        assert "  Bob  " not in result

    def test_greet_special_characters(self):
        result = greet("María José")
        assert "María José" in result


class TestClassifySentiment:
    def test_positive_sentiment(self):
        result = classify_sentiment("This is great and amazing!")
        assert result["sentiment"] == "positive"
        assert result["confidence"] > 0.5

    def test_negative_sentiment(self):
        result = classify_sentiment("This is terrible and awful!")
        assert result["sentiment"] == "negative"
        assert result["confidence"] > 0.5

    def test_neutral_sentiment(self):
        result = classify_sentiment("The sky is blue.")
        assert result["sentiment"] == "neutral"
        assert result["confidence"] == 0.5

    def test_empty_text_raises(self):
        with pytest.raises(ValueError, match="Text cannot be empty"):
            classify_sentiment("")

    def test_result_structure(self):
        result = classify_sentiment("Hello world")
        assert "text" in result
        assert "sentiment" in result
        assert "confidence" in result

    def test_confidence_range(self):
        result = classify_sentiment("great amazing excellent love")
        assert 0 <= result["confidence"] <= 1.0

    def test_result_includes_original_text(self):
        text = "Test input text"
        result = classify_sentiment(text)
        assert result["text"] == text

tests/unit/test_utils.py

"""Unit tests para funciones de utilidad."""
import pytest
from src.utils import estimate_tokens, calculate_cost, validate_prompt


class TestEstimateTokens:
    def test_empty_string(self):
        assert estimate_tokens("") == 0

    def test_short_text(self):
        result = estimate_tokens("Hello")
        assert result >= 1

    def test_longer_text(self):
        text = "This is a longer piece of text for token estimation."
        result = estimate_tokens(text)
        assert 10 <= result <= 20

    def test_returns_integer(self):
        assert isinstance(estimate_tokens("test"), int)

    def test_minimum_one_token(self):
        assert estimate_tokens("Hi") >= 1


class TestCalculateCost:
    def test_gpt4o_mini_cost(self):
        result = calculate_cost(1000, 500, "gpt-4o-mini")
        assert result["model"] == "gpt-4o-mini"
        assert result["total_cost"] > 0

    def test_gpt4o_cost(self):
        result = calculate_cost(1000, 500, "gpt-4o")
        assert result["total_cost"] > calculate_cost(1000, 500, "gpt-4o-mini")["total_cost"]

    def test_unknown_model_raises(self):
        with pytest.raises(ValueError, match="Unknown model"):
            calculate_cost(100, 100, "gpt-5-imaginary")

    def test_zero_tokens(self):
        result = calculate_cost(0, 0, "gpt-4o-mini")
        assert result["total_cost"] == 0

    def test_cost_structure(self):
        result = calculate_cost(1000, 500, "gpt-4o")
        required_keys = [
            "model", "prompt_tokens", "completion_tokens",
            "input_cost", "output_cost", "total_cost",
        ]
        for key in required_keys:
            assert key in result

    def test_cost_proportional_to_tokens(self):
        cost_1k = calculate_cost(1000, 500, "gpt-4o-mini")["total_cost"]
        cost_2k = calculate_cost(2000, 1000, "gpt-4o-mini")["total_cost"]
        assert abs(cost_2k - cost_1k * 2) < 0.0001


class TestValidatePrompt:
    def test_valid_prompt(self):
        result = validate_prompt("Summarize the following text in 3 sentences.")
        assert result["valid"] is True
        assert len(result["issues"]) == 0

    def test_empty_prompt(self):
        result = validate_prompt("")
        assert result["valid"] is False

    def test_short_prompt(self):
        result = validate_prompt("Hi")
        assert result["valid"] is False

    def test_token_estimation_included(self):
        result = validate_prompt("A normal prompt for testing purposes.")
        assert "estimated_tokens" in result
        assert result["estimated_tokens"] > 0

    def test_very_long_prompt(self):
        long_prompt = "word " * 20000
        result = validate_prompt(long_prompt, max_tokens=4000)
        assert result["valid"] is False
        assert any("too long" in issue.lower() for issue in result["issues"])

tests/integration/__init__.py

tests/integration/test_ai_service.py

"""
Integration tests que simulan interacciones con servicios AI.
En un proyecto real, estos tests llamarían a APIs externas.
Aquí simulamos el patrón para demostrar timeouts y separación.
"""
import time
import pytest
from src.main import classify_sentiment
from src.utils import estimate_tokens, calculate_cost


@pytest.mark.integration
@pytest.mark.timeout(10)
class TestAIServiceIntegration:
    """Tests que simulan llamadas a servicios AI."""

    def test_sentiment_pipeline(self):
        """Simula el pipeline completo: tokenize → classify → cost."""
        text = "This product is absolutely amazing and wonderful!"
        tokens = estimate_tokens(text)
        sentiment = classify_sentiment(text)
        cost = calculate_cost(tokens, tokens // 2, "gpt-4o-mini")

        assert sentiment["sentiment"] == "positive"
        assert cost["total_cost"] > 0
        assert cost["total_cost"] < 0.01

    def test_batch_processing(self):
        """Simula procesamiento batch de múltiples textos."""
        texts = [
            "Great product, love it!",
            "Terrible experience, worst ever.",
            "The weather is nice today.",
            "Amazing service, highly recommend!",
            "Bad quality, very disappointed.",
        ]
        results = [classify_sentiment(text) for text in texts]

        positive = sum(1 for r in results if r["sentiment"] == "positive")
        negative = sum(1 for r in results if r["sentiment"] == "negative")

        assert positive >= 2
        assert negative >= 2

    def test_cost_estimation_batch(self):
        """Simula estimación de costos para un batch de requests."""
        texts = ["Sample text for estimation."] * 100
        total_cost = 0

        for text in texts:
            tokens = estimate_tokens(text)
            cost = calculate_cost(tokens, tokens, "gpt-4o-mini")
            total_cost += cost["total_cost"]

        assert total_cost > 0
        assert total_cost < 1.0

    @pytest.mark.timeout(5)
    def test_with_simulated_latency(self):
        """Simula un test con latencia de API (timeout test)."""
        time.sleep(0.5)
        result = classify_sentiment("Quick test with simulated latency")
        assert result["sentiment"] in ["positive", "negative", "neutral"]

pyproject.toml (actualizado)

[tool.ruff]
target-version = "py310"
line-length = 88

[tool.ruff.lint]
select = ["E", "F", "I", "W"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
markers = [
    "integration: tests that simulate external API calls",
    "slow: tests that take more than 5 seconds",
]
timeout = 30

[tool.coverage.run]
source = ["src"]
omit = ["tests/*"]

[tool.coverage.report]
show_missing = true
fail_under = 80

El Workflow: ci.yml completo

Este es el workflow que integra todo lo aprendido en el módulo:

name: CI Pipeline

on:
  push:
    branches: [main]
    paths-ignore:
      - "*.md"
      - "docs/**"
  pull_request:
    branches: [main]
  workflow_dispatch:

env:
  MIN_COVERAGE: 80

jobs:
  # ──────────── JOB 1: LINT ────────────
  lint:
    name: Code Quality
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"

      - name: Install linting tools
        run: pip install ruff

      - name: Run linting
        run: ruff check src/ tests/

      - name: Check formatting
        run: ruff format --check src/ tests/

  # ──────────── JOB 2: UNIT TESTS (MATRIX) ────────────
  unit-tests:
    name: Unit Tests (Python ${{ matrix.python-version }})
    runs-on: ubuntu-latest
    timeout-minutes: 10
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.10", "3.11", "3.12"]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: "pip"

      - name: Install dependencies
        run: pip install -r requirements-dev.txt

      - name: Run unit tests with coverage
        run: |
          pytest tests/unit/ \
            -v \
            --tb=short \
            --timeout=30 \
            --junitxml=reports/junit-${{ matrix.python-version }}.xml \
            --cov=src \
            --cov-report=term-missing \
            --cov-report=xml:reports/coverage-${{ matrix.python-version }}.xml

      - name: Upload test reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-reports-py${{ matrix.python-version }}
          path: reports/
          retention-days: 7

  # ──────────── JOB 3: INTEGRATION TESTS ────────────
  integration-tests:
    name: Integration Tests
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"

      - name: Install dependencies
        run: pip install -r requirements-dev.txt

      - name: Run integration tests
        run: |
          pytest tests/integration/ \
            -v \
            --tb=short \
            --timeout=60 \
            -m integration \
            --junitxml=reports/junit-integration.xml

      - name: Upload integration reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: integration-reports
          path: reports/
          retention-days: 7

  # ──────────── JOB 4: PIPELINE STATUS ────────────
  pipeline-status:
    name: Pipeline Status
    needs: [lint, unit-tests, integration-tests]
    runs-on: ubuntu-latest
    timeout-minutes: 5
    if: always()
    steps:
      - name: Check all jobs
        run: |
          echo "=== Pipeline Results ==="
          echo "Lint: ${{ needs.lint.result }}"
          echo "Unit Tests: ${{ needs.unit-tests.result }}"
          echo "Integration Tests: ${{ needs.integration-tests.result }}"
          echo ""

          if [ "${{ needs.lint.result }}" != "success" ] || \
             [ "${{ needs.unit-tests.result }}" != "success" ] || \
             [ "${{ needs.integration-tests.result }}" != "success" ]; then
            echo "❌ Pipeline FAILED"
            exit 1
          fi

          echo "✅ All checks passed!"

Arquitectura del pipeline

Push/PR
  │
  ├──► lint ────────────────────────────────────────┐
  │                                                  │
  ├──► unit-tests (Python 3.10) ────────────────────┤
  ├──► unit-tests (Python 3.11) ────────────────────┤  (todos en paralelo)
  ├──► unit-tests (Python 3.12) ────────────────────┤
  │                                                  │
  ├──► integration-tests ───────────────────────────┤
  │                                                  │
  └──► pipeline-status ◄────────────────────────────┘  (espera a todos)
       needs: [lint, unit-tests, integration-tests]

6 jobs en paralelo: lint + 3 matrix (unit tests) + integration tests, seguidos de un status check final.


Paso a paso: Implementación

Paso 1: Actualiza la estructura del proyecto

# Crea los directorios de tests organizados
mkdir -p tests/unit tests/integration

# Mueve los tests existentes
mv tests/test_main.py tests/unit/test_main.py
mv tests/test_utils.py tests/unit/test_utils.py

# Crea __init__.py
touch tests/unit/__init__.py
touch tests/integration/__init__.py

Paso 2: Crea los archivos nuevos

Crea requirements-dev.txt, actualiza pyproject.toml, y crea tests/integration/test_ai_service.py con el contenido mostrado arriba.

Paso 3: Verifica localmente

# Instala dependencias de desarrollo
pip install -r requirements-dev.txt

# Corre unit tests
pytest tests/unit/ -v --cov=src --cov-report=term-missing

Output esperado:

tests/unit/test_main.py::TestGreet::test_greet_with_name PASSED
tests/unit/test_main.py::TestGreet::test_greet_empty_string PASSED
... (todos los tests)
tests/unit/test_utils.py::TestValidatePrompt::test_very_long_prompt PASSED

---------- coverage: platform linux, python 3.12.0 ----------
Name             Stmts   Miss  Cover   Missing
------------------------------------------------
src/__init__.py      0      0   100%
src/main.py         25      2    92%   42-43
src/utils.py        30      0   100%
------------------------------------------------
TOTAL               55      2    96%

========================= 27 passed in 0.15s =========================
# Corre integration tests
pytest tests/integration/ -v -m integration --timeout=30

Output esperado:

tests/integration/test_ai_service.py::TestAIServiceIntegration::test_sentiment_pipeline PASSED
tests/integration/test_ai_service.py::TestAIServiceIntegration::test_batch_processing PASSED
tests/integration/test_ai_service.py::TestAIServiceIntegration::test_cost_estimation_batch PASSED
tests/integration/test_ai_service.py::TestAIServiceIntegration::test_with_simulated_latency PASSED

========================= 4 passed in 0.62s =========================
# Corre lint
ruff check src/ tests/
ruff format --check src/ tests/

Paso 4: Actualiza el workflow y push

Reemplaza .github/workflows/ci.yml con el workflow completo mostrado arriba.

git add .
git commit -m "Upgrade CI with matrix testing, caching, coverage, and timeouts"
git push origin main

Paso 5: Verifica en GitHub

  1. Ve a Actions → verás 6 jobs ejecutándose
  2. lint, 3 unit-tests (matrix), e integration-tests corren en paralelo
  3. pipeline-status espera a todos
  4. Descarga los artifacts: reports con JUnit XML y coverage

Checklist de completitud

Workflow

  • Matrix testing con Python 3.10, 3.11, 3.12
  • fail-fast: false en la matrix
  • Dependency caching con cache: "pip"
  • Timeouts en todos los jobs
  • JUnit XML reports generados
  • Coverage reports generados
  • Artifacts uploaded con actions/upload-artifact
  • Unit tests y integration tests en jobs separados
  • Job final que verifica todos los resultados

Tests

  • Unit tests organizados en tests/unit/
  • Integration tests en tests/integration/ con @pytest.mark.integration
  • Timeouts configurados: @pytest.mark.timeout() en integration tests
  • Coverage > 80% en unit tests
  • Todos los tests pasan localmente en Python 3.10, 3.11, y 3.12

Configuración

  • pyproject.toml con markers, timeout default, y coverage config
  • requirements-dev.txt con pytest, pytest-cov, pytest-timeout, ruff
  • .github/workflows/ci.yml actualizado con el pipeline completo

Troubleshooting del proyecto

"Matrix job falla en Python 3.10 pero pasa en 3.12"

Revisa si tu código usa features de Python 3.11+ (como match statements o ExceptionGroup). El target version en pyproject.toml debe ser py310 si quieres compatibilidad.

"Coverage report muestra < 80%"

Si --cov-fail-under=80 falla, revisa qué líneas no están cubiertas con --cov-report=term-missing. Agrega tests para las líneas faltantes o ajusta el threshold.

"Artifact upload falla"

Verifica que el directorio reports/ existe. Agrégalo antes del step de pytest:

- name: Create reports directory
  run: mkdir -p reports

"Integration tests tardan mucho"

El test_with_simulated_latency tiene un time.sleep(0.5). En un proyecto real con API calls, los tiempos pueden ser mayores. Verifica que el timeout-minutes: 15 del job es suficiente.

"Import path issues: ModuleNotFoundError: No module named 'src'"

Este es uno de los errores más comunes. pytest necesita saber dónde buscar el módulo src. La solución está en pyproject.toml:

[tool.pytest.ini_options]
pythonpath = ["."]

Sin esa línea, pytest no encuentra src.main ni src.utils. Si ya la tienes y sigue fallando, verifica que estás corriendo pytest desde la raíz del proyecto (donde está pyproject.toml), no desde dentro de tests/ u otro subdirectorio.

En el workflow de GitHub Actions esto no suele ser problema porque actions/checkout te deja en la raíz del repo. Pero si corres localmente, asegúrate de estar en el directorio correcto.

"Ruff formatting check fails en CI pero pasa local"

El job de lint corre ruff format --check que verifica pero no modifica archivos. Si falla, significa que hay archivos sin formatear. Antes de hacer push:

ruff format src/ tests/
ruff check src/ tests/ --fix

La causa más común es editar archivos sin tener configurado format-on-save en tu editor. Si usas VS Code, asegúrate de tener la extensión de Ruff instalada y activado editor.formatOnSave. Otra causa frecuente: formatear con black en lugar de ruff — ambos producen output ligeramente distinto.


Criterios de éxito

CriterioEstado
6 jobs corren en la UI de Actions
Matrix: 3 versiones de Python en paralelo
Caching: pip install < 10 segundos (después del primer run)
Reports: JUnit XML y coverage como artifacts descargables
Timeouts: configurados en jobs y tests
Coverage: > 80% en unit tests
Pipeline status: job final verifica todo

Qué construiste

Al completar este proyecto tienes:

  • ✅ Pipeline de testing profesional con matrix testing (3 versiones de Python)
  • ✅ Dependency caching que hace tu CI rápido
  • ✅ Test reports y coverage visibles como artifacts
  • ✅ Timeouts que protegen contra tests colgados
  • ✅ Separación clara entre unit tests e integration tests
  • ✅ La base para agregar AI-specific checks en el Módulo 3

Extensiones opcionales

Si terminaste el proyecto y quieres ir más allá, aquí hay tres extensiones que refuerzan lo aprendido y hacen tu pipeline más robusto.

1. Agregar mypy para type checking

Agrega un step de type checking estático al job de lint. Esto verifica que los type hints en tu código son consistentes:

      - name: Install type checker
        run: pip install mypy

      - name: Run type checking
        run: mypy src/ --ignore-missing-imports

Necesitarás agregar mypy>=1.8 a requirements-dev.txt. mypy puede ser ruidoso al principio — usa --ignore-missing-imports para evitar errores por dependencias externas sin stubs.

2. Agregar pre-commit hooks

pre-commit te permite correr checks antes de cada commit local, atrapando errores antes de que lleguen al CI. Crea .pre-commit-config.yaml en la raíz:

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.3.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer

Instálalo con pip install pre-commit && pre-commit install. A partir de ahora, cada git commit correrá estos checks automáticamente.

3. Badge de CI en el README

Agrega un badge al inicio de tu README.md que muestre el estado del pipeline:

![CI Pipeline](https://github.com/TU-USUARIO/TU-REPO/actions/workflows/ci.yml/badge.svg)

Reemplaza TU-USUARIO y TU-REPO con tus datos. El badge se actualiza automáticamente — verde si el pipeline pasa, rojo si falla. Es un indicador visual inmediato del estado de salud de tu proyecto.


Siguiente módulo

El Módulo 3 (AI-Specific CI Checks) agrega lo que hace especial a esta guía:

  • Prompt regression testing: Detectar que un cambio degradó la calidad de las respuestas
  • Cost estimation checks: Calcular cuánto costará un prompt antes de merge
  • Quality gates: Bloquear merge si los checks AI fallan

La transición es directa: "Tus tests ya corren automáticamente con matrix, caching, y reports → ahora agreguemos checks que ningún CI genérico tiene."


Recursos adicionales

  1. Building and testing Python - Guía oficial de Python en Actions
  2. Using a matrix for your jobs - Referencia de matrix strategy
  3. Caching dependencies - Guía de caching
  4. pytest-cov documentation - Plugin de coverage
  5. pytest-timeout documentation - Plugin de timeouts
  6. Upload artifact action - Guardar archivos de workflow runs