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:

sudo apt update
sudo apt install -y python3 python3-venv python3-pip git

Crea la estructura del proyecto:

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:

nano app/precios.py
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:

nano app/db.py
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:

nano requirements-dev.txt
pytest>=8
pytest-cov>=5
psycopg[binary]>=3.2

Crea el entorno virtual e instálalas:

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt
pytest --version
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:

nano tests/unit/test_precios.py
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:

nano pyproject.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:

pytest -m "not integration"
============================= 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:

nano tests/integration/test_pedidos.py
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:

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:

docker exec pg-test pg_isready -U postgres
/var/run/postgresql:5432 - accepting connections

Ejecuta todas las pruebas con la variable de conexión:

export DATABASE_URL=postgresql://postgres:[email protected]:5432/tienda_test
pytest
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:

pytest --cov=app --cov-report=term-missing --cov-fail-under=80
---------- 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:

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:

mkdir -p .github/workflows
nano .github/workflows/tests.yml
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:

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 [email protected]: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:

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ó:

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.