Los tests unitarios tienen mala reputación entre muchos desarrolladores. La queja más habitual es que escribir tests es perder el tiempo, que los tests siempre pasan porque se escriben después del código y que cuando algo falla el test no te dice nada útil. Esa experiencia es real, pero no es un problema de los tests unitarios: es un problema de cómo se escriben.
Un test bien escrito detecta errores reales, documenta cómo funciona el código y te avisa cuando un cambio rompe algo. En esta guía aprenderás a escribir tests que de verdad sirven, en Python con pytest y en JavaScript con Jest.
Qué es un test unitario
Un test unitario verifica que una unidad de código (una función, un método, una clase) hace lo que se supone que debe hacer, de forma aislada y sin depender de factores externos como la base de datos, la red o el sistema de archivos.
Las características de un buen test unitario son:
- Rápido: corre en milisegundos, no en segundos. Si tarda, tiene dependencias externas que no deberían estar ahí.
- Determinista: siempre da el mismo resultado con la misma entrada. No depende de la hora, del estado de la base de datos ni de conexiones de red.
- Aislado: un test no debe afectar a otro ni depender de que otro haya corrido antes.
- Claro: cuando falla, debes entender qué salió mal leyendo el nombre del test y el mensaje de error, sin necesidad de depurar.
Configuración: pytest (Python) y Jest (JavaScript)
# Python: instalar pytest
pip install pytest pytest-cov
# Ejecutar los tests
pytest # ejecutar todos los tests del proyecto
pytest tests/ # ejecutar tests de una carpeta
pytest tests/test_pagos.py # ejecutar un archivo específico
pytest -v # verbose: muestra el nombre de cada test
pytest -k "descuento" # ejecutar solo tests cuyo nombre contiene "descuento"
pytest --tb=short # traceback corto en caso de fallo
pytest --cov=src # con cobertura de código
# JavaScript: instalar Jest
npm install --save-dev jest
# En package.json:
# "scripts": { "test": "jest", "test:watch": "jest --watch" }
npm test # ejecutar todos los tests
npm test -- --verbose # mostrar nombre de cada test
npm test -- --coverage # con cobertura de código
npm test -- pagos.test # filtrar por nombre de archivo
Tu primer test: el patrón AAA
El patrón AAA (Arrange, Act, Assert) es la estructura que todo test unitario debería seguir. Hace que los tests sean fáciles de leer y de mantener.
- Arrange: preparar los datos y el contexto necesario.
- Act: ejecutar la función que se está probando.
- Assert: verificar que el resultado es el esperado.
# Python: función que vamos a testear (src/precios.py)
def calcular_precio_con_descuento(precio: float, descuento_pct: float) -> float:
"""Calcula el precio final aplicando un descuento porcentual."""
if precio < 0:
raise ValueError("El precio no puede ser negativo")
if not 0 <= descuento_pct <= 100:
raise ValueError("El descuento debe estar entre 0 y 100")
return round(precio * (1 - descuento_pct / 100), 2)
# Python: tests con pytest (tests/test_precios.py)
from src.precios import calcular_precio_con_descuento
import pytest
def test_descuento_veinte_porciento():
# Arrange: preparar los datos
precio = 100.0
descuento = 20.0
# Act: ejecutar la función
resultado = calcular_precio_con_descuento(precio, descuento)
# Assert: verificar el resultado
assert resultado == 80.0
def test_sin_descuento_devuelve_precio_original():
precio = 49.99
descuento = 0.0
resultado = calcular_precio_con_descuento(precio, descuento)
assert resultado == 49.99
def test_descuento_cien_porciento_es_gratis():
resultado = calcular_precio_con_descuento(200.0, 100.0)
assert resultado == 0.0
def test_precio_negativo_lanza_error():
with pytest.raises(ValueError, match="no puede ser negativo"):
calcular_precio_con_descuento(-10.0, 20.0)
def test_descuento_mayor_cien_lanza_error():
with pytest.raises(ValueError, match="entre 0 y 100"):
calcular_precio_con_descuento(100.0, 150.0)
def test_redondea_a_dos_decimales():
resultado = calcular_precio_con_descuento(99.99, 33.0)
assert resultado == 66.99 # sin redondeo sería 66.9933
# JavaScript: función a testear (src/precios.js)
function calcularPrecioConDescuento(precio, descuentoPct) {
if (precio < 0) throw new Error('El precio no puede ser negativo');
if (descuentoPct < 0 || descuentoPct > 100) {
throw new Error('El descuento debe estar entre 0 y 100');
}
return Math.round(precio * (1 - descuentoPct / 100) * 100) / 100;
}
module.exports = { calcularPrecioConDescuento };
# JavaScript: tests con Jest (tests/precios.test.js)
const { calcularPrecioConDescuento } = require('../src/precios');
describe('calcularPrecioConDescuento', () => {
test('aplica un descuento del 20%', () => {
// Arrange
const precio = 100;
const descuento = 20;
// Act
const resultado = calcularPrecioConDescuento(precio, descuento);
// Assert
expect(resultado).toBe(80);
});
test('sin descuento devuelve el precio original', () => {
expect(calcularPrecioConDescuento(49.99, 0)).toBe(49.99);
});
test('descuento del 100% devuelve 0', () => {
expect(calcularPrecioConDescuento(200, 100)).toBe(0);
});
test('lanza error si el precio es negativo', () => {
expect(() => calcularPrecioConDescuento(-10, 20))
.toThrow('El precio no puede ser negativo');
});
test('lanza error si el descuento es mayor de 100', () => {
expect(() => calcularPrecioConDescuento(100, 150))
.toThrow('El descuento debe estar entre 0 y 100');
});
test('redondea a dos decimales', () => {
expect(calcularPrecioConDescuento(99.99, 33)).toBe(66.99);
});
});
Nombres de tests: la diferencia entre útil e inútil
El nombre del test es lo primero que lees cuando algo falla. Un nombre vago no te dice nada; un nombre descriptivo te dice exactamente qué está roto.
# ❌ Nombres malos: no dicen qué debería pasar
def test_precio(): ...
def test_descuento_1(): ...
def test_error(): ...
def test_funcion_calculo(): ...
# ✅ Nombres buenos: describen el escenario y el resultado esperado
# Patrón: test_[escenario]_[resultado_esperado]
def test_precio_negativo_lanza_value_error(): ...
def test_descuento_cero_devuelve_precio_original(): ...
def test_usuario_sin_email_no_puede_registrarse(): ...
def test_pedido_con_stock_insuficiente_lanza_excepcion(): ...
# En Jest, usa describe para agrupar y test/it para el caso concreto
describe('calcularPrecioConDescuento', () => {
describe('cuando el descuento es válido', () => {
test('devuelve el precio reducido', () => { ... });
test('redondea a dos decimales', () => { ... });
});
describe('cuando los parámetros son inválidos', () => {
test('lanza error si el precio es negativo', () => { ... });
test('lanza error si el descuento supera 100', () => { ... });
});
});
// Cuando falla, el mensaje es:
// FAIL calcularPrecioConDescuento > cuando los parámetros son inválidos >
// lanza error si el descuento supera 100
Tests parametrizados: probar múltiples casos sin repetir código
# Python: pytest.mark.parametrize
import pytest
from src.precios import calcular_precio_con_descuento
@pytest.mark.parametrize("precio, descuento, esperado", [
(100.0, 20.0, 80.0), # caso base
(50.0, 10.0, 45.0), # precio distinto
(200.0, 0.0, 200.0), # sin descuento
(200.0, 100.0, 0.0), # descuento total
(99.99, 33.0, 66.99), # redondeo
(0.0, 50.0, 0.0), # precio cero
])
def test_calcular_precio_con_descuento(precio, descuento, esperado):
assert calcular_precio_con_descuento(precio, descuento) == esperado
# pytest ejecuta el test 6 veces, una por cada caso
# Si uno falla, muestra exactamente qué valores causaron el fallo:
# FAILED test_precios.py::test_calcular_precio_con_descuento[99.99-33.0-66.99]
# JavaScript: test.each en Jest
test.each([
[100, 20, 80],
[50, 10, 45],
[200, 0, 200],
[200, 100, 0],
[99.99, 33, 66.99],
])(
'precio %d con descuento %d% devuelve %d',
(precio, descuento, esperado) => {
expect(calcularPrecioConDescuento(precio, descuento)).toBe(esperado);
}
);
Mocks y stubs: aislar el código de sus dependencias
Cuando la función que estás probando llama a una base de datos, a una API externa o envía emails, el test deja de ser unitario. Los mocks reemplazan esas dependencias con versiones controladas que devuelven exactamente lo que necesitas para el test.
# Python: función con dependencia externa
# src/usuarios.py
import requests
def obtener_usuario_github(username: str) -> dict:
respuesta = requests.get(f"https://api.github.com/users/{username}")
if respuesta.status_code == 404:
raise ValueError(f"Usuario '{username}' no encontrado en GitHub")
respuesta.raise_for_status()
datos = respuesta.json()
return {
"nombre": datos["name"],
"seguidores": datos["followers"],
"repositorios": datos["public_repos"]
}
# Python: mock con unittest.mock (sin hacer peticiones reales)
from unittest.mock import patch, MagicMock
from src.usuarios import obtener_usuario_github
import pytest
def test_devuelve_datos_formateados_del_usuario():
# Arrange: preparar la respuesta falsa de la API
respuesta_falsa = MagicMock()
respuesta_falsa.status_code = 200
respuesta_falsa.json.return_value = {
"name": "Ana García",
"followers": 1234,
"public_repos": 42,
"bio": "Desarrolladora",
"location": "Madrid" # campo que la función ignora
}
# Act + Assert: reemplazar requests.get con nuestro mock
with patch("src.usuarios.requests.get", return_value=respuesta_falsa):
resultado = obtener_usuario_github("anagarcia")
assert resultado == {
"nombre": "Ana García",
"seguidores": 1234,
"repositorios": 42
}
# La función solo incluyó los campos que le interesan
def test_lanza_error_si_usuario_no_existe():
respuesta_falsa = MagicMock()
respuesta_falsa.status_code = 404
with patch("src.usuarios.requests.get", return_value=respuesta_falsa):
with pytest.raises(ValueError, match="no encontrado en GitHub"):
obtener_usuario_github("usuario-inexistente-12345")
# JavaScript: mock con Jest
// src/usuarios.js
const axios = require('axios');
async function obtenerUsuarioGithub(username) {
try {
const { data } = await axios.get(`https://api.github.com/users/${username}`);
return {
nombre: data.name,
seguidores: data.followers,
repositorios: data.public_repos
};
} catch (error) {
if (error.response?.status === 404) {
throw new Error(`Usuario '${username}' no encontrado en GitHub`);
}
throw error;
}
}
module.exports = { obtenerUsuarioGithub };
# JavaScript: test con Jest mock
const axios = require('axios');
const { obtenerUsuarioGithub } = require('../src/usuarios');
// Reemplazar axios con un mock automático
jest.mock('axios');
describe('obtenerUsuarioGithub', () => {
test('devuelve los datos formateados del usuario', async () => {
// Arrange: configurar qué devuelve el mock de axios
axios.get.mockResolvedValue({
data: {
name: 'Ana García',
followers: 1234,
public_repos: 42,
bio: 'Desarrolladora', // campo ignorado por la función
}
});
// Act
const resultado = await obtenerUsuarioGithub('anagarcia');
// Assert
expect(resultado).toEqual({
nombre: 'Ana García',
seguidores: 1234,
repositorios: 42
});
expect(axios.get).toHaveBeenCalledWith(
'https://api.github.com/users/anagarcia'
);
});
test('lanza error si el usuario no existe', async () => {
axios.get.mockRejectedValue({ response: { status: 404 } });
await expect(obtenerUsuarioGithub('inexistente'))
.rejects.toThrow("no encontrado en GitHub");
});
// Limpiar mocks entre tests
afterEach(() => jest.clearAllMocks());
});
Qué testear y qué no
No todo el código necesita tests unitarios al mismo nivel. Aquí está la guía práctica:
Sí testear:
# ✅ Lógica de negocio con reglas complejas
def calcular_comision(venta: float, nivel: str) -> float:
"""La comisión varía según el nivel del vendedor y el importe."""
if nivel == "junior" and venta < 1000:
return venta * 0.05
elif nivel == "junior":
return venta * 0.08
elif nivel == "senior" and venta < 5000:
return venta * 0.10
else:
return venta * 0.15
# Múltiples condiciones → múltiples tests, uno por cada caso
# ✅ Funciones puras con transformaciones de datos
def normalizar_email(email: str) -> str:
return email.strip().lower()
def formatear_nombre(nombre: str) -> str:
return " ".join(p.capitalize() for p in nombre.strip().split())
# ✅ Manejo de casos límite y errores
def dividir(a: float, b: float) -> float:
if b == 0:
raise ZeroDivisionError("No se puede dividir entre cero")
return a / b
# ✅ Algoritmos y cálculos
def calcular_iva(precio: float, tipo_iva: float = 21.0) -> float:
return round(precio * tipo_iva / 100, 2)
No merece la pena testear de forma unitaria:
# ❌ Getters y setters triviales sin lógica
@property
def nombre(self):
return self._nombre # testear esto no aporta valor
# ❌ Constructores que solo asignan atributos
def __init__(self, nombre, email):
self.nombre = nombre
self.email = email
# ❌ Código que es directamente un wrapper de una librería
def guardar_en_db(usuario):
db.session.add(usuario)
db.session.commit()
# Esto es integración con la BD: testearlo es un test de integración, no unitario
# ❌ Configuración estática
APP_NAME = "Mi App"
MAX_USUARIOS = 1000
Los errores más habituales al escribir tests
Test que solo verifica que no lanza error
# ❌ Test inútil: no verifica nada
def test_calcular_precio():
calcular_precio_con_descuento(100, 20) # no hay assert
# Si la función devuelve -999 el test pasa igualmente
# ✅ Siempre verificar el resultado
def test_calcular_precio():
resultado = calcular_precio_con_descuento(100, 20)
assert resultado == 80.0
Un test que verifica demasiado
# ❌ Un test que verifica 5 cosas distintas: cuando falla, no sabes cuál es el problema
def test_usuario():
usuario = crear_usuario("Ana", "ana@mail.com", edad=25)
assert usuario.nombre == "Ana"
assert usuario.email == "ana@mail.com"
assert usuario.edad == 25
assert usuario.activo == True
assert usuario.rol == "usuario"
assert usuario.creado_en is not None
# Si falla, ¿cuál de los 6 asserts falló primero y por qué?
# ✅ Un assert principal por test (o asserts que verifican una misma cosa)
def test_usuario_creado_con_datos_correctos():
usuario = crear_usuario("Ana", "ana@mail.com", edad=25)
assert usuario.nombre == "Ana"
assert usuario.email == "ana@mail.com"
def test_usuario_nuevo_esta_activo_por_defecto():
usuario = crear_usuario("Ana", "ana@mail.com")
assert usuario.activo == True
def test_usuario_nuevo_tiene_rol_usuario_por_defecto():
usuario = crear_usuario("Ana", "ana@mail.com")
assert usuario.rol == "usuario"
Test que depende del orden de ejecución
# ❌ Tests que comparten estado: el segundo depende del primero
usuarios = []
def test_añadir_usuario():
usuarios.append({"nombre": "Ana"})
assert len(usuarios) == 1
def test_listar_usuarios():
assert len(usuarios) == 1 # depende de que test_añadir_usuario haya corrido antes
# Si los tests corren en otro orden, test_listar_usuarios falla
# ✅ Cada test crea su propio estado
def test_añadir_usuario():
lista = []
lista.append({"nombre": "Ana"})
assert len(lista) == 1
def test_listar_usuarios():
lista = [{"nombre": "Ana"}, {"nombre": "Carlos"}] # estado propio
assert len(lista) == 2
Mock que verifica la implementación, no el comportamiento
# ❌ Test frágil: acoplado a los detalles de implementación
def test_obtener_usuario():
with patch("src.db.Session") as mock_session:
mock_session.return_value.query.return_value.filter.return_value.first.return_value = usuario_mock
resultado = obtener_usuario(123)
# Si cambia la implementación de la consulta SQL, el test falla aunque la función siga funcionando
# ✅ Verificar el comportamiento observable (qué devuelve)
def test_obtener_usuario_existente():
# Usar un repositorio en memoria en lugar de mockear el ORM
repo = RepositorioUsuariosEnMemoria()
repo.guardar(Usuario(id=123, nombre="Ana"))
servicio = ServicioUsuarios(repo)
resultado = servicio.obtener_usuario(123)
assert resultado.nombre == "Ana"
Fixtures: preparar el contexto de forma reutilizable
# Python: fixtures en pytest
import pytest
@pytest.fixture
def usuario_basico():
"""Un usuario básico para tests que necesitan un usuario de partida."""
return {
"nombre": "Ana García",
"email": "ana@mail.com",
"edad": 28,
"activo": True
}
@pytest.fixture
def carrito_con_productos():
"""Un carrito con dos productos para tests de checkout."""
return {
"items": [
{"id": 1, "nombre": "Teclado", "precio": 79.99, "cantidad": 1},
{"id": 2, "nombre": "Ratón", "precio": 39.99, "cantidad": 2},
]
}
# Usar los fixtures en los tests
def test_calcular_total_carrito(carrito_con_productos):
total = calcular_total(carrito_con_productos)
assert total == 159.97 # 79.99 + 39.99*2
def test_usuario_activo_puede_comprar(usuario_basico, carrito_con_productos):
assert puede_comprar(usuario_basico, carrito_con_productos) == True
# JavaScript: beforeEach para preparar el contexto
describe('ServicioCarrito', () => {
let carrito;
beforeEach(() => {
// Se ejecuta antes de CADA test: estado limpio
carrito = new Carrito();
carrito.añadir({ id: 1, nombre: 'Teclado', precio: 79.99 });
carrito.añadir({ id: 2, nombre: 'Ratón', precio: 39.99 });
});
test('calcula el total correctamente', () => {
expect(carrito.total()).toBeCloseTo(119.98);
});
test('cuenta los items correctamente', () => {
expect(carrito.numItems()).toBe(2);
});
test('puede vaciarse', () => {
carrito.vaciar();
expect(carrito.numItems()).toBe(0);
});
});
Cobertura de código: cómo interpretarla
# Python: ejecutar tests con cobertura
pytest --cov=src --cov-report=term-missing
# Salida:
# Name Stmts Miss Cover Missing
# -------------------------------------------------------
# src/precios.py 8 1 87% 15
# src/usuarios.py 24 5 79% 45-49
# src/pedidos.py 45 0 100%
# -------------------------------------------------------
# TOTAL 77 6 92%
# Miss: líneas no cubiertas por ningún test
# Missing: número de línea sin cubrir
Un porcentaje de cobertura alto no significa que los tests sean buenos: puedes tener 100% de cobertura con tests que no verifican nada. La cobertura es útil para encontrar código que nadie ha probado nunca, no para garantizar que los tests son correctos.
Un umbral razonable para proyectos reales es entre el 70% y el 85%. Perseguir el 100% suele llevar a tests frágiles que testean código trivial y que se rompen con cualquier refactorización interna.
TDD: escribir el test antes que el código
TDD (Test-Driven Development) invierte el proceso: primero escribes el test (que falla porque el código no existe todavía), luego escribes el mínimo código necesario para que el test pase, y finalmente refactorizas.
# Ciclo TDD: Red → Green → Refactor
# ─── Red: escribir el test que falla ──────────────────────────────────────────
def test_contraseña_segura_requiere_mayuscula():
assert es_contraseña_segura("sinmayuscula1!") == False
# ERROR: NameError: name 'es_contraseña_segura' is not defined
# ─── Green: mínimo código para que pase ───────────────────────────────────────
def es_contraseña_segura(contraseña: str) -> bool:
return any(c.isupper() for c in contraseña)
# El test pasa. Pero la función es incompleta.
# ─── Añadir más tests ─────────────────────────────────────────────────────────
def test_contraseña_segura_requiere_numero():
assert es_contraseña_segura("SinNumero!") == False
def test_contraseña_segura_requiere_minimo_8_caracteres():
assert es_contraseña_segura("Aa1!") == False
def test_contraseña_segura_con_todos_los_requisitos():
assert es_contraseña_segura("Segura1!") == True
# ─── Green: hacer pasar todos ─────────────────────────────────────────────────
def es_contraseña_segura(contraseña: str) -> bool:
tiene_mayuscula = any(c.isupper() for c in contraseña)
tiene_numero = any(c.isdigit() for c in contraseña)
es_larga = len(contraseña) >= 8
return tiene_mayuscula and tiene_numero and es_larga
# ─── Refactor: limpiar el código sin romper los tests ─────────────────────────
def es_contraseña_segura(contraseña: str) -> bool:
return (
len(contraseña) >= 8
and any(c.isupper() for c in contraseña)
and any(c.isdigit() for c in contraseña)
)
# Todos los tests siguen pasando: la refactorización fue segura
Resumen
- Un test unitario verifica una función de forma aislada, sin base de datos ni red. Debe ser rápido, determinista y claro cuando falla.
- El patrón AAA (Arrange, Act, Assert) da estructura a cada test: preparar los datos, ejecutar la función, verificar el resultado.
- El nombre del test debe describir el escenario y el resultado esperado:
test_precio_negativo_lanza_value_error, notest_error. - Los tests parametrizados (
pytest.mark.parametrize,test.each) permiten probar múltiples casos con el mismo test sin repetir código. - Los mocks reemplazan dependencias externas (APIs, base de datos) con versiones controladas. Testea el comportamiento observable (qué devuelve la función), no los detalles de implementación (cómo lo hace).
- Un test sin
assertno es un test: verifica que no lanza error pero no que el resultado sea correcto. Cada test debe verificar una cosa concreta. - Los fixtures y el
beforeEachpreparan el contexto de forma reutilizable. Cada test debe ser independiente: no puede depender de que otro test haya corrido antes. - La cobertura del 100% no garantiza buenos tests. Un 70-85% con tests que verifican comportamiento real es mejor que el 100% con tests triviales.
No hay comentarios todavía. Sé el primero en compartir tu opinión.