Pytest desde cero — Tu primer test en Python en 10 minutos

Si llevas un tiempo programando, sabes que escribir tests debería ser parte normal del trabajo. Pero también sabes que casi nunca lo es. La excusa habitual: “no tengo tiempo”, “es código de juguete”, “ya lo testeo a mano cuando ejecuto”.
Con pytest, escribir tests es literalmente más rápido que probar a mano. Defines un test una vez, lo lanzas con un comando, y cada vez que cambias el código sabes en segundos si algo se rompió. Una vez te acostumbras, no entiendes cómo trabajabas antes sin esto.
En esta entrada te enseño a escribir tu primer test, las funcionalidades clave (fixtures, parametrize, mocks), qué testear y qué no, y los errores típicos cuando empiezas. En 10 minutos te montas tu primer test y entiendes el patrón para todo lo demás.
Contenido
- 1 Instalación
- 2 Tu primer test
- 3 Convenciones que pytest descubre solo
- 4 Aserciones con assert plano
- 5 Esperar excepciones
- 6 parametrize — un test, muchos casos
- 7 Fixtures — preparar contexto reutilizable
- 8 Mockear con monkeypatch y unittest.mock
- 9 Qué testear (y qué no)
- 10 Casos reales típicos
- 11 Configuración mínima en pyproject.toml
- 12 Errores típicos al empezar
- 13 Resumen
- 14 Tu siguiente paso
Instalación
pip install pytest
💡 ¿Aún no usas entornos virtuales?
venvypipsin liarte. Cinco comandos que te ahorran problemas.
Listo. pytest viene con todo lo básico que necesitas.
Tu primer test
Estructura mínima de un proyecto:
mi_proyecto/
├── src/
│ └── calculadora.py
└── tests/
└── test_calculadora.py
# src/calculadora.py
def sumar(a, b):
return a + b
def restar(a, b):
return a - b
# tests/test_calculadora.py
from src.calculadora import sumar, restar
def test_sumar_dos_positivos():
assert sumar(2, 3) == 5
def test_sumar_con_cero():
assert sumar(5, 0) == 5
def test_restar():
assert restar(10, 4) == 6
Ejecutar:
$ pytest
============= test session starts =============
collected 3 items
tests/test_calculadora.py ... [100%]
============= 3 passed in 0.02s =============
Tres puntos = tres tests pasaron. Una F sería un fallo. Eso es todo lo que pytest necesita: funciones cuyo nombre empiece por test_ y assert para verificar.
Convenciones que pytest descubre solo
pytest busca tests automáticamente:
- Ficheros que empiezan por
test_o terminan en_test.py. - Funciones dentro que empiezan por
test_. - Clases que empiezan por
Test(sin__init__).
No hace falta configurar nada. Lanzas pytest desde la raíz del proyecto y descubre todo.
📥 Llévate el cheatsheet de Python (gratis)
PDF de 6 páginas con lo esencial: tipos, condicionales, bucles, estructuras de datos, funciones y los errores que más vas a cometer. Para tener al lado mientras programas.
Sin spam. Te apuntas a la lista, descargas el cheatsheet y recibes contenido de Python cada semana.
Aserciones con assert plano
Una de las grandes ventajas de pytest sobre unittest: usas el assert normal de Python. Y cuando falla, te muestra exactamente qué falló:
def test_resta():
assert restar(10, 4) == 7 # ← está mal, debería ser 6
# Output:
# > assert restar(10, 4) == 7
# E assert 6 == 7
# E + where 6 = restar(10, 4)
Te muestra el valor real (6) y el esperado (7). Sin necesidad de assertEqual, assertTrue, assertGreater… del unittest tradicional.
Otros asserts útiles:
def test_listas():
assert sumar(1, 2) > 0
assert "ana" in ["ana", "luis"]
assert isinstance(sumar(1, 2), int)
assert len([1, 2, 3]) == 3
Esperar excepciones
Cuando quieres comprobar que un código lanza una excepción concreta:
import pytest
def dividir(a, b):
return a / b
def test_dividir_por_cero():
with pytest.raises(ZeroDivisionError):
dividir(10, 0)
pytest.raises(Exception) es un context manager que verifica que el bloque levanta esa excepción. Si no la levanta, el test falla.
Para verificar el mensaje:
def test_dividir_por_cero_mensaje():
with pytest.raises(ZeroDivisionError, match="division by zero"):
dividir(10, 0)
parametrize — un test, muchos casos
Aquí es donde pytest brilla. Para correr el mismo test con datos distintos:
import pytest
@pytest.mark.parametrize("a, b, esperado", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(10, -5, 5),
])
def test_sumar(a, b, esperado):
assert sumar(a, b) == esperado
Esto crea 4 tests separados automáticamente. Si uno falla, ves exactamente cuál — los otros 3 siguen ejecutándose.
tests/test_calculadora.py::test_sumar[2-3-5] PASSED
tests/test_calculadora.py::test_sumar[0-0-0] PASSED
tests/test_calculadora.py::test_sumar[-1-1-0] PASSED
tests/test_calculadora.py::test_sumar[10--5-5] PASSED
💡 Patrón pro:
parametrizecon todos los casos edge (negativo, cero, valores grandes, vacío…). Una sola función de test cubre 10 escenarios.
Fixtures — preparar contexto reutilizable
Una fixture es código que prepara datos o estado para un test. Se decora con @pytest.fixture:
import pytest
@pytest.fixture
def usuario_admin():
return {"id": 1, "nombre": "Ana", "es_admin": True}
def test_usuario_es_admin(usuario_admin):
assert usuario_admin["es_admin"]
def test_usuario_tiene_id(usuario_admin):
assert "id" in usuario_admin
pytest ve que test_usuario_es_admin recibe un argumento usuario_admin y busca una fixture con ese nombre. Llama a la función decorada, le pasa el resultado al test.
⚡ Tip-friki: las fixtures pueden depender de otras fixtures. Cadenas de fixtures que preparan BBDD, sesiones, mocks… son lo que mantiene los tests limpios en proyectos grandes.
💡 Las fixtures son decoradores. ¿Quieres entender decoradores a fondo? Decoradores en Python explicados.
Fixture con setup y teardown
Si tu fixture tiene que limpiar al final, usa yield:
@pytest.fixture
def db_temporal():
conn = abrir_db_temp()
yield conn # ← lo que recibe el test
conn.close() # ← se ejecuta después del test
Mismo patrón que context managers con yield.
Mockear con monkeypatch y unittest.mock
A veces tienes que simular dependencias externas (APIs, BBDD, fechas):
def saludo_de_dia():
from datetime import datetime
if datetime.now().hour < 12:
return "Buenos días"
return "Buenas tardes"
def test_buenos_dias(monkeypatch):
from datetime import datetime
class MockDateTime:
@classmethod
def now(cls):
class FakeNow: hour = 9
return FakeNow()
monkeypatch.setattr("datetime.datetime", MockDateTime)
assert saludo_de_dia() == "Buenos días"
Más común con unittest.mock.patch (también funciona en pytest):
from unittest.mock import patch
def test_descargar_url():
with patch("requests.get") as mock_get:
mock_get.return_value.json.return_value = {"data": [1, 2, 3]}
resultado = mi_funcion_que_llama_api()
assert resultado == [1, 2, 3]
patch sustituye requests.get por un mock durante el bloque. Cuando sale del with, restaura.
Qué testear (y qué no)
Testea:
- Lógica de negocio. Cualquier función con cálculos, decisiones, transformaciones.
- Edge cases. Valores en los límites, vacíos, negativos, máximos.
- Bugs encontrados. Cuando arregles uno, escribe el test que lo capture. No volverá.
- Funciones puras. Las que no dependen de I/O y devuelven el mismo resultado para los mismos inputs.
No testees obsesivamente:
- Frameworks ajenos. No testees que
requests.getfunciona — Django, Flask, requests ya tienen sus tests. - Trivialidades.
def get_nombre(self): return self.nombreno necesita test. - UI/CSS. Los tests E2E (con Playwright/Selenium) son otra historia y son caros.
💡 La regla práctica: 80/20. El 20% de tu código (la lógica de negocio) tiene el 80% de los bugs. Testea eso bien y deja el resto a tests integration cuando los necesites.
Casos reales típicos
Test parametrizado de validación
import pytest
def es_email_valido(email):
return "@" in email and "." in email.split("@")[1]
@pytest.mark.parametrize("email, esperado", [
("ana@gmail.com", True),
("info@elpythonista.com", True),
("sin-arroba", False),
("sin@dominio", False),
("", False),
])
def test_validar_email(email, esperado):
assert es_email_valido(email) == esperado
Fixture que crea fichero temporal y lo limpia
import pytest
from pathlib import Path
@pytest.fixture
def fichero_temporal(tmp_path):
ruta = tmp_path / "datos.txt"
ruta.write_text("contenido inicial", encoding="utf-8")
return ruta
def test_leer_fichero(fichero_temporal):
contenido = fichero_temporal.read_text(encoding="utf-8")
assert contenido == "contenido inicial"
tmp_path es una fixture que pytest da gratis: una Path a un directorio temporal único por test, que pytest borra después.
Marcadores para tests lentos
import pytest
@pytest.mark.slow
def test_consulta_costosa():
...
# Lanzar solo los rápidos:
# pytest -m "not slow"
# Solo los lentos:
# pytest -m slow
Útil cuando algunos tests tardan minutos: no los corres en cada pytest, solo en CI o cuando lanzas pytest -m slow.
Configuración mínima en pyproject.toml
[tool.pytest.ini_options]
minversion = "7.0"
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-v --tb=short"
markers = [
"slow: tests que tardan más de 1 segundo",
"integration: tests que requieren BBDD o red",
]
-v muestra cada test individual. --tb=short corta tracebacks largos. Con esto pytest queda limpio.
Errores típicos al empezar
# 1. Olvidar que los nombres deben empezar por test_
def comprueba_suma(): # ❌ pytest no lo descubre
assert sumar(2, 3) == 5
def test_suma(): # ✓
assert sumar(2, 3) == 5
# 2. Tests que dependen del orden
contador = 0
def test_uno():
global contador
contador += 1
assert contador == 1
def test_dos():
assert contador == 1 # ❌ depende de que test_uno corriera antes
# Cada test debe ser independiente.
# 3. Tests que usan datos reales (BBDD productiva, APIs externas)
def test_login():
requests.post("https://api.real.com/login", ...) # ❌ rompe la API real
# Mockea con `patch` o usa una BBDD de test.
# 4. Olvidar limpieza al usar fixtures
@pytest.fixture
def fichero():
f = open("temporal.txt", "w")
return f
# ❌ no cierra. Usa yield + close, o `tmp_path`.
# 5. assert con mensaje custom raro
assert sumar(2, 3) == 5, "esto debería ser 5"
# pytest ya muestra el valor real. El mensaje custom suele estorbar.
# 6. Tests demasiado grandes (10 asserts en uno)
def test_todo():
assert sumar(2, 3) == 5
assert restar(5, 3) == 2
assert multiplicar(2, 4) == 8
...
# ❌ si falla el primero, no ves los demás. Divídelo en 3 tests.
Resumen
| Concepto | Sintaxis |
|---|---|
| Instalación | pip install pytest |
| Test mínimo | def test_xxx(): assert ... |
| Excepción esperada | with pytest.raises(ValueError): ... |
| Mismos test, varios datos | @pytest.mark.parametrize("a, b", [...]) |
| Setup reutilizable | @pytest.fixture |
| Setup + teardown | Fixture con yield |
| Fichero temporal gratis | Fixture tmp_path |
| Mockear | unittest.mock.patch o monkeypatch |
| Lanzar | pytest (busca solo) |
| Verbose | pytest -v |
| Por marcador | pytest -m slow |
| Configurar | [tool.pytest.ini_options] en pyproject.toml |
¿Te ha valido esto?
Si te ha resultado útil, llévate el cheatsheet de Python en PDF — 6 páginas con tipos, condicionales, bucles, estructuras de datos, funciones y los errores típicos. Para tener al lado mientras programas. Gratis.
Sin spam. Email + cheatsheet + un correo por semana con tutoriales nuevos.
Tu siguiente paso
Si has llegado hasta aquí, ya tienes lo necesario para escribir tests útiles desde la próxima función que escribas. Empezar es fácil: el primer test te llevará 5 minutos. Mantenerlos te lleva minutos al día. Y la primera vez que un test te avise de un bug que ibas a meter en producción, ya no te imaginas trabajando sin ellos.
Si quieres aprender Python desde la base hasta proyectos reales con tests, deploy y CI, en El Pythonista lo enseño paso a paso.
Un abrazo,
Oscar
¿Quieres aprender Python en orden, no a saltos?
Esto que has leído es solo una pieza. En El Pythonista lo verás todo encadenado: 11 módulos, 37+ horas de vídeo, 734 actividades y un proyecto real (MovieTracker) que crece contigo desde la primera variable hasta el deploy a producción.
37+ horas · 734 actividades · Proyecto real · Acceso de por vida · 14 días de garantía
