¿Para quién es este artículo? Para estudiantes de Python que quieren prepararse para su primera entrevista técnica, y para cualquier persona que quiera ver cómo se resuelve un problema real de programación con buenas prácticas.
Ejercicio 1 — Lógica y manipulación de datos
El primer ejercicio evalúa cómo manejas listas de diccionarios y si sabes usar las herramientas de Python de forma idiomática. Tenemos esta lista de usuarios:
users = [
{"id": 1, "name": "Ana", "age": 25, "active": True},
{"id": 2, "name": "Luis", "age": 17, "active": False},
{"id": 3, "name": "Carlos", "age": 30, "active": True},
]
Y debemos retornar: usuarios mayores de edad, usuarios activos, promedio de edad y el usuario más joven. La solución:
def analyze_users(users: list) -> dict:
"""
Analiza una lista de usuarios y retorna estadísticas clave.
Args:
users: Lista de diccionarios con datos de usuario.
Returns:
Diccionario con adultos, activos, promedio de edad y más joven.
"""
if not users:
return {
"adults": [],
"active_users": [],
"average_age": 0,
"youngest": None,
}
adults = [u for u in users if u["age"] >= 18]
active = [u for u in users if u["active"]]
avg_age = sum(u["age"] for u in users) / len(users)
youngest = min(users, key=lambda u: u["age"])
return {
"adults": adults,
"active_users": active,
"average_age": round(avg_age, 2),
"youngest": youngest,
}
# --- Uso ---
result = analyze_users(users)
print("Adultos:", result["adults"])
print("Activos:", result["active_users"])
print("Promedio edad:", result["average_age"])
print("Más joven:", result["youngest"])
¿Por qué así? Usamos list comprehensions en lugar de bucles for con append() — es más legible y más rápido en Python. La función min() con key=lambda es la forma idiomática de encontrar el mínimo en una lista de diccionarios. Y validamos la lista vacía al inicio para evitar errores de división por cero en el promedio.
Ejercicio 2 — Programación orientada a objetos: BankAccount
Este ejercicio evalúa si entiendes encapsulamiento, validación de datos y manejo de excepciones. Una clase mal diseñada aquí es una red flag inmediata para cualquier entrevistador.
class InsufficientFundsError(Exception):
"""Error personalizado para fondos insuficientes."""
pass
class BankAccount:
"""
Cuenta bancaria con operaciones básicas de depósito,
retiro y transferencia entre cuentas.
"""
def __init__(self, owner: str, balance: float = 0.0):
if not owner or not isinstance(owner, str):
raise ValueError("El nombre del titular no puede estar vacío.")
if balance < 0:
raise ValueError("El saldo inicial no puede ser negativo.")
self.owner = owner
self._balance = balance # atributo privado
@property
def balance(self) -> float:
return self._balance
def _validate_amount(self, amount: float) -> None:
"""Valida que el monto sea un número positivo."""
if not isinstance(amount, (int, float)):
raise TypeError(f"El monto debe ser numérico, se recibió: {type(amount)}")
if amount <= 0:
raise ValueError("El monto debe ser mayor que cero.")
def deposit(self, amount: float) -> float:
"""Deposita un monto en la cuenta. Retorna el nuevo saldo."""
self._validate_amount(amount)
self._balance += amount
print(f"✔ Depósito de ${amount:.2f} exitoso. Saldo: ${self._balance:.2f}")
return self._balance
def withdraw(self, amount: float) -> float:
"""Retira un monto de la cuenta. Retorna el nuevo saldo."""
self._validate_amount(amount)
if amount > self._balance:
raise InsufficientFundsError(
f"Fondos insuficientes. Saldo: ${self._balance:.2f}, "
f"solicitado: ${amount:.2f}"
)
self._balance -= amount
print(f"✔ Retiro de ${amount:.2f} exitoso. Saldo: ${self._balance:.2f}")
return self._balance
def transfer(self, other_account: "BankAccount", amount: float) -> None:
"""Transfiere un monto a otra cuenta BankAccount."""
if not isinstance(other_account, BankAccount):
raise TypeError("La cuenta destino debe ser una instancia de BankAccount.")
self.withdraw(amount)
other_account.deposit(amount)
print(f"✔ Transferencia de ${amount:.2f} a {other_account.owner} completada.")
def __repr__(self) -> str:
return f"BankAccount(owner='{self.owner}', balance={self._balance:.2f})"
# --- Uso ---
ana = BankAccount("Ana", balance=500)
luis = BankAccount("Luis", balance=100)
ana.deposit(200)
ana.withdraw(100)
ana.transfer(luis, 150)
try:
ana.withdraw(9999)
except InsufficientFundsError as e:
print(f"Error: {e}")
Puntos clave de esta solución: creamos una excepción personalizada InsufficientFundsError en lugar de usar un ValueError genérico — esto hace el manejo de errores más preciso. El balance usa un atributo privado _balance con una propiedad pública de solo lectura, evitando que se modifique directamente desde fuera. Y el método _validate_amount centraliza la validación para no repetir código en deposit y withdraw.
Ejercicio 3 — Consumo de API con manejo de errores
Consumir APIs es una habilidad fundamental para cualquier desarrollador backend. Lo que diferencia a un junior sólido no es saber hacer el request — es saber manejar todos los casos en que puede fallar.
import requests
from typing import Optional
API_URL = "https://jsonplaceholder.typicode.com/users"
def _parse_user(user: dict) -> dict:
"""Extrae solo los campos relevantes de un usuario."""
return {
"name": user.get("name", "N/A"),
"email": user.get("email", "N/A"),
"company": user.get("company", {}).get("name", "N/A"),
}
def fetch_users(email: Optional[str] = None) -> list[dict]:
"""
Obtiene usuarios de la API y retorna nombre, email y empresa.
Args:
email: Si se proporciona, filtra por ese email exacto.
Returns:
Lista de usuarios con name, email y company.
Raises:
requests.exceptions.Timeout: Si la API no responde a tiempo.
requests.exceptions.ConnectionError: Si no hay conexión.
ValueError: Si la respuesta no es JSON válido.
"""
try:
response = requests.get(API_URL, timeout=10)
response.raise_for_status() # lanza error si status >= 400
data = response.json()
if not isinstance(data, list):
raise ValueError("La API no retornó una lista de usuarios.")
users = [_parse_user(u) for u in data]
if email:
users = [u for u in users if u["email"].lower() == email.lower()]
return users
except requests.exceptions.Timeout:
print("Error: La API tardó demasiado en responder.")
return []
except requests.exceptions.ConnectionError:
print("Error: No se pudo conectar a la API. Verifica tu conexión.")
return []
except requests.exceptions.HTTPError as e:
print(f"Error HTTP: {e}")
return []
except (ValueError, KeyError) as e:
print(f"Error al procesar la respuesta: {e}")
return []
# --- Uso ---
todos = fetch_users()
print(f"Total usuarios: {len(todos)}")
print(todos[0])
# Buscar por email
resultado = fetch_users(email="Sincere@april.biz")
print(resultado)
¿Por qué separar _parse_user? Porque las funciones pequeñas con una sola responsabilidad son más fáciles de testear, leer y mantener. Si la estructura de la API cambia, solo tocas esa función. Notar que usamos .get() en lugar de acceso directo con [] para evitar KeyError si algún campo viene vacío.
Ejercicio 4 — Debugging: encuentra y corrige el error
Este es el código con el bug:
# Código con error
def calculate_total(items):
total = 0
for item in items:
total += item["price"] * item["quantity"]
return total
products = [
{"name": "Laptop", "price": "1000", "quantity": 2}, # ← price es STRING
{"name": "Mouse", "price": 50, "quantity": 1},
]
¿Cuál es el error? El precio del Laptop está guardado como string "1000" en lugar de número 1000. Cuando Python intenta multiplicar "1000" * 2, no da un error inmediato — Python repite el string dos veces, resultando en "10001000", no en 2000. El resultado total sería completamente incorrecto y difícil de detectar porque no lanza una excepción.
La corrección:
# Código corregido
def calculate_total(items: list[dict]) -> float:
"""
Calcula el total de una lista de productos.
Convierte price a float para manejar strings numéricos.
"""
if not items:
return 0.0
total = 0.0
for item in items:
try:
price = float(item["price"]) # conversión explícita
quantity = int(item["quantity"]) # conversión explícita
if price < 0 or quantity < 0:
raise ValueError(f"Valores negativos en: {item['name']}")
total += price * quantity
except (ValueError, KeyError) as e:
print(f"Advertencia — producto ignorado: {e}")
return round(total, 2)
# ¿Cómo evitar que vuelva a pasar?
# Opción A: validar los datos al recibirlos (dataclasses o Pydantic)
# Opción B: definir el tipo en el esquema de la base de datos
# Opción C: tests que cubran este caso con strings numéricos
Cómo evitar que vuelva a pasar: la solución más robusta para proyectos reales es usar Pydantic para validar la estructura de los datos de entrada. Si defines que price debe ser float, Pydantic lo convierte automáticamente si viene como string numérico, y lanza un error claro si no es convertible.
Ejercicio 5 — Testing con pytest
Escribir tests no es opcional — es lo que separa el código que "funciona en tu máquina" del código que funciona en producción. Aquí están los tests para la clase BankAccount:
# test_bank_account.py
import pytest
from bank_account import BankAccount, InsufficientFundsError
# ── Fixtures ──────────────────────────────────────────────────
@pytest.fixture
def account():
"""Cuenta con saldo inicial de 100 para cada test."""
return BankAccount("Ana", balance=100.0)
@pytest.fixture
def empty_account():
"""Cuenta sin saldo."""
return BankAccount("Luis")
# ── Tests de depósito ─────────────────────────────────────────
def test_deposit_increases_balance(account):
account.deposit(50)
assert account.balance == 150.0
def test_deposit_returns_new_balance(account):
result = account.deposit(200)
assert result == 300.0
def test_deposit_zero_raises_error(account):
with pytest.raises(ValueError, match="mayor que cero"):
account.deposit(0)
def test_deposit_negative_raises_error(account):
with pytest.raises(ValueError):
account.deposit(-50)
def test_deposit_string_raises_error(account):
with pytest.raises(TypeError):
account.deposit("100")
# ── Tests de retiro ───────────────────────────────────────────
def test_withdraw_decreases_balance(account):
account.withdraw(40)
assert account.balance == 60.0
def test_withdraw_exact_balance(account):
"""Debe permitir retirar exactamente el saldo disponible."""
account.withdraw(100)
assert account.balance == 0.0
def test_withdraw_insufficient_funds(account):
with pytest.raises(InsufficientFundsError):
account.withdraw(9999)
def test_withdraw_from_empty_account(empty_account):
with pytest.raises(InsufficientFundsError):
empty_account.withdraw(1)
def test_withdraw_negative_raises_error(account):
with pytest.raises(ValueError):
account.withdraw(-10)
# ── Tests de transferencia ────────────────────────────────────
def test_transfer_moves_balance(account, empty_account):
account.transfer(empty_account, 60)
assert account.balance == 40.0
assert empty_account.balance == 60.0
def test_transfer_insufficient_funds(empty_account, account):
with pytest.raises(InsufficientFundsError):
empty_account.transfer(account, 50)
Para ejecutar los tests, instala pytest y corre el comando:
pip install pytest
pytest test_bank_account.py -v
Buena práctica: los fixtures de pytest (@pytest.fixture) crean una instancia fresca de la clase antes de cada test, evitando que el estado de un test afecte al siguiente. Nunca compartas estado entre tests.
Resumen: qué hace destacar esta solución
| Buena práctica | Dónde se aplica |
|---|---|
| Excepciones personalizadas | InsufficientFundsError en BankAccount |
| Validación de inputs | _validate_amount(), listas vacías, tipos |
| Funciones pequeñas | _parse_user(), _validate_amount() |
| Type hints | Todos los métodos y funciones |
| Docstrings | Todas las clases y funciones públicas |
| Manejo de errores de red | Timeout, ConnectionError, HTTPError |
| Tests con fixtures | Estado limpio por cada test con pytest |
Si estás preparándote para una entrevista técnica de Python, practica resolviendo ejercicios como estos y luego revisa tu propio código con ojo crítico: ¿validé todos los inputs? ¿Manejé todos los errores posibles? ¿Mis funciones hacen una sola cosa? ¿Escribí al menos un test por caso de uso? Esas cuatro preguntas son el filtro que separa el código junior del código que realmente funciona en producción. Si tienes dudas sobre alguno de los ejercicios, déjalas en los comentarios.
Publicar un comentario