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:
optionscon--health-cmdhace 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
testsolo 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.concurrencycancela la ejecución anterior de la misma rama si haces otro push, para no gastar minutos en código obsoleto.fail-fast: falsedeja 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)eintegration. 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.