--- title: "Pruebas automatizadas en CI/CD con pytest y GitHub Actions" description: "Configura pruebas unitarias y de integración con PostgreSQL en GitHub Actions, con informes JUnit, umbral de cobertura y checks obligatorios en la rama main." category: "CI/CD DevOps" language: es source: https://cubepath.com/es/docs/ci-cd-devops/pruebas-automatizadas-en-pipelines-cicd updated: 2026-09-25 --- # Pruebas automatizadas en CI/CD con pytest y GitHub Actions Las pruebas automatizadas son el filtro que decide si un cambio puede llegar a producción: si fallan, el pipeline se detiene y el código no se fusiona. En este tutorial montarás, para un proyecto Python, un pipeline de GitHub Actions con pruebas unitarias en varias versiones de Python, pruebas de integración contra un PostgreSQL real, informes JUnit descargables y un umbral mínimo de cobertura. Primero lo harás funcionar en local en Ubuntu 24.04, para que el pipeline ejecute exactamente los mismos comandos que tú. ## Requisitos previos - Ubuntu 24.04 LTS en tu estación de trabajo o en un VPS de CubePath, con un usuario no root con `sudo`. - Docker instalado, para levantar PostgreSQL en local. - Git y un repositorio en GitHub donde puedas crear workflows y configurar reglas de rama. - Conocimientos básicos de Python. El proyecto de ejemplo es mínimo a propósito: una función de cálculo (pruebas unitarias) y un módulo que guarda pedidos en PostgreSQL (pruebas de integración). La estructura se aplica igual a un proyecto real. ## Paso 1: Preparar el proyecto de ejemplo Instala Python con soporte para entornos virtuales. En Ubuntu 24.04 el paquete `python3-venv` no viene instalado por defecto: ```bash sudo apt update sudo apt install -y python3 python3-venv python3-pip git ``` Crea la estructura del proyecto: ```bash mkdir -p ~/tienda/{app,tests/unit,tests/integration} cd ~/tienda git init touch app/__init__.py ``` Crea la lógica de negocio en `app/precios.py`: ```bash nano app/precios.py ``` ```python def aplicar_iva(importe: float, tipo: float = 0.21) -> float: """Devuelve el importe con IVA, redondeado a dos decimales.""" if importe < 0: raise ValueError("El importe no puede ser negativo") return round(importe * (1 + tipo), 2) ``` Crea el acceso a datos en `app/db.py`. Lee la cadena de conexión de la variable `DATABASE_URL`, igual que lo hará en CI: ```bash nano app/db.py ``` ```python import os import psycopg def conectar() -> psycopg.Connection: return psycopg.connect(os.environ["DATABASE_URL"]) def guardar_pedido(conn: psycopg.Connection, cliente: str, total: float) -> int: with conn.cursor() as cur: cur.execute( "INSERT INTO pedidos (cliente, total) VALUES (%s, %s) RETURNING id", (cliente, total), ) pedido_id = cur.fetchone()[0] conn.commit() return pedido_id def contar_pedidos(conn: psycopg.Connection) -> int: with conn.cursor() as cur: cur.execute("SELECT count(*) FROM pedidos") return cur.fetchone()[0] ``` Declara las dependencias de desarrollo en `requirements-dev.txt`: ```bash nano requirements-dev.txt ``` ```text pytest>=8 pytest-cov>=5 psycopg[binary]>=3.2 ``` Crea el entorno virtual e instálalas: ```bash python3 -m venv .venv source .venv/bin/activate pip install -r requirements-dev.txt ``` ```bash pytest --version ``` ```output pytest 8.4.1 ``` ## Paso 2: Escribir las pruebas unitarias Las pruebas unitarias no tocan red, disco ni base de datos, así que son rápidas y deben ejecutarse en cada push. Crea `tests/unit/test_precios.py`: ```bash nano tests/unit/test_precios.py ``` ```python import pytest from app.precios import aplicar_iva def test_iva_general(): assert aplicar_iva(100) == 121.0 def test_iva_reducido(): assert aplicar_iva(100, tipo=0.10) == 110.0 def test_redondeo_a_dos_decimales(): assert aplicar_iva(9.99) == 12.09 def test_importe_negativo_lanza_error(): with pytest.raises(ValueError): aplicar_iva(-1) ``` Configura pytest en `pyproject.toml`. La marca `integration` permite separar las pruebas que necesitan PostgreSQL, y `pythonpath` hace que `import app` funcione sin instalar el paquete: ```bash nano pyproject.toml ``` ```toml [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] addopts = "-ra --strict-markers" markers = [ "integration: pruebas que necesitan una base de datos PostgreSQL", ] ``` `--strict-markers` convierte en error cualquier marca mal escrita, para que una prueba no deje de filtrarse por un error tipográfico. Ejecuta solo las unitarias: ```bash pytest -m "not integration" ``` ```output ============================= test session starts ============================== collected 4 items tests/unit/test_precios.py .... [100%] ============================== 4 passed in 0.02s =============================== ``` ## Paso 3: Escribir las pruebas de integración Las pruebas de integración comprueban que el código funciona contra un PostgreSQL real, no contra un simulacro. Una *fixture* crea la tabla y la vacía antes de cada prueba, de modo que cada prueba parte de un estado conocido. Crea `tests/integration/test_pedidos.py`: ```bash nano tests/integration/test_pedidos.py ``` ```python import os import pytest from app import db pytestmark = pytest.mark.integration @pytest.fixture def conn(): if "DATABASE_URL" not in os.environ: pytest.skip("DATABASE_URL no está definida") conexion = db.conectar() with conexion.cursor() as cur: cur.execute( "CREATE TABLE IF NOT EXISTS pedidos (" " id serial PRIMARY KEY," " cliente text NOT NULL," " total numeric(10, 2) NOT NULL)" ) cur.execute("TRUNCATE pedidos RESTART IDENTITY") conexion.commit() yield conexion conexion.close() def test_guardar_pedido_devuelve_id(conn): assert db.guardar_pedido(conn, "ana", 121.0) == 1 def test_contar_pedidos(conn): db.guardar_pedido(conn, "ana", 10) db.guardar_pedido(conn, "luis", 20) assert db.contar_pedidos(conn) == 2 ``` Levanta un PostgreSQL desechable con Docker, escuchando solo en `127.0.0.1`: ```bash docker run -d --name pg-test \ -e POSTGRES_PASSWORD=test \ -e POSTGRES_DB=tienda_test \ -p 127.0.0.1:5432:5432 \ postgres:16 ``` Espera a que acepte conexiones: ```bash docker exec pg-test pg_isready -U postgres ``` ```output /var/run/postgresql:5432 - accepting connections ``` Ejecuta todas las pruebas con la variable de conexión: ```bash export DATABASE_URL=postgresql://postgres:test@127.0.0.1:5432/tienda_test pytest ``` ```output collected 6 items tests/integration/test_pedidos.py .. [ 33%] tests/unit/test_precios.py .... [100%] ============================== 6 passed in 0.21s =============================== ``` Si ejecutas `pytest` sin `DATABASE_URL`, las pruebas de integración aparecen como `skipped` en lugar de fallar, lo que permite a cualquiera lanzar las unitarias sin montar la base de datos. ## Paso 4: Medir la cobertura y fijar un umbral La cobertura indica qué líneas del código ejecutan las pruebas. No garantiza que las pruebas sean buenas, pero una cobertura que baja de un PR a otro es una señal clara de código nuevo sin probar. `pytest-cov` calcula la cobertura y, con `--cov-fail-under`, hace fallar la ejecución si no llega al mínimo: ```bash pytest --cov=app --cov-report=term-missing --cov-fail-under=80 ``` ```output ---------- coverage: platform linux, python 3.12.3-final-0 ----------- Name Stmts Miss Cover Missing ----------------------------------------------- app/__init__.py 0 0 100% app/db.py 15 0 100% app/precios.py 5 0 100% ----------------------------------------------- TOTAL 20 0 100% Required test coverage of 80% reached. Total coverage: 100.00% ``` La columna `Missing` lista las líneas no cubiertas. Elige un umbral que tu proyecto ya cumple y súbelo poco a poco; un 80 % impuesto de golpe sobre un proyecto con un 40 % solo consigue que el equipo desactive la comprobación. Cuando termines, elimina el contenedor de pruebas: ```bash docker rm -f pg-test ``` ## Paso 5: Crear el workflow de GitHub Actions El workflow tiene dos jobs. `unit` ejecuta las pruebas unitarias en Python 3.12 y 3.13 en paralelo mediante una matriz. `integration` solo arranca si las unitarias pasan, levanta PostgreSQL como contenedor de servicio y ejecuta toda la suite con el umbral de cobertura. Ambos guardan sus informes como artefactos. Crea `.github/workflows/tests.yml`: ```bash mkdir -p .github/workflows nano .github/workflows/tests.yml ``` ```yaml name: tests on: push: branches: [main] pull_request: permissions: contents: read concurrency: group: tests-${{ github.ref }} cancel-in-progress: true jobs: unit: runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: pip cache-dependency-path: requirements-dev.txt - name: Instalar dependencias run: pip install -r requirements-dev.txt - name: Pruebas unitarias run: pytest -m "not integration" --junitxml=reports/unit.xml - name: Guardar informe if: always() uses: actions/upload-artifact@v4 with: name: unit-${{ matrix.python-version }} path: reports/ retention-days: 14 integration: needs: unit runs-on: ubuntu-24.04 services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: test POSTGRES_DB: tienda_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10 env: DATABASE_URL: postgresql://postgres:test@localhost:5432/tienda_test steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip cache-dependency-path: requirements-dev.txt - name: Instalar dependencias run: pip install -r requirements-dev.txt - name: Suite completa con cobertura run: >- pytest --cov=app --cov-report=term-missing --cov-report=xml:reports/coverage.xml --cov-fail-under=80 --junitxml=reports/all.xml - name: Guardar informes if: always() uses: actions/upload-artifact@v4 with: name: integration path: reports/ retention-days: 14 ``` Algunos detalles importantes: - `options` con `--health-cmd` hace que GitHub espere a que PostgreSQL acepte conexiones antes de ejecutar los pasos. Sin ello, las primeras pruebas fallarían de forma intermitente. - La contraseña `test` solo existe dentro del runner efímero, por eso puede ir en claro. Las credenciales reales nunca van en el YAML. - `if: always()` sube los informes también cuando las pruebas fallan, que es justo cuando más se necesitan. - `concurrency` cancela la ejecución anterior de la misma rama si haces otro push, para no gastar minutos en código obsoleto. - `fail-fast: false` deja terminar todas las versiones de la matriz aunque una falle, así sabes si el fallo es de una versión concreta. Añade un `.gitignore` y sube el proyecto: ```bash printf '.venv/\n__pycache__/\n.coverage\nreports/\n' > .gitignore git add . git commit -m "Añadir pruebas y workflow de CI" git branch -M main git remote add origin git@github.com:tu_usuario/tienda.git git push -u origin main ``` En la pestaña **Actions** del repositorio verás tres ejecuciones: `unit (3.12)`, `unit (3.13)` e `integration`. Al terminar, los informes aparecen en la sección **Artifacts** del resumen de la ejecución. ## Paso 6: Convertir las pruebas en un requisito para fusionar Un pipeline que falla pero no bloquea nada es solo un aviso. Para que las pruebas actúen como *quality gate*, haz que sean obligatorias en la rama `main`. En **Settings > Rules > Rulesets**, crea un ruleset para la rama por defecto y activa: - **Require a pull request before merging**. - **Require status checks to pass**, añadiendo `unit (3.12)`, `unit (3.13)` e `integration`. Los nombres solo aparecen en el buscador después de que el workflow se haya ejecutado al menos una vez. - **Require branches to be up to date before merging**, para que las pruebas se ejecuten sobre el resultado real de la fusión. Comprueba que funciona. Crea una rama que rompa una prueba: ```bash git switch -c prueba-rota sed -i 's/return round(importe \* (1 + tipo), 2)/return round(importe * tipo, 2)/' app/precios.py git commit -am "Cambio que rompe el cálculo del IVA" git push -u origin prueba-rota ``` Abre un pull request desde esa rama. El job `unit` fallará, `integration` no llegará a ejecutarse y el botón de fusionar quedará bloqueado. En el log del job verás exactamente qué prueba falló: ```output FAILED tests/unit/test_precios.py::test_iva_general - assert 21.0 == 121.0 FAILED tests/unit/test_precios.py::test_iva_reducido - assert 10.0 == 110.0 FAILED tests/unit/test_precios.py::test_redondeo_a_dos_decimales - assert 2.1 == 12.09 ========================= 3 failed, 1 passed in 0.03s ========================== ``` Cierra el pull request y borra la rama cuando termines. ## Solución de problemas **`psycopg.OperationalError: connection failed: Connection refused`** en CI: falta el bloque `ports` del servicio o `DATABASE_URL` apunta a `postgres` en lugar de `localhost`. Cuando el job se ejecuta directamente en el runner (sin `container:`), el servicio se alcanza por `localhost` y el puerto publicado. **`ModuleNotFoundError: No module named 'app'`**: falta `pythonpath = ["."]` en `pyproject.toml` o no existe `app/__init__.py`. **Las pruebas de integración aparecen como `skipped` en CI**: la variable `DATABASE_URL` no está definida en ese job. Revisa que el bloque `env` esté al nivel del job `integration`. **`Coverage failure: total of 72 is less than fail-under=80`**: el código nuevo no tiene pruebas. Revisa la columna `Missing` del informe para ver qué líneas faltan por cubrir. **`PytestUnknownMarkWarning` o error de marca desconocida**: la marca no está declarada en `markers` de `pyproject.toml`. ## Conclusión Tienes un pipeline que ejecuta pruebas unitarias en varias versiones de Python, pruebas de integración contra un PostgreSQL real, conserva los informes JUnit y de cobertura, y bloquea la fusión de cualquier cambio que rompa las pruebas o baje la cobertura del umbral. Como siguientes pasos, puedes añadir un job de análisis estático con `ruff` antes de las pruebas unitarias, pruebas end-to-end con Playwright contra un entorno de staging, y un despliegue automático que solo se ejecute cuando todos estos jobs terminen en verde.