41. Depuración y pruebas de tu código
Escribir código es solo la mitad de la batalla. La otra mitad es asegurarte de que tu código funcione correctamente y encontrar problemas cuando no lo hace. Todo programador, desde principiantes hasta expertos, escribe código con bugs. La diferencia es que los programadores con experiencia han desarrollado enfoques sistemáticos para encontrar y corregir esos bugs.
En este capítulo, aprenderás técnicas prácticas de depuración(debugging) que te ayudan a entender qué está haciendo realmente tu código, localizar problemas rápidamente y verificar que tu código funcione como se pretende. Estas habilidades te harán un programador más seguro y eficaz.
41.1) Leer tracebacks para localizar errores (Repaso rápido)
Como aprendimos en el Capítulo 24, Python proporciona mensajes de error detallados llamados tracebacks cuando algo sale mal. Repasemos cómo leerlos de forma efectiva, ya que esta es tu primera línea de defensa cuando depuras.
41.1.1) La anatomía de un traceback
Cuando Python encuentra un error, te muestra exactamente dónde ocurrió el problema y qué tipo de error fue. Aquí tienes un traceback típico:
def calculate_average(numbers):
total = sum(numbers)
count = len(numbers)
return total / count
def process_student_grades(grades):
average = calculate_average(grades)
return f"Average: {average:.1f}"
# Esto causará un error
student_grades = []
result = process_student_grades(student_grades)
print(result)Output:
Traceback (most recent call last):
File "grades.py", line 12, in <module>
result = process_student_grades(student_grades)
File "grades.py", line 7, in process_student_grades
average = calculate_average(grades)
File "grades.py", line 4, in calculate_average
return total / count
~~~~~~^~~~~~~
ZeroDivisionError: division by zeroDesglosemos lo que este traceback nos dice:
Leyendo de abajo hacia arriba:
- El tipo de error y el mensaje (abajo):
ZeroDivisionError: division by zeronos dice exactamente qué salió mal - La línea exacta donde ocurrió el error:
return total / counten la línea 4 - La cadena de llamadas que muestra cómo llegamos ahí: empezó en la línea 12, pasó por la línea 7, terminó en la línea 4
41.1.2) Usar tracebacks para encontrar la causa raíz
El traceback te muestra el síntoma (dónde ocurrió el error), pero necesitas encontrar la causa (por qué ocurrió). Sigamos la pista del problema:
# El error ocurre aquí
return total / count # count es 0
# Pero el problema real está aquí
student_grades = [] # Lista vacía pasada a la funciónLa división por cero ocurre porque pasamos una lista vacía. El traceback señala la línea 4, pero la corrección necesita suceder antes—ya sea validando la entrada o manejando el caso de lista vacía:
def calculate_average(numbers):
"""Return the average of numbers, or None if the list is empty."""
if not numbers:
return None
return sum(numbers) / len(numbers)
def process_student_grades(grades):
"""Process student grades and return a formatted string."""
average = calculate_average(grades)
if average is None:
return "No grades to process"
return f"Average: {average:.1f}"
# Ahora esto funciona de forma segura
student_grades = []
result = process_student_grades(student_grades)
print(result) # Output: No grades to process
# Y esto también funciona
student_grades = [85, 92, 78, 90]
result = process_student_grades(student_grades)
print(result) # Output: Average: 86.2Puntos clave:
- Lee los tracebacks de abajo hacia arriba
- La ubicación del error (síntoma) no siempre es la causa raíz
- Valida entradas temprano para prevenir errores más tarde
- Usa programación defensiva (
.get(), comprobaciones de longitud) para un código más seguro
Distintos tipos de errores producen distintos tracebacks, pero el proceso de lectura siempre es el mismo: empieza en la parte inferior para ver qué salió mal, y luego sigue hacia arriba para entender cómo llegaste ahí. Si necesitas un repaso de tipos de excepciones específicos, vuelve al Capítulo 24.
Ahora que puedes leer tracebacks de forma efectiva, aprendamos cómo rastrear tu código mentalmente para entender qué está haciendo paso a paso.
41.2) Trazar la ejecución del código mentalmente
A veces te encuentras con un bug pero no puedes ejecutar el código de inmediato—quizá estás revisando código en papel, leyendo el pull request de otra persona, o intentando entender por qué una función(function) se comporta de forma inesperada. En estas situaciones, la ejecución mental—recorrer el código línea por línea en tu cabeza, siguiendo qué sucede con cada variable—se vuelve invaluable.
Incluso los programadores con experiencia usan esta técnica con regularidad. Antes de añadir sentencias print o ejecutar un depurador(debugger), a menudo recorren mentalmente unas cuantas iteraciones para formular una hipótesis sobre dónde podría estar el problema. Esto es más rápido que el ensayo y error y te ayuda a entender tu código con más profundidad.
La ejecución mental es especialmente útil cuando:
- Lees código desconocido para entender qué hace
- Revisas funciones pequeñas (5-15 líneas) antes de ejecutarlas
- Depuras errores de lógica donde el código se ejecuta pero produce resultados incorrectos
- Entiendes el comportamiento de un bucle(loop) cuando el patrón no es obvio de inmediato
- Revisión de código donde no puedes ejecutar el código tú mismo fácilmente
Para código más grande o más complejo, combinarás el trazado mental con otras técnicas que cubriremos más adelante en este capítulo. Pero dominar esta habilidad te hará un depurador mucho más eficaz.
41.2.1) El proceso de ejecución mental
Cuando ejecutas código mentalmente, actúas como el intérprete de Python, siguiendo las mismas reglas que Python sigue. Practiquemos con un ejemplo sencillo:
def find_maximum(numbers):
max_value = numbers[0]
for num in numbers:
if num > max_value:
max_value = num
return max_value
result = find_maximum([3, 7, 2, 9, 5])
print(result) # Output: 9Así es como se rastrea este código:
Traza paso a paso:
Initial state:
numbers = [3, 7, 2, 9, 5]
max_value = 3 (numbers[0])
Iteration 1: num = 3
Check: 3 > 3? → False
max_value remains 3
Iteration 2: num = 7
Check: 7 > 3? → True
max_value = 7 ✓
Iteration 3: num = 2
Check: 2 > 7? → False
max_value remains 7
Iteration 4: num = 9
Check: 9 > 7? → True
max_value = 9 ✓
Iteration 5: num = 5
Check: 5 > 9? → False
max_value remains 9
Return: 941.2.2) Crear una tabla de seguimiento
Para código más complejo, crea una tabla de seguimiento (trace table) que muestre cómo cambian las variables con el tiempo. Esto es especialmente útil para bucles(loop) y estructuras anidadas:
def calculate_running_totals(numbers):
totals = []
running_sum = 0
for num in numbers:
running_sum += num
totals.append(running_sum)
return totals
result = calculate_running_totals([10, 20, 30, 40])
print(result) # Output: [10, 30, 60, 100]Tabla de seguimiento:
La tabla muestra el estado de las variables en cada paso. Observa cómo running_sum cambia de "antes" a "después" de cada suma:
| Iteration | num | running_sum (before) | running_sum (after) | totals |
|---|---|---|---|---|
| Start | - | 0 | 0 | [] |
| 1 | 10 | 0 | 10 | [10] |
| 2 | 20 | 10 | 30 | [10, 30] |
| 3 | 30 | 30 | 60 | [10, 30, 60] |
| 4 | 40 | 60 | 100 | [10, 30, 60, 100] |
Crear esta tabla te ayuda a ver exactamente cómo fluyen los datos por tu código. Si la salida no coincide con lo que esperas, puedes identificar exactamente dónde las cosas salen mal.
41.2.3) Trazar la lógica condicional
Las sentencias condicionales requieren atención cuidadosa a qué ramas se ejecutan. Sigamos un ejemplo más complejo:
def categorize_grade(score):
if score >= 90:
category = "Excellent"
bonus = 10
elif score >= 80:
category = "Good"
bonus = 5
elif score >= 70:
category = "Satisfactory"
bonus = 0
else:
category = "Needs Improvement"
bonus = 0
final_score = score + bonus
return category, final_score
result = categorize_grade(85)
print(result) # Output: ('Good', 90)Traza mental para score = 85:
- Comprueba
85 >= 90→ False, omite el primer bloque - Comprueba
85 >= 80→ True, entra en el segundo bloque - Asigna
category = "Good"ybonus = 5 - Omite los bloques elif y else restantes (ya se encontró una coincidencia)
- Calcula
final_score = 85 + 5 = 90 - Devuelve
("Good", 90)
41.2.4) Trazar llamadas y retornos de funciones
Cuando funciones llaman a otras funciones, necesitas seguir el call stack—la secuencia de llamadas de funciones y sus variables locales:
def calculate_tax(amount, rate):
tax = amount * rate
return tax
def calculate_total(price, quantity, tax_rate):
subtotal = price * quantity
tax = calculate_tax(subtotal, tax_rate)
total = subtotal + tax
return total
result = calculate_total(50, 3, 0.08)
print(f"Total: ${result:.2f}") # Output: Total: $162.00Traza con call stack:
┌─ calculate_total(50, 3, 0.08)
│ price = 50, quantity = 3, tax_rate = 0.08
│ subtotal = 150
│
│ ┌─ calculate_tax(150, 0.08)
│ │ amount = 150, rate = 0.08
│ │ tax = 12.0
│ │ return 12.0
│ └─
│
│ tax = 12.0 (from calculate_tax)
│ total = 162.0
│ return 162.0
└─
result = 162.0Esta traza paso a paso muestra exactamente cómo fluyen los datos entre funciones. Al depurar, si el resultado final es incorrecto, puedes rastrear hacia atrás para ver qué función produjo un valor intermedio incorrecto.
El trazado mental es potente, pero para código complejo puede ser tedioso. En la próxima sección, aprenderemos a usar sentencias print de forma estratégica para ver qué está ocurriendo realmente mientras se ejecuta tu código, lo cual a menudo es más rápido y más fiable que la ejecución mental por sí sola.
41.3) Depurar con print: f"{var=}" y repr()
Aunque la ejecución mental funciona bien para funciones pequeñas, se vuelve poco práctica para código más grande o más complejo. Cuando no estás seguro de qué está pasando dentro de un bucle(loop), o cuando un cálculo produce resultados inesperados, la forma más rápida de investigar suele ser añadir sentencias print() estratégicas.
La depuración con print (print debugging) tiene algunas ventajas sobre otras técnicas:
- No se necesitan herramientas especiales: Funciona en cualquier entorno de Python
- Rápida de implementar: Añade una sentencia print en segundos
- Salida clara: Ves exactamente lo que pediste
- Fácil de eliminar: Borra los prints cuando termines
Los desarrolladores profesionales usan la depuración con print todo el tiempo—no es una técnica de "principiante". Aprendamos a usarla de forma efectiva.
41.3.1) Depuración básica con print
El enfoque más simple de depuración es imprimir valores de variables en puntos clave de tu código:
def process_order(items, discount_rate):
print(f"Starting process_order")
print(f"Items: {items}")
print(f"Discount rate: {discount_rate}")
subtotal = sum(item['price'] * item['quantity'] for item in items)
print(f"Subtotal: {subtotal}")
discount = subtotal * discount_rate
print(f"Discount amount: {discount}")
total = subtotal - discount
print(f"Final total: {total}")
return total
order_items = [
{'name': 'Book', 'price': 25.99, 'quantity': 2},
{'name': 'Pen', 'price': 3.50, 'quantity': 5}
]
result = process_order(order_items, 0.10)Output:
Starting process_order
Items: [{'name': 'Book', 'price': 25.99, 'quantity': 2}, {'name': 'Pen', 'price': 3.5, 'quantity': 5}]
Discount rate: 0.1
Subtotal: 69.47999999999999
Discount amount: 6.9479999999999995
Final total: 62.53199999999999Estas sentencias print te muestran el flujo de ejecución y los valores en cada paso. Si el resultado final es incorrecto, puedes ver exactamente dónde el cálculo se desvió.
41.3.2) Usar f"{var=}" para una inspección rápida
Python 3.8 introdujo una sintaxis conveniente para depuración: f"{var=}". Esto imprime tanto el nombre de la variable como su valor:
def calculate_compound_interest(principal, rate, years):
# Enfoque tradicional
print(f"principal: {principal}")
print(f"rate: {rate}")
print(f"years: {years}")
# Enfoque más limpio con f"{var=}"
print(f"{principal=}")
print(f"{rate=}")
print(f"{years=}")
# Puedes usar expresiones, no solo variables
print(f"{principal * rate=}")
print(f"{(1 + rate) ** years=}")
amount = principal * (1 + rate) ** years
print(f"{amount=}")
return amount
result = calculate_compound_interest(1000, 0.05, 10)Output:
principal: 1000
rate: 0.05
years: 10
principal=1000
rate=0.05
years=10
principal * rate=50.0
(1 + rate) ** years=1.628894626777442
amount=1628.89462677744241.3.3) Usar repr() para ver la forma real de los datos
A veces, lo que ves impreso no es lo que crees que es. La función repr() te muestra la representación exacta de un objeto, incluyendo caracteres ocultos:
# Estas cadenas parecen iguales cuando se imprimen
text1 = "Hello"
text2 = "Hello\n" # Tiene un salto de línea al final
print("Using print():")
print(f"text1: {text1}")
print(f"text2: {text2}")
print("\nUsing repr():")
print(f"text1: {repr(text1)}")
print(f"text2: {repr(text2)}")Output:
Using print():
text1: Hello
text2: Hello
Using repr():
text1: 'Hello'
text2: 'Hello\n'La salida de repr() muestra que text2 tiene un carácter de salto de línea oculto. Esto es crucial al depurar el procesamiento de cadenas:
def clean_user_input():
# La entrada del usuario a menudo tiene espacios en blanco ocultos
username = input("Enter username: ") # El usuario escribe "Alice "
print(f"Username with print(): {username}")
print(f"Username with repr(): {repr(username)}")
# Limpia la entrada
cleaned = username.strip()
print(f"Cleaned with repr(): {repr(cleaned)}")
return cleanedSi un usuario escribe "Alice" seguido de espacios y pulsa Enter, podrías ver:
Output:
Enter username: Alice
Username with print(): Alice
Username with repr(): 'Alice '
Cleaned with repr(): 'Alice'La salida de repr() revela los espacios finales que print() no muestra claramente.
Cuándo usar repr() vs str():
repr() está diseñado para desarrolladores—muestra la representación de cadena "oficial" que podría recrear el objeto. str() (que print() usa por defecto) está diseñado para usuarios finales—muestra una versión legible y amigable.
Para depurar, repr() suele ser más útil porque revela la estructura real de tus datos.
41.3.4) Colocación estratégica de prints
No disperses sentencias print por todas partes. Colócalas de forma estratégica:
def calculate_shipping_cost(weight, distance, express=False):
print(f"=== calculate_shipping_cost called ===")
print(f"Input: {weight=}, {distance=}, {express=}")
# Calcular el coste base
base_rate = 0.50
base_cost = weight * distance * base_rate
print(f"Calculated: {base_cost=}")
# Aplicar recargo de envío exprés
if express:
surcharge = base_cost * 0.50
print(f"Express surcharge: {surcharge=}")
total = base_cost + surcharge
else:
print("No express surcharge")
total = base_cost
print(f"Final: {total=}")
print(f"=== calculate_shipping_cost returning ===\n")
return total
# Probar diferentes escenarios
cost1 = calculate_shipping_cost(10, 500, express=True)
cost2 = calculate_shipping_cost(5, 200, express=False)Output:
=== calculate_shipping_cost called ===
Input: weight=10, distance=500, express=True
Calculated: base_cost=2500.0
Express surcharge: surcharge=1250.0
Final: total=3750.0
=== calculate_shipping_cost returning ===
=== calculate_shipping_cost called ===
Input: weight=5, distance=200, express=False
Calculated: base_cost=500.0
No express surcharge
Final: total=500.0
=== calculate_shipping_cost returning ===Los marcadores claros (===) y la salida organizada hacen que sea fácil seguir el flujo de ejecución.
41.3.5) Eliminar prints de depuración
Una vez que hayas encontrado y corregido el bug, recuerda eliminar tus prints de depuración. Aquí tienes algunas estrategias:
Estrategia 1: Usa un prefijo distintivo
# Fácil de encontrar y eliminar con buscar/reemplazar
print(f"DEBUG: {total=}")
print(f"DEBUG: {items=}")Estrategia 2: Usa un flag de depuración
DEBUG = True
def calculate_total(items):
if DEBUG:
print(f"Processing {len(items)} items")
total = sum(item['price'] for item in items)
if DEBUG:
print(f"{total=}")
return total
# Desactiva toda la salida de depuración de una vez
DEBUG = FalseEstrategia 3: Coméntalos pero consérvalos
def process_data(data):
# print(f"DEBUG: {data=}") # Útil para futuras depuraciones
result = transform(data)
# print(f"DEBUG: {result=}")
return resultPara un registro (logging) más sofisticado que puedas dejar en código de producción, Python tiene un módulo logging, pero sentencias print simples son perfectas para depuración rápida durante el desarrollo.
La depuración con print te muestra los valores de las variables, pero a veces necesitas entender la estructura de un objeto—qué métodos tiene, qué tipo es y qué puede hacer. En la próxima sección, aprenderemos a inspeccionar objetos usando type() y dir().
41.4) Inspeccionar objetos: type() y dir()
La depuración con print te muestra los valores de tus variables, pero a veces el problema no es el valor—es el tipo de objeto con el que estás trabajando. Puede que esperes una lista(list) pero recibas una cadena (string), o estás trabajando con un objeto desconocido y no sabes qué métodos soporta.
Python proporciona herramientas integradas para inspeccionar objetos: type() te dice qué tipo de objeto tienes, y dir() te muestra qué operaciones soporta. Estas funciones son esenciales cuando:
- Depuras errores relacionados con tipos (TypeError, AttributeError)
- Trabajas con bibliotecas o APIs desconocidas
- Entiendes objetos devueltos por código de terceros
- Verificas que tu código reciba los tipos esperados
Aprendamos a usar estas herramientas de inspección de forma efectiva.
41.4.1) Usar type() para identificar tipos de objetos
La función type() te dice exactamente qué tipo de objeto tienes. Esto es crucial al depurar errores relacionados con tipos:
def process_data(data):
print(f"Received data: {data}")
print(f"Data type: {type(data)}")
if isinstance(data, list):
print("Processing as list")
return sum(data)
elif isinstance(data, dict):
print("Processing as dictionary")
return sum(data.values())
else:
print("Unexpected type!")
return None
# Probar con distintos tipos
result1 = process_data([10, 20, 30])
print(f"Result: {result1}\n")
result2 = process_data({'a': 10, 'b': 20, 'c': 30})
print(f"Result: {result2}\n")
result3 = process_data("123")
print(f"Result: {result3}")Output:
Received data: [10, 20, 30]
Data type: <class 'list'>
Processing as list
Result: 60
Received data: {'a': 10, 'b': 20, 'c': 30}
Data type: <class 'dict'>
Processing as dictionary
Result: 60
Received data: 123
Data type: <class 'str'>
Unexpected type!
Result: None41.4.2) Depurar confusión de tipos
La confusión de tipos es una fuente común de bugs, especialmente cuando trabajas con funciones que pueden recibir datos de múltiples fuentes—entrada del usuario, lectura de archivos, respuestas de API u otras funciones. Puedes esperar una lista de números pero recibir accidentalmente una cadena, o esperar un diccionario pero obtener una lista.
Usar type() ayuda a identificar cuándo tienes el tipo equivocado. Al imprimir el tipo temprano en tu función, puedes detectar inmediatamente incongruencias de tipo antes de que causen mensajes de error confusos más adelante en tu código:
def calculate_average(numbers):
print(f"{type(numbers)=}")
print(f"{numbers=}") # Mostrar lo que realmente recibimos
# Esto fallará si numbers no es una lista de números
total = sum(numbers)
count = len(numbers)
return total / count
# Error común: se olvidó convertir la cadena en lista
scores = "85" # Debería ser [85] o simplemente 85
try:
avg = calculate_average(scores)
print(f"Average: {avg}")
except TypeError as e:
print(f"TypeError: {e}")
print(f"Expected list of numbers, got {type(scores)}")
print(f"The string contains: {repr(scores)}")Output:
type(numbers)=<class 'str'>
numbers='85'
TypeError: unsupported operand type(s) for +: 'int' and 'str'
Expected list of numbers, got <class 'str'>
The string contains: '85'La comprobación con type() revela de inmediato el problema: pasamos una cadena cuando necesitábamos una lista. Sin esta salida de depuración, podrías haber perdido tiempo intentando entender por qué sum() falló, cuando el verdadero problema es que el tipo de dato equivocado entró a la función en primer lugar.
41.4.3) Usar dir() para descubrir métodos disponibles
Al trabajar con objetos desconocidos—ya sea de una biblioteca que estás aprendiendo, una respuesta de API, o incluso tipos integrados de Python—a menudo necesitas saber: "¿Qué puedo hacer con este objeto?" La función dir() responde a esta pregunta listando todos los atributos y métodos disponibles en un objeto.
Esto es especialmente valioso cuando:
- Estás explorando una biblioteca nueva y quieres ver qué métodos ofrece un objeto
- Recibes un objeto de código de terceros y necesitas entender sus capacidades
- Has olvidado el nombre exacto de un método que quieres usar
- Estás depurando y quieres verificar que un objeto tiene los métodos que esperas
Exploremos qué métodos tiene una cadena:
# Explorar qué métodos tiene una cadena
text = "Python Programming"
print(f"Type: {type(text)}")
print(f"\nAvailable string methods (showing first 10):")
methods = [m for m in dir(text) if not m.startswith('_')]
for method in methods[:10]: # Mostrar los primeros 10
print(f" {method}")
print(f" ... and {len(methods) - 10} more")Output:
Type: <class 'str'>
Available string methods (showing first 10):
capitalize
casefold
center
count
encode
endswith
expandtabs
find
format
format_map
... and 37 moreAhora puedes ver todas las operaciones disponibles en las cadenas. Si no estabas seguro de si las cadenas tenían un método count o un método endswith, dir() te muestra que existen. Luego puedes usar la función help() de Python para aprender más sobre cualquier método específico:
# Learn more about a specific method
help(text.count)Esto te mostrará la documentación del método count:
Help on built-in function count:
count(sub[, start[, end]], /) method of builtins.str instance
Return the number of non-overlapping occurrences of substring sub in string S[start:end].
Optional arguments start and end are interpreted as in slice notation.La función dir() es como tener documentación integrada directamente en Python—te muestra qué es posible con cualquier objeto con el que estés trabajando.
41.4.4) Inspeccionar objetos personalizados
Al trabajar con clases personalizadas, type() y dir() te ayudan a entender con qué estás tratando. Además, Python proporciona hasattr() para comprobar si un objeto tiene un atributo específico antes de intentar acceder a él—esto evita excepciones AttributeError.
class Student:
def __init__(self, name, grade):
self.name = name
self.grade = grade
def get_status(self):
return "Passing" if self.grade >= 60 else "Failing"
student = Student("Alice", 85)
print(f"Object type: {type(student)}")
print(f"\nAvailable attributes and methods:")
for attr in dir(student):
if not attr.startswith('_'):
print(f" {attr}")
# Comprobar si existen atributos específicos
print(f"\nHas 'name' attribute: {hasattr(student, 'name')}")
print(f"Has 'age' attribute: {hasattr(student, 'age')}")
print(f"Has 'get_status' method: {hasattr(student, 'get_status')}")
# Ahora podemos acceder de forma segura a atributos que sabemos que existen
if hasattr(student, 'name'):
print(f"\nStudent name: {student.name}")
else:
print("\nNo name attribute found")
if hasattr(student, 'get_status'):
print(f"Status: {student.get_status()}")
else:
print("No get_status method found")
# Esto evita errores como este:
# print(student.age) # Would raise AttributeError!Output:
Object type: <class '__main__.Student'>
Available attributes and methods:
get_status
grade
name
Has 'name' attribute: True
Has 'age' attribute: False
Has 'get_status' method: True
Student name: Alice
Status: PassingLa función hasattr() es esencial para escribir código defensivo—código que comprueba si las operaciones son seguras antes de realizarlas. La función devuelve True si el atributo existe, False si no existe—permitiéndote tomar decisiones antes de intentar acceder a atributos. Esto es especialmente importante al trabajar con objetos de bibliotecas externas o entrada del usuario donde no puedes garantizar qué atributos estarán presentes.
41.4.5) Usar getattr() para acceso seguro a atributos
Cuando no estás seguro de si un atributo existe, usa getattr() con un valor por defecto:
def display_student_info(student):
"""Safely display student info even if some attributes are missing."""
print(f"Type: {type(student)}")
# Acceso seguro a atributos con valores por defecto
name = getattr(student, 'name', 'Unknown')
grade = getattr(student, 'grade', 0)
age = getattr(student, 'age', 'Not specified')
print(f"Name: {name}")
print(f"Grade: {grade}")
print(f"Age: {age}")
# Comprobar si el método existe antes de llamarlo
if hasattr(student, 'get_status'):
status = student.get_status()
print(f"Status: {status}")
# Usando la misma clase Student de arriba
student = Student("Bob", 72)
display_student_info(student)Output:
Type: <class '__main__.Student'>
Name: Bob
Grade: 72
Age: Not specified
Status: PassingEste enfoque evita excepciones AttributeError cuando trabajas con objetos que podrían no tener todos los atributos esperados. La función getattr() es especialmente útil cuando:
- Trabajas con objetos de APIs externas que podrían tener versiones diferentes
- Manejas atributos opcionales en tus propias clases
- Construyes código defensivo que maneja con elegancia datos faltantes
Entender qué tipo de objeto tienes y qué métodos soporta es crucial para depurar. Pero a veces necesitas verificar no solo que tu código se ejecuta, sino que produce los resultados correctos. En la próxima sección, aprenderemos a usar sentencias assert para probar tus suposiciones y detectar bugs temprano.
41.5) Probar con sentencias assert
Hemos aprendido cómo depurar código cuando las cosas van mal—leer tracebacks, trazar la ejecución mentalmente, usar sentencias print e inspeccionar objetos. Pero hay un enfoque mejor que arreglar bugs después de que aparecen: prevenirlos en primer lugar mediante pruebas.
La sentencia assert es la herramienta de pruebas más simple de Python. Te permite verificar que tu código se comporta correctamente comprobando suposiciones en puntos críticos. Cuando una aserción falla, Python te dice inmediatamente exactamente qué salió mal y dónde, haciendo que sea mucho más fácil detectar bugs temprano—a menudo antes incluso de ejecutar tu programa principal.
Las aserciones son especialmente valiosas para:
- Verificar que las funciones produzcan resultados esperados
- Comprobar que las entradas cumplen tus requisitos
- Probar casos límite que podrían romper tu código
- Documentar suposiciones de las que depende tu código
Piensa en las aserciones como comprobaciones automatizadas que verifican continuamente que tu código está funcionando como se pretende. Aprendamos a usarlas de forma efectiva.
41.5.1) Qué hace assert
Una sentencia assert comprueba si una condición es verdadera. Si la condición es verdadera, no pasa nada—el código continúa con normalidad. Si es falsa, Python lanza un AssertionError y detiene la ejecución.
Sintaxis:
assert condition, "Optional error message"condition: Cualquier expresión que evalúe a True o False"Optional error message": Texto útil mostrado cuando la aserción falla
Así es como funciona en la práctica:
# Aserciones simples
x = 10
assert x > 0 # Pasa en silencio (x efectivamente es > 0)
assert x < 5 # ¡Falla! Lanza AssertionError
# Con mensajes de error (¡mucho más útil!)
assert x > 0, f"x must be positive, got {x}"
assert x < 5, f"x must be less than 5, got {x}" # Falla con un mensaje claroAhora veamos aserciones en una función real:
def calculate_discount(price, discount_percent):
# Verificar que las entradas son válidas
assert price >= 0, "Price cannot be negative"
assert 0 <= discount_percent <= 100, "Discount must be between 0 and 100"
discount_amount = price * (discount_percent / 100)
final_price = price - discount_amount
# Verificar que la salida tiene sentido
assert final_price >= 0, "Final price cannot be negative"
return final_price
# Entradas válidas funcionan bien
result = calculate_discount(100, 20)
print(f"Price after 20% discount: ${result}") # Output: Price after 20% discount: $80.0
# Entradas inválidas disparan aserciones
try:
result = calculate_discount(-50, 20)
except AssertionError as e:
print(f"Assertion failed: {e}") # Output: Assertion failed: Price cannot be negative
try:
result = calculate_discount(100, 150)
except AssertionError as e:
print(f"Assertion failed: {e}") # Output: Assertion failed: Discount must be between 0 and 10041.5.2) Usar aserciones para verificar el comportamiento de una función
Las aserciones son excelentes para probar que las funciones producen resultados esperados:
def calculate_average(numbers):
if not numbers:
return 0.0
return sum(numbers) / len(numbers)
# Probar con varias entradas
result = calculate_average([10, 20, 30])
assert result == 20.0, f"Expected 20.0, got {result}"
print(f"Test 1 passed: average of [10, 20, 30] = {result}")
result = calculate_average([5, 5, 5, 5])
assert result == 5.0, f"Expected 5.0, got {result}"
print(f"Test 2 passed: average of [5, 5, 5, 5] = {result}")
result = calculate_average([])
assert result == 0.0, f"Expected 0.0 for empty list, got {result}"
print(f"Test 3 passed: average of [] = {result}")
result = calculate_average([100])
assert result == 100.0, f"Expected 100.0, got {result}"
print(f"Test 4 passed: average of [100] = {result}")Output:
Test 1 passed: average of [10, 20, 30] = 20.0
Test 2 passed: average of [5, 5, 5, 5] = 5.0
Test 3 passed: average of [] = 0.0
Test 4 passed: average of [100] = 100.0Si alguna aserción falla, sabes inmediatamente qué caso de prueba reveló el problema.
41.5.3) Probar casos límite
Los casos límite son entradas en los límites de lo que tu función debería manejar. Probarlos revela bugs que entradas normales podrían pasar por alto:
def get_first_and_last(items):
"""Return the first and last items from a sequence."""
assert len(items) > 0, "Cannot get first and last from empty sequence"
return items[0], items[-1]
# Probar el caso normal
result = get_first_and_last([1, 2, 3, 4, 5])
assert result == (1, 5), f"Expected (1, 5), got {result}"
print(f"Normal case: {result}")
# Probar el caso límite: un solo elemento
result = get_first_and_last([42])
assert result == (42, 42), f"Expected (42, 42), got {result}"
print(f"Single item: {result}")
# Probar el caso límite: dos elementos
result = get_first_and_last([10, 20])
assert result == (10, 20), f"Expected (10, 20), got {result}"
print(f"Two items: {result}")
# Probar el caso límite: secuencia vacía (debería fallar)
try:
result = get_first_and_last([])
print("ERROR: Should have raised AssertionError for empty list")
except AssertionError as e:
print(f"Empty list correctly rejected: {e}")Output:
Normal case: (1, 5)
Single item: (42, 42)
Two items: (10, 20)
Empty list correctly rejected: Cannot get first and last from empty sequence41.5.4) Probar transformaciones de datos
Cuando tu función transforma datos, afirma que la transformación es correcta:
def remove_duplicates(items):
"""Remove duplicates while preserving order."""
seen = set()
result = []
for item in items:
if item not in seen:
seen.add(item)
result.append(item)
return result
# Probar eliminación básica de duplicados
input_data = [1, 2, 2, 3, 1, 4, 3, 5]
result = remove_duplicates(input_data)
expected = [1, 2, 3, 4, 5]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 1 passed: {input_data} -> {result}")
# Probar que el orden se conserva
input_data = [3, 1, 2, 1, 3, 2]
result = remove_duplicates(input_data)
expected = [3, 1, 2]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 2 passed: {input_data} -> {result}")
# Probar sin duplicados
input_data = [1, 2, 3, 4, 5]
result = remove_duplicates(input_data)
assert result == input_data, f"Expected {input_data}, got {result}"
print(f"Test 3 passed: {input_data} -> {result}")
# Probar con todos duplicados
input_data = [5, 5, 5, 5]
result = remove_duplicates(input_data)
expected = [5]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 4 passed: {input_data} -> {result}")Output:
Test 1 passed: [1, 2, 2, 3, 1, 4, 3, 5] -> [1, 2, 3, 4, 5]
Test 2 passed: [3, 1, 2, 1, 3, 2] -> [3, 1, 2]
Test 3 passed: [1, 2, 3, 4, 5] -> [1, 2, 3, 4, 5]
Test 4 passed: [5, 5, 5, 5] -> [5]41.5.5) Crear una función de prueba sencilla
A medida que tu código crece, dispersar sentencias assert por tu código principal se vuelve desordenado y difícil de gestionar. Un enfoque mejor es organizar tus pruebas en funciones de prueba dedicadas. Esto separa el código de pruebas del código de producción y hace que sea fácil ejecutar todas tus pruebas de una vez.
¿Por qué usar funciones de prueba dedicadas?
- Organización: Todas las pruebas para una función están en un solo lugar
- Reutilización: Ejecuta pruebas cada vez que cambies el código
- Documentación: Las pruebas muestran cómo debería comportarse la función
- Depuración(debugging): Cuando una prueba falla, sabes inmediatamente qué escenario se rompió
- Flujo de trabajo de desarrollo: Prueba primero, luego implementa o corrige el código
Veámoslo en la práctica:
def calculate_grade(score):
"""Convert numeric score to letter grade."""
if score >= 90:
return 'A'
elif score >= 80:
return 'B'
elif score >= 70:
return 'C'
elif score >= 60:
return 'D'
else:
return 'F'
def test_calculate_grade():
"""Test the calculate_grade function.
This function tests all expected behaviors:
- Each grade range (A, B, C, D, F)
- Boundary values (90, 80, 70, 60)
- Edge cases (just below each boundary)
"""
print("Testing calculate_grade...")
# Test A grades
assert calculate_grade(95) == 'A', "95 should be A"
assert calculate_grade(90) == 'A', "90 should be A (boundary)"
print(" ✓ A grades: passed")
# Test B grades
assert calculate_grade(85) == 'B', "85 should be B"
assert calculate_grade(80) == 'B', "80 should be B (boundary)"
print(" ✓ B grades: passed")
# Test C grades
assert calculate_grade(75) == 'C', "75 should be C"
assert calculate_grade(70) == 'C', "70 should be C (boundary)"
print(" ✓ C grades: passed")
# Test D grades
assert calculate_grade(65) == 'D', "65 should be D"
assert calculate_grade(60) == 'D', "60 should be D (boundary)"
print(" ✓ D grades: passed")
# Test F grades
assert calculate_grade(55) == 'F', "55 should be F"
assert calculate_grade(0) == 'F', "0 should be F"
print(" ✓ F grades: passed")
# Test boundary edge cases (one below each threshold)
assert calculate_grade(89) == 'B', "89 should be B (just below A)"
assert calculate_grade(79) == 'C', "79 should be C (just below B)"
assert calculate_grade(69) == 'D', "69 should be D (just below C)"
assert calculate_grade(59) == 'F', "59 should be F (just below D)"
print(" ✓ Boundary cases: passed")
print("All tests passed! ✓\n")
# Run the tests
test_calculate_grade()
# Now you can confidently use the function
student_score = 87
grade = calculate_grade(student_score)
print(f"Student score {student_score} = Grade {grade}")Output:
Testing calculate_grade...
✓ A grades: passed
✓ B grades: passed
✓ C grades: passed
✓ D grades: passed
✓ F grades: passed
✓ Boundary cases: passed
All tests passed! ✓
Student score 87 = Grade BBeneficios de este enfoque:
- Organización clara de las pruebas: Puedes ver todos los casos de prueba de un vistazo
- Fácil de ejecutar: Solo llama a
test_calculate_grade()cuando modifiques la función - Feedback progresivo: Mira qué grupos de pruebas pasan mientras se ejecuta la función
- Auto-documentado: La función de prueba muestra exactamente cómo debería funcionar
calculate_grade()
Cuándo ejecutar tus pruebas:
- Antes de hacer cambios: Asegúrate de que tus pruebas pasan con el código actual
- Después de hacer cambios: Verifica que no rompiste nada
- Al añadir funcionalidades: Escribe pruebas para la nueva funcionalidad primero (desarrollo guiado por pruebas)
- Al corregir bugs: Añade una prueba que reproduzca el bug y luego corrígelo
Este patrón simple—escribir funciones de prueba con aserciones—es la base de las pruebas de software profesionales. A medida que avances, aprenderás sobre frameworks de testing como pytest y unittest, pero la idea central sigue siendo la misma: escribe funciones que verifiquen que tu código funciona correctamente.
41.5.6) Cuándo usar aserciones vs excepciones
Entender cuándo usar aserciones versus excepciones es crucial. Cumplen propósitos fundamentalmente distintos:
Las aserciones son para encontrar bugs durante el desarrollo:
- Comprueban cosas que nunca deberían ser falsas si tu código está escrito correctamente
- Verifican suposiciones internas y lógica de tu propio código
- Te ayudan a detectar errores de programación mientras escribes y pruebas el código
- Ejemplo: "En este punto de mi función, esta lista nunca debería estar vacía"
- Ejemplo: "Todos los elementos de esta lista deberían ser enteros porque acabo de filtrarlos"
Las excepciones son para manejar errores que pueden ocurrir durante el funcionamiento normal:
- Se ocupan de condiciones externas que no puedes controlar
- Manejan situaciones que podrían ocurrir incluso cuando tu código es perfecto
- Permiten que tu programa se recupere con elegancia o falle de forma informativa
- Ejemplo: El usuario introduce texto cuando esperabas un número
- Ejemplo: Un archivo que tu código intenta abrir no existe
- Ejemplo: Una solicitud de red expira (timeout)
La diferencia clave: Las aserciones dicen "esto debería ser imposible", mientras que las excepciones dicen "esto podría pasar, y así lo manejaremos".
Veámoslo en la práctica:
# Ejemplo 1: Función usada con ENTRADA DEL USUARIO
# Los usuarios pueden introducir cualquier cosa, incluido 0
def calculate_user_ratio(numerator, denominator):
"""Calculate ratio from user-provided numbers."""
# El usuario podría introducir 0, así que usa manejo de excepciones
if denominator == 0:
raise ValueError("Denominator cannot be zero")
return numerator / denominator
# Ejemplo 2: Cálculo interno donde 0 debería ser imposible
def calculate_percentage(part, total):
"""Calculate what percentage 'part' is of 'total'."""
# Esto se llama internamente después de que hemos verificado total > 0
# Si total es 0, es un bug de programación en nuestro código
assert total > 0, "total must be positive - check calling code"
return (part / total) * 100Más ejemplos de lo que cada uno debería manejar:
| Situation | Use Assertion | Use Exception |
|---|---|---|
| El usuario introduce una entrada inválida | ❌ No | ✅ Sí |
| El archivo no existe | ❌ No | ✅ Sí |
| Falla una solicitud de red | ❌ No | ✅ Sí |
| La función recibe un tipo de parámetro incorrecto desde tu código | ✅ Sí | ❌ No |
| Una lista debería tener elementos pero está vacía por un error de lógica | ✅ Sí | ❌ No |
| La estructura de datos está en un estado inesperado por un bug | ✅ Sí | ❌ No |
| Falla la conexión a la base de datos | ❌ No | ✅ Sí |
| La API devuelve un formato inesperado | ❌ No | ✅ Sí |
| Tu algoritmo produce un resultado matemáticamente imposible | ✅ Sí | ❌ No |
Limitación crítica de las aserciones:
Las aserciones se pueden desactivar completamente cuando Python se ejecuta con optimización:
python -O script.py # All assert statements are ignored!Cuando las aserciones están desactivadas, simplemente desaparecen—Python no las comprueba en absoluto. Esto significa:
- ❌ Nunca uses aserciones para validar entrada del usuario
- ❌ Nunca uses aserciones para comprobaciones de seguridad
- ❌ Nunca uses aserciones para nada que deba funcionar siempre en producción
# PELIGROSO - NO HAGAS ESTO:
def process_payment(amount):
assert amount > 0, "Amount must be positive" # ¡MAL! Se desactiva con -O
# Process payment...
# CORRECTO - HAZ ESTO:
def process_payment(amount):
if amount <= 0:
raise ValueError("Amount must be positive") # ¡Siempre se comprueba!
# Process payment...En resumen:
-
Aserciones = "Estoy comprobando mi propio código para detectar bugs durante el desarrollo"
- Piensa: "Esto debería ser imposible si codifiqué bien"
- Te ayudan a encontrar errores en tu lógica
-
Excepciones = "Estoy manejando condiciones del mundo real que pueden ocurrir"
- Piensa: "Esto podría pasar durante el uso normal, y necesito gestionarlo"
- Ayudan a tu programa a manejar situaciones impredecibles
Las aserciones son herramientas de desarrollo y depuración(debugging) que te ayudan a escribir código correcto. Las excepciones son herramientas de producción que ayudan a tu programa a manejar la realidad desordenada de la entrada del usuario, sistemas de archivos, redes y otros factores externos que no puedes controlar.
Ya has aprendido las técnicas esenciales de depuración y pruebas que te servirán durante todo tu recorrido de programación:
- Leer tracebacks para localizar rápidamente dónde ocurren los errores
- Trazar el código mentalmente para entender lo que hace tu código paso a paso
- Usar sentencias print estratégicamente para ver valores y flujo en tiempo de ejecución
- Inspeccionar objetos con
type()ydir()para entender con qué estás trabajando - Probar con aserciones para verificar que tu código funciona y detectar bugs temprano
Estas habilidades trabajan juntas como un kit completo de depuración. Cuando te encuentres con un problema:
- Lee el traceback para encontrar dónde falló
- Usa depuración con print o trazado mental para entender por qué
- Usa inspección con type/dir cuando no estés seguro de lo que un objeto puede hacer
- Escribe aserciones para evitar que el bug regrese
Con la práctica, desarrollarás intuición sobre qué técnica usar en cada situación. Recuerda: todo programador depura código—la diferencia es que los programadores con experiencia lo hacen de forma sistemática y eficiente. Estas técnicas te convertirán en uno de ellos.