Un error no debería desaparecer silenciosamente. Cuando el código captura cualquier excepción y devuelve un valor por defecto, el programa puede seguir funcionando con datos incorrectos y el fallo real queda oculto.
En este tema refactorizaremos manejo de errores en Python. Reemplazaremos except amplios por excepciones específicas, mensajes claros y pruebas que documenten los casos esperados.
El siguiente patrón parece cómodo, pero es peligroso:
try:
configuracion = cargar_configuracion("config.json")
except Exception:
configuracion = {}
El código no distingue entre archivo inexistente, JSON mal formado, claves faltantes o un error de programación. Todos los fallos se convierten en un diccionario vacío.
Crea el archivo src/configuracion.py:
import json
def cargar_configuracion(ruta):
try:
with open(ruta, encoding="utf-8") as archivo:
datos = json.load(archivo)
return {
"host": datos["host"],
"puerto": int(datos["puerto"]),
"debug": bool(datos.get("debug", False)),
}
except Exception:
return {
"host": "localhost",
"puerto": 8000,
"debug": False,
}
Si el archivo tiene una clave mal escrita, el programa arranca con valores por defecto y nadie sabe que la configuración estaba mal.
Antes de modificar, documentamos el comportamiento correcto para una configuración válida:
from src.configuracion import cargar_configuracion
def test_carga_configuracion_valida(tmp_path):
archivo = tmp_path / "config.json"
archivo.write_text(
'{"host": "api.local", "puerto": "9000", "debug": true}',
encoding="utf-8",
)
configuracion = cargar_configuracion(archivo)
assert configuracion == {
"host": "api.local",
"puerto": 9000,
"debug": True,
}
python -m pytest tests/test_configuracion.py
No todos los errores deben tratarse igual. En este ejemplo podemos distinguir:
La refactorización empieza cuando nombramos esos casos. Lo que tiene nombre se puede probar y comunicar.
Podemos definir excepciones propias para que el código cliente entienda qué tipo de problema ocurrió:
class ErrorConfiguracion(Exception):
pass
class ConfiguracionInvalida(ErrorConfiguracion):
pass
class ConfiguracionNoEncontrada(ErrorConfiguracion):
pass
Estas clases no agregan lógica todavía. Su valor está en expresar intención.
El siguiente paso es extraer funciones con responsabilidades claras:
def leer_archivo_texto(ruta):
with open(ruta, encoding="utf-8") as archivo:
return archivo.read()
def parsear_json(contenido):
return json.loads(contenido)
def construir_configuracion(datos):
return {
"host": datos["host"],
"puerto": int(datos["puerto"]),
"debug": bool(datos.get("debug", False)),
}
Aún falta mejorar los errores, pero el código ya permite probar el parseo y la construcción sin tocar archivos.
Ahora la función pública captura solo errores esperados y los traduce a errores del dominio:
import json
def cargar_configuracion(ruta):
try:
contenido = leer_archivo_texto(ruta)
except FileNotFoundError as error:
raise ConfiguracionNoEncontrada(f"No existe el archivo: {ruta}") from error
try:
datos = parsear_json(contenido)
return construir_configuracion(datos)
except json.JSONDecodeError as error:
raise ConfiguracionInvalida("El archivo no contiene JSON válido") from error
except KeyError as error:
raise ConfiguracionInvalida(f"Falta la clave obligatoria: {error.args[0]}") from error
except ValueError as error:
raise ConfiguracionInvalida("El puerto debe ser un número entero") from error
El uso de raise ... from error conserva la causa original. Esto ayuda al diagnóstico sin exponer detalles innecesarios al resto del programa.
La prueba debe comprobar el tipo de error y el mensaje principal:
import pytest
from src.configuracion import ConfiguracionNoEncontrada, cargar_configuracion
def test_falla_si_archivo_no_existe(tmp_path):
archivo = tmp_path / "no_existe.json"
with pytest.raises(ConfiguracionNoEncontrada, match="No existe el archivo"):
cargar_configuracion(archivo)
python -m pytest tests/test_configuracion.py
El JSON mal formado también tiene una respuesta explícita:
import pytest
from src.configuracion import ConfiguracionInvalida, cargar_configuracion
def test_falla_si_json_es_invalido(tmp_path):
archivo = tmp_path / "config.json"
archivo.write_text("{host: api.local}", encoding="utf-8")
with pytest.raises(ConfiguracionInvalida, match="JSON válido"):
cargar_configuracion(archivo)
Si falta una clave obligatoria, no conviene usar valores mágicos por defecto:
import pytest
from src.configuracion import ConfiguracionInvalida, cargar_configuracion
def test_falla_si_falta_host(tmp_path):
archivo = tmp_path / "config.json"
archivo.write_text('{"puerto": 9000}', encoding="utf-8")
with pytest.raises(ConfiguracionInvalida, match="host"):
cargar_configuracion(archivo)
import json
class ErrorConfiguracion(Exception):
pass
class ConfiguracionInvalida(ErrorConfiguracion):
pass
class ConfiguracionNoEncontrada(ErrorConfiguracion):
pass
def leer_archivo_texto(ruta):
with open(ruta, encoding="utf-8") as archivo:
return archivo.read()
def parsear_json(contenido):
return json.loads(contenido)
def construir_configuracion(datos):
return {
"host": datos["host"],
"puerto": int(datos["puerto"]),
"debug": bool(datos.get("debug", False)),
}
def cargar_configuracion(ruta):
try:
contenido = leer_archivo_texto(ruta)
except FileNotFoundError as error:
raise ConfiguracionNoEncontrada(f"No existe el archivo: {ruta}") from error
try:
datos = parsear_json(contenido)
return construir_configuracion(datos)
except json.JSONDecodeError as error:
raise ConfiguracionInvalida("El archivo no contiene JSON válido") from error
except KeyError as error:
raise ConfiguracionInvalida(f"Falta la clave obligatoria: {error.args[0]}") from error
except ValueError as error:
raise ConfiguracionInvalida("El puerto debe ser un número entero") from error
Los valores por defecto son correctos cuando forman parte explícita de la regla. En el ejemplo, debug puede ser opcional porque lo decidimos conscientemente.
En cambio, usar host="localhost" o puerto=8000 ante cualquier error oculta problemas de configuración. Un valor por defecto no debe funcionar como una alfombra para esconder fallos.
Una buena práctica es dejar que el núcleo lance errores claros y capturarlos en el borde de la aplicación:
def main():
try:
configuracion = cargar_configuracion("config.json")
except ErrorConfiguracion as error:
print(f"No se pudo iniciar la aplicación: {error}")
return 1
iniciar_aplicacion(configuracion)
return 0
Así la regla de carga no decide cómo terminar el programa, mostrar mensajes o registrar eventos. Solo informa el problema.
Capturar una excepción tiene sentido cuando el código puede hacer algo útil:
Si no hay una acción concreta, probablemente conviene dejar que la excepción suba.
except Exception captura demasiadas cosas: errores de datos, errores de infraestructura y errores de programación. Si se usa en el núcleo de la aplicación, puede ocultar fallos que deberían corregirse.
Puede ser aceptable en un borde externo, por ejemplo para registrar un error inesperado antes de terminar el proceso. Aun en ese caso, no debería convertir cualquier fallo en éxito.
Refactoriza esta función para que no oculte errores:
def obtener_total_respuesta(respuesta):
try:
return float(respuesta["data"]["total"])
except Exception:
return 0.0
Objetivos:
data.total.class RespuestaInvalida(Exception):
pass
def obtener_total_respuesta(respuesta):
try:
data = respuesta["data"]
except KeyError as error:
raise RespuestaInvalida("La respuesta no contiene data") from error
try:
return float(data["total"])
except KeyError as error:
raise RespuestaInvalida("La respuesta no contiene total") from error
except (TypeError, ValueError) as error:
raise RespuestaInvalida("El total no es numérico") from error
Prueba posible:
import pytest
from src.respuestas import RespuestaInvalida, obtener_total_respuesta
def test_obtiene_total_numerico():
respuesta = {"data": {"total": "125.50"}}
assert obtener_total_respuesta(respuesta) == 125.50
def test_falla_si_no_hay_total():
respuesta = {"data": {}}
with pytest.raises(RespuestaInvalida, match="total"):
obtener_total_respuesta(respuesta)
python -m pytest tests/test_respuestas.py
Antes de continuar, verifica que puedes explicar estos puntos:
except amplio puede ocultar errores reales.raise ... from error.En este tema reemplazamos errores ocultos por excepciones específicas y mensajes claros. El código dejó de convertir cualquier fallo en un valor por defecto y empezó a comunicar problemas reales.
En el próximo tema usaremos herramientas como pytest, coverage, ruff, black y mypy para guiar refactorizaciones con más seguridad.