Python & AI Tutorials Logo
Programación Python

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:

python
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 zero

Desglosemos lo que este traceback nos dice:

Línea 12: se llamó a process_student_grades

Línea 7: se llamó a calculate_average

Línea 4: operación de división

ZeroDivisionError: division by zero

Leyendo de abajo hacia arriba:

  1. El tipo de error y el mensaje (abajo): ZeroDivisionError: division by zero nos dice exactamente qué salió mal
  2. La línea exacta donde ocurrió el error: return total / count en la línea 4
  3. 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:

python
# 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ón

La 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:

python
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.2

Puntos 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:

python
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: 9

Así 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: 9

41.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:

python
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:

Iterationnumrunning_sum (before)running_sum (after)totals
Start-00[]
110010[10]
2201030[10, 30]
3303060[10, 30, 60]
44060100[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:

python
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:

  1. Comprueba 85 >= 90 → False, omite el primer bloque
  2. Comprueba 85 >= 80 → True, entra en el segundo bloque
  3. Asigna category = "Good" y bonus = 5
  4. Omite los bloques elif y else restantes (ya se encontró una coincidencia)
  5. Calcula final_score = 85 + 5 = 90
  6. 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:

python
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.00

Traza 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.0

Esta 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:

python
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.53199999999999

Estas 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:

python
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.894626777442

41.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:

python
# 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:

python
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 cleaned

Si 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:

python
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

python
# 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

python
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 = False

Estrategia 3: Coméntalos pero consérvalos

python
def process_data(data):
    # print(f"DEBUG: {data=}")  # Útil para futuras depuraciones
    result = transform(data)
    # print(f"DEBUG: {result=}")
    return result

Para 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:

python
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: None

41.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:

python
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:

python
# 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 more

Ahora 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:

python
# 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.

python
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: Passing

La 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:

python
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: Passing

Este 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:

python
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:

python
# 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 claro

Ahora veamos aserciones en una función real:

python
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 100

41.5.2) Usar aserciones para verificar el comportamiento de una función

Las aserciones son excelentes para probar que las funciones producen resultados esperados:

python
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.0

Si 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:

python
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 sequence

41.5.4) Probar transformaciones de datos

Cuando tu función transforma datos, afirma que la transformación es correcta:

python
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:

python
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 B

Beneficios de este enfoque:

  1. Organización clara de las pruebas: Puedes ver todos los casos de prueba de un vistazo
  2. Fácil de ejecutar: Solo llama a test_calculate_grade() cuando modifiques la función
  3. Feedback progresivo: Mira qué grupos de pruebas pasan mientras se ejecuta la función
  4. 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:

python
# 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) * 100

Más ejemplos de lo que cada uno debería manejar:

SituationUse AssertionUse 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:

bash
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
python
# 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() y dir() 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:

  1. Lee el traceback para encontrar dónde falló
  2. Usa depuración con print o trazado mental para entender por qué
  3. Usa inspección con type/dir cuando no estés seguro de lo que un objeto puede hacer
  4. 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.

© 2025. Primesoft Co., Ltd.
support@primesoft.ai