Python & AI Tutorials Logo
Программирование Python

41. Отладка и тестирование вашего кода

Написание кода — это только половина дела. Другая половина — убедиться, что ваш код работает правильно, и находить проблемы, когда это не так. Каждый программист, от новичков до экспертов, пишет код с багами. Разница в том, что опытные программисты выработали систематические подходы к поиску и исправлению этих багов.

В этой главе вы изучите практические техники отладки(debugging), которые помогают понять, что ваш код на самом деле делает, быстро находить проблемы и проверять, что код работает так, как задумано. Эти навыки сделают вас более уверенным и эффективным программистом.

41.1) Чтение отчётов traceback для поиска ошибок (быстрый обзор)

Как мы узнали в главе 24, Python предоставляет подробные сообщения об ошибках, называемые отчётами traceback, когда что-то идет не так. Давайте повторим, как эффективно их читать, поскольку это ваша первая линия обороны при отладке.

41.1.1) Анатомия traceback

Когда Python сталкивается с ошибкой, он показывает вам точное место, где возникла проблема, и тип ошибки. Вот типичный traceback:

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}"
 
# Это вызовет ошибку
student_grades = []
result = process_student_grades(student_grades)
print(result)

Вывод:

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

Разберем, что говорит нам этот traceback:

Строка 12: вызвана process_student_grades

Строка 7: вызвана calculate_average

Строка 4: операция деления

ZeroDivisionError: division by zero

Читаем снизу вверх:

  1. Тип ошибки и сообщение (внизу): ZeroDivisionError: division by zero точно говорит, что пошло не так
  2. Точная строка, где произошла ошибка: return total / count в строке 4
  3. Цепочка вызовов, показывающая, как мы туда пришли: началось со строки 12, прошло через строку 7, закончилось на строке 4

41.1.2) Использование traceback для поиска первопричины

Traceback показывает вам симптом (где произошла ошибка), но вам нужно найти причину (почему она произошла). Давайте проследим проблему:

python
# Ошибка происходит здесь
return total / count  # count равен 0
 
# Но реальная проблема здесь
student_grades = []  # В функцию передан пустой список

Деление на ноль происходит потому, что мы передали пустой список. Traceback указывает на строку 4, но исправление нужно сделать раньше — либо валидируя входные данные, либо обрабатывая случай с пустым списком:

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}"
 
# Теперь это работает безопасно
student_grades = []
result = process_student_grades(student_grades)
print(result)  # Output: No grades to process
 
# И это тоже работает
student_grades = [85, 92, 78, 90]
result = process_student_grades(student_grades)
print(result)  # Output: Average: 86.2

Ключевые выводы:

  • Читайте traceback снизу вверх
  • Место ошибки (симптом) не всегда является первопричиной
  • Валидируйте входные данные заранее, чтобы предотвратить ошибки позже
  • Используйте защитное программирование (.get(), проверки длины) для более безопасного кода

Разные типы ошибок создают разные отчёты traceback, но процесс чтения всегда один и тот же: начните снизу, чтобы увидеть, что пошло не так, затем поднимайтесь вверх, чтобы понять, как вы туда пришли. Если вам нужно освежить в памяти конкретные типы исключений(exception), вернитесь к главе 24.

Теперь, когда вы умеете эффективно читать traceback, давайте научимся мысленно проходить по вашему коду, чтобы понимать, что он делает, шаг за шагом.

41.2) Мысленное трассирование выполнения кода

Иногда вы сталкиваетесь с багом, но не можете сразу запустить код — возможно, вы просматриваете код на бумаге, читаете pull request другого человека или пытаетесь понять, почему функция ведет себя неожиданно. В таких ситуациях мысленное выполнение — пошаговый проход по коду строка за строкой в голове, отслеживая, что происходит с каждой переменной, — становится бесценным.

Даже опытные программисты регулярно используют эту технику. Прежде чем добавлять print-операторы или запускать отладчик, они часто мысленно проходят несколько итераций, чтобы сформировать гипотезу о том, где может быть проблема. Это быстрее, чем метод проб и ошибок, и помогает глубже понять ваш код.

Мысленное выполнение особенно полезно, когда:

  • Вы читаете незнакомый код, чтобы понять, что он делает
  • Вы просматриваете небольшие функции (5–15 строк) перед их запуском
  • Вы отлаживаете логические ошибки, когда код выполняется, но дает неверные результаты
  • Вы понимаете поведение цикла, когда закономерность не очевидна сразу
  • Code review, когда вы не можете легко запустить код сами

Для более крупного или сложного кода вы будете сочетать мысленное трассирование с другими техниками, которые мы рассмотрим далее в этой главе. Но освоение этого навыка сделает вас значительно более эффективным отладчиком.

41.2.1) Процесс мысленного выполнения

Когда вы мысленно выполняете код, вы действуете как интерпретатор Python, следуя тем же правилам, которым следует Python. Давайте потренируемся на простом примере:

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

Вот как трассировать этот код:

Пошаговое трассирование:

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) Создание таблицы трассировки

Для более сложного кода создайте таблицу трассировки, которая показывает, как переменные меняются со временем. Это особенно полезно для циклов и вложенных структур:

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]

Таблица трассировки:

Таблица показывает состояние переменных на каждом шаге. Обратите внимание, как running_sum меняется от «до» к «после» каждого сложения:

Итерацияnumrunning_sum (до)running_sum (после)totals
Начало-00[]
110010[10]
2201030[10, 30]
3303060[10, 30, 60]
44060100[10, 30, 60, 100]

Создание такой таблицы помогает увидеть, как именно данные проходят через ваш код. Если вывод не соответствует ожиданиям, вы можете точно определить, где все идет не так.

41.2.3) Трассирование условной логики

Условные операторы требуют внимательного отслеживания того, какие ветки выполняются. Давайте проследим более сложный пример:

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)

Мысленное трассирование для score = 85:

  1. Проверяем 85 >= 90 → False, пропускаем первый блок
  2. Проверяем 85 >= 80 → True, заходим во второй блок
  3. Устанавливаем category = "Good" и bonus = 5
  4. Пропускаем оставшиеся блоки elif и else (совпадение уже найдено)
  5. Вычисляем final_score = 85 + 5 = 90
  6. Возвращаем ("Good", 90)

41.2.4) Трассирование вызовов функций и возвратов

Когда функции вызывают другие функции, вам нужно отслеживать стек вызовов(call stack) — последовательность вызовов функций и их локальные переменные:

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

Трассировка со стеком вызовов:

┌─ 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

Эта пошаговая трассировка показывает, как именно данные перемещаются между функциями. При отладке, если итоговый результат неверен, вы можете вернуться назад и увидеть, какая функция дала неправильное промежуточное значение.

Мысленное трассирование мощное, но для сложного кода оно может быть утомительным. В следующем разделе мы научимся стратегически использовать print-операторы, чтобы увидеть, что происходит на самом деле во время выполнения кода, что часто быстрее и надежнее, чем одно лишь мысленное выполнение.

41.3) Отладка с помощью print: f"{var=}" и repr()

Хотя мысленное выполнение хорошо работает для небольших функций, оно становится непрактичным для более крупного или сложного кода. Когда вы не уверены, что происходит внутри цикла(loop), или когда вычисление дает неожиданные результаты, самый быстрый способ разобраться — часто добавить стратегические операторы print().

У print-отладки есть некоторые преимущества по сравнению с другими техниками:

  • Не нужны специальные инструменты: работает в любой среде Python
  • Быстро внедряется: добавить print можно за секунды
  • Понятный вывод: вы видите именно то, что попросили вывести
  • Легко удалить: когда закончили, удалите print

Профессиональные разработчики постоянно используют print-отладку — это не «техника для новичков». Давайте научимся применять ее эффективно.

41.3.1) Базовая print-отладка

Самый простой подход к отладке — печатать значения переменных в ключевых точках вашего кода:

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)

Вывод:

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

Эти print-операторы показывают вам поток выполнения и значения на каждом шаге. Если итоговый результат неверен, вы можете точно увидеть, где вычисление «съехало» с правильного пути.

41.3.2) Использование f"{var=}" для быстрой проверки

Python 3.8 добавил удобный синтаксис для отладки: f"{var=}". Он печатает и имя переменной, и ее значение:

python
def calculate_compound_interest(principal, rate, years):
    # Традиционный подход
    print(f"principal: {principal}")
    print(f"rate: {rate}")
    print(f"years: {years}")
    
    # Более чистый подход с f"{var=}"
    print(f"{principal=}")
    print(f"{rate=}")
    print(f"{years=}")
    
    # Можно использовать выражения, не только переменные
    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)

Вывод:

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) Использование repr() для просмотра истинной формы данных

Иногда то, что вы видите при печати, не соответствует тому, что вы думаете. Функция repr() показывает точное представление объекта, включая скрытые символы:

python
# Эти строки выглядят одинаково при печати
text1 = "Hello"
text2 = "Hello\n"  # В конце есть символ перевода строки
 
print("Using print():")
print(f"text1: {text1}")
print(f"text2: {text2}")
 
print("\nUsing repr():")
print(f"text1: {repr(text1)}")
print(f"text2: {repr(text2)}")

Вывод:

Using print():
text1: Hello
text2: Hello
 
Using repr():
text1: 'Hello'
text2: 'Hello\n'

Вывод repr() показывает, что у text2 есть скрытый символ перевода строки. Это критично при отладке обработки строк:

python
def clean_user_input():
    # В пользовательском вводе часто есть скрытые пробелы
    username = input("Enter username: ")  # Пользователь вводит "Alice  "
    
    print(f"Username with print(): {username}")
    print(f"Username with repr(): {repr(username)}")
    
    # Очищаем ввод
    cleaned = username.strip()
    print(f"Cleaned with repr(): {repr(cleaned)}")
    
    return cleaned

Если пользователь вводит "Alice", затем пробелы и нажимает Enter, вы можете увидеть:

Вывод:

Enter username: Alice  
Username with print(): Alice  
Username with repr(): 'Alice  '
Cleaned with repr(): 'Alice'

Вывод repr() показывает пробелы в конце, которые print() не демонстрирует так явно.

Когда использовать repr() vs str():

repr() предназначен для разработчиков — он показывает «официальное» строковое представление, которое могло бы воссоздать объект. str() (который print() использует по умолчанию) предназначен для конечных пользователей — он показывает читаемую, дружелюбную версию.

Для отладки repr() обычно полезнее, потому что раскрывает реальную структуру ваших данных.

41.3.4) Стратегическое размещение print

Не разбрасывайте print-операторы повсюду. Размещайте их стратегически:

python
def calculate_shipping_cost(weight, distance, express=False):
    print(f"=== calculate_shipping_cost called ===")
    print(f"Input: {weight=}, {distance=}, {express=}")
    
    # Рассчитываем базовую стоимость
    base_rate = 0.50
    base_cost = weight * distance * base_rate
    print(f"Calculated: {base_cost=}")
    
    # Применяем наценку за express
    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
 
# Тестируем разные сценарии
cost1 = calculate_shipping_cost(10, 500, express=True)
cost2 = calculate_shipping_cost(5, 200, express=False)

Вывод:

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

Четкие маркеры (===) и организованный вывод упрощают отслеживание потока выполнения.

41.3.5) Удаление отладочных print

После того как вы нашли и исправили баг, не забудьте удалить отладочные print. Вот несколько стратегий:

Стратегия 1: Используйте отличительный префикс

python
# Легко найти и удалить поиском/заменой
print(f"DEBUG: {total=}")
print(f"DEBUG: {items=}")

Стратегия 2: Используйте флаг отладки

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
 
# Отключаем весь отладочный вывод одним разом
DEBUG = False

Стратегия 3: Закомментируйте их, но оставьте

python
def process_data(data):
    # print(f"DEBUG: {data=}")  # Useful for future debugging
    result = transform(data)
    # print(f"DEBUG: {result=}")
    return result

Для более продвинутого логирования, которое можно оставлять в production-коде, в Python есть модуль logging, но простые print-операторы отлично подходят для быстрой отладки во время разработки.

Print-отладка показывает вам значения переменных, но иногда нужно понять структуру объекта — какие у него методы, какой у него тип, и что он умеет. В следующем разделе мы научимся инспектировать объекты с помощью type() и dir().

41.4) Инспектирование объектов: type() и dir()

Print-отладка показывает значения ваших переменных, но иногда проблема не в значении — а в типе(type) объекта, с которым вы работаете. Вы можете ожидать список(list), но получить строку(string), или работать с незнакомым объектом и не знать, какие методы(method) он поддерживает.

Python предоставляет встроенные инструменты для инспектирования объектов: type() говорит, какой у вас объект, а dir() показывает, какие операции он поддерживает. Эти функции незаменимы, когда:

  • Вы отлаживаете ошибки, связанные с типами (TypeError, AttributeError)
  • Вы работаете с незнакомыми библиотеками или API
  • Вы пытаетесь понять объекты, возвращаемые сторонним кодом
  • Вы проверяете, что ваш код получает ожидаемые типы

Давайте научимся эффективно использовать эти инструменты инспектирования.

41.4.1) Использование type() для определения типов объектов

Функция type() говорит вам точно, какой у вас объект. Это критично при отладке ошибок, связанных с типами:

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
 
# Тестируем с разными типами
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}")

Вывод:

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) Отладка путаницы с типами

Путаница с типами — частый источник багов, особенно когда функции могут получать данные из нескольких источников: пользовательский ввод, чтение файлов, ответы API или другие функции. Вы можете ожидать список чисел, но случайно получить строку, или ожидать словарь(dict), но получить список.

Использование type() помогает определить, когда у вас неверный тип. Печать типа в начале функции позволяет сразу заметить несоответствия типов, прежде чем они приведут к запутанным сообщениям об ошибках глубже в коде:

python
def calculate_average(numbers):
    print(f"{type(numbers)=}")
    print(f"{numbers=}")  # Показываем, что мы реально получили
    
    # Это упадет, если numbers — не список чисел
    total = sum(numbers)
    count = len(numbers)
    return total / count
 
# Частая ошибка: забыли преобразовать строку в список
scores = "85"  # Должно быть [85] или просто 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)}")

Вывод:

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'

Проверка type() сразу раскрывает проблему: мы передали строку, когда нужен был список. Без этого отладочного вывода вы могли бы потратить время, пытаясь понять, почему sum() не сработал, когда реальная причина в том, что в функцию изначально попал неверный тип данных.

41.4.3) Использование dir() для поиска доступных методов

При работе с незнакомыми объектами — будь то из библиотеки, которую вы изучаете, ответа API или даже встроенных типов Python — часто нужно знать: «Что я могу сделать с этим объектом?» Функция dir() отвечает на этот вопрос, перечисляя все атрибуты(attributes) и методы(methods), доступные у объекта.

Это особенно ценно, когда:

  • Вы исследуете новую библиотеку и хотите увидеть, какие методы предоставляет объект
  • Вы получаете объект из стороннего кода и хотите понять его возможности
  • Вы забыли точное имя метода, который хотите использовать
  • Вы отлаживаете и хотите проверить, что у объекта есть ожидаемые методы

Давайте посмотрим, какие методы есть у строки:

python
# Изучаем, какие методы есть у строки
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]:  # Show first 10
    print(f"  {method}")
print(f"  ... and {len(methods) - 10} more")

Вывод:

Type: <class 'str'>
 
Available string methods (showing first 10):
  capitalize
  casefold
  center
  count
  encode
  endswith
  expandtabs
  find
  format
  format_map
  ... and 37 more

Теперь вы видите все операции, доступные для строк. Если вы не были уверены, есть ли у строк метод count или endswith, dir() показывает, что они существуют. Затем вы можете использовать функцию Python help(), чтобы узнать больше о любом конкретном методе:

python
# Learn more about a specific method
help(text.count)

Это покажет вам документацию для метода 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.

Функция dir() — это как документация, встроенная прямо в Python: она показывает, что возможно сделать с любым объектом, с которым вы работаете.

41.4.4) Инспектирование пользовательских объектов

При работе с пользовательскими классами(class) type() и dir() помогают понять, с чем вы имеете дело. Кроме того, Python предоставляет hasattr(), чтобы проверить, есть ли у объекта конкретный атрибут, прежде чем пытаться к нему обратиться — это предотвращает исключения 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}")
 
# Проверяем, существуют ли конкретные атрибуты
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')}")
 
# Теперь можно безопасно обращаться к атрибутам, про которые мы знаем, что они существуют
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")
 
# Это предотвращает такие ошибки:
# print(student.age)  # Would raise AttributeError!

Вывод:

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

Функция hasattr() важна для написания защитного кода — кода, который проверяет, безопасны ли операции, прежде чем их выполнять. Функция возвращает True, если атрибут существует, и False, если нет — позволяя принимать решения до попытки доступа к атрибутам. Это особенно важно при работе с объектами из внешних библиотек или с пользовательским вводом, где вы не можете гарантировать, какие атрибуты будут присутствовать.

41.4.5) Использование getattr() для безопасного доступа к атрибутам

Если вы не уверены, существует ли атрибут, используйте getattr() со значением по умолчанию:

python
def display_student_info(student):
    """Safely display student info even if some attributes are missing."""
    print(f"Type: {type(student)}")
    
    # Безопасный доступ к атрибутам со значениями по умолчанию
    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}")
    
    # Проверяем, существует ли метод, прежде чем вызывать
    if hasattr(student, 'get_status'):
        status = student.get_status()
        print(f"Status: {status}")
 
# Используем тот же класс Student, что и выше
student = Student("Bob", 72)
display_student_info(student)

Вывод:

Type: <class '__main__.Student'>
Name: Bob
Grade: 72
Age: Not specified
Status: Passing

Этот подход предотвращает исключения AttributeError при работе с объектами, у которых может не быть всех ожидаемых атрибутов. Функция getattr() особенно полезна, когда:

  • Вы работаете с объектами из внешних API, у которых могут быть разные версии
  • Вы обрабатываете необязательные атрибуты в собственных классах
  • Вы пишете защитный код, который корректно обрабатывает отсутствие данных

Понимание того, какой у вас тип объекта и какие методы он поддерживает, критично для отладки. Но иногда нужно проверить не только то, что код выполняется, но и то, что он дает правильные результаты. В следующем разделе мы научимся использовать assert-выражения, чтобы тестировать ваши предположения и ловить баги на ранней стадии.

41.5) Тестирование с помощью assert

Мы научились отлаживать код, когда что-то идет не так — читать traceback, мысленно трассировать выполнение, использовать print-операторы и инспектировать объекты. Но есть подход лучше, чем исправлять баги после того, как они появились: предотвращать их с самого начала с помощью тестирования.

Оператор assert — самый простой инструмент тестирования в Python. Он позволяет проверять, что ваш код ведет себя правильно, контролируя предположения в критических точках. Когда утверждение(assertion) не проходит, Python сразу сообщает, что именно пошло не так и где, что значительно упрощает раннее обнаружение багов — часто еще до запуска основной программы.

Утверждения особенно полезны для:

  • Проверки того, что функции выдают ожидаемые результаты
  • Проверки того, что входные данные соответствуют вашим требованиям
  • Тестирования граничных случаев, которые могут «сломать» ваш код
  • Документирования предположений, на которые опирается ваш код

Думайте об утверждениях как об автоматических проверках, которые постоянно подтверждают, что ваш код работает так, как задумано. Давайте научимся применять их эффективно.

41.5.1) Что делает assert

Оператор assert проверяет, истинно ли условие. Если условие истинно, ничего не происходит — код продолжает выполняться. Если оно ложно, Python вызывает AssertionError и останавливает выполнение.

Синтаксис:

python
assert condition, "Optional error message"
  • condition: любое выражение, которое вычисляется как True или False
  • "Optional error message": полезный текст, показываемый при провале утверждения

Вот как это работает на практике:

python
# Простые утверждения
x = 10
assert x > 0  # Проходит молча (x действительно > 0)
assert x < 5  # Провал! Вызывает AssertionError
 
# С сообщениями об ошибках (гораздо полезнее!)
assert x > 0, f"x must be positive, got {x}"
assert x < 5, f"x must be less than 5, got {x}"  # Провал с понятным сообщением

Теперь посмотрим на утверждения в реальной функции:

python
def calculate_discount(price, discount_percent):
    # Проверяем, что входные данные валидны
    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
    
    # Проверяем, что выход имеет смысл
    assert final_price >= 0, "Final price cannot be negative"
    
    return final_price
 
# Валидные входные данные работают нормально
result = calculate_discount(100, 20)
print(f"Price after 20% discount: ${result}")  # Output: Price after 20% discount: $80.0
 
# Невалидные входные данные запускают утверждения
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) Использование утверждений для проверки поведения функций

Утверждения отлично подходят для тестирования того, что функции выдают ожидаемые результаты:

python
def calculate_average(numbers):
    if not numbers:
        return 0.0
    return sum(numbers) / len(numbers)
 
# Тестируем с различными входными данными
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}")

Вывод:

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

Если какое-то утверждение проваливается, вы сразу знаете, какой тестовый сценарий выявил проблему.

41.5.3) Тестирование граничных случаев

Граничные случаи — это входные данные на границах того, что ваша функция должна обрабатывать. Тестирование таких случаев выявляет баги, которые обычные входные данные могут не показать:

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]
 
# Тестируем обычный случай
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}")
 
# Тестируем граничный случай: один элемент
result = get_first_and_last([42])
assert result == (42, 42), f"Expected (42, 42), got {result}"
print(f"Single item: {result}")
 
# Тестируем граничный случай: два элемента
result = get_first_and_last([10, 20])
assert result == (10, 20), f"Expected (10, 20), got {result}"
print(f"Two items: {result}")
 
# Тестируем граничный случай: пустая последовательность (должно упасть)
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}")

Вывод:

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) Тестирование преобразований данных

Когда ваша функция преобразует данные, проверьте утверждением, что преобразование корректно:

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
 
# Тестируем базовое удаление дубликатов
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}")
 
# Тестируем, что порядок сохраняется
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}")
 
# Тестируем случай без дубликатов
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}")
 
# Тестируем случай, когда все элементы — дубликаты
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}")

Вывод:

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) Создание простой тестовой функции

По мере роста кода разбрасывать assert-выражения по основному коду становится грязно и сложно поддерживать. Лучший подход — организовать тесты в выделенные тестовые функции. Это отделяет тестовый код от production-кода и позволяет легко запускать все тесты разом.

Зачем использовать отдельные тестовые функции?

  • Организация: все тесты для функции находятся в одном месте
  • Повторное использование: запускайте тесты каждый раз, когда меняете код
  • Документация: тесты показывают, как функция должна себя вести
  • Отладка: когда тест падает, вы сразу знаете, какой сценарий сломался
  • Процесс разработки: сначала тест, затем реализация или исправление кода

Посмотрим, как это выглядит:

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...")
    
    # Тестируем оценки A
    assert calculate_grade(95) == 'A', "95 should be A"
    assert calculate_grade(90) == 'A', "90 should be A (boundary)"
    print("  ✓ A grades: passed")
    
    # Тестируем оценки B
    assert calculate_grade(85) == 'B', "85 should be B"
    assert calculate_grade(80) == 'B', "80 should be B (boundary)"
    print("  ✓ B grades: passed")
    
    # Тестируем оценки C
    assert calculate_grade(75) == 'C', "75 should be C"
    assert calculate_grade(70) == 'C', "70 should be C (boundary)"
    print("  ✓ C grades: passed")
    
    # Тестируем оценки D
    assert calculate_grade(65) == 'D', "65 should be D"
    assert calculate_grade(60) == 'D', "60 should be D (boundary)"
    print("  ✓ D grades: passed")
    
    # Тестируем оценки F
    assert calculate_grade(55) == 'F', "55 should be F"
    assert calculate_grade(0) == 'F', "0 should be F"
    print("  ✓ F grades: passed")
    
    # Тестируем граничные случаи (на 1 меньше каждого порога)
    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")
 
# Запускаем тесты
test_calculate_grade()
 
# Теперь вы можете уверенно использовать функцию
student_score = 87
grade = calculate_grade(student_score)
print(f"Student score {student_score} = Grade {grade}")

Вывод:

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

Преимущества этого подхода:

  1. Понятная организация тестов: все тест-кейсы видны с первого взгляда
  2. Легко запускать: просто вызывайте test_calculate_grade() каждый раз, когда меняете функцию
  3. Пошаговая обратная связь: видно, какие группы тестов проходят по мере выполнения
  4. Самодокументируемость: тестовая функция точно показывает, как должна работать calculate_grade()

Когда запускать тесты:

  • Перед внесением изменений: убедитесь, что тесты проходят на текущем коде
  • После внесения изменений: проверьте, что вы ничего не сломали
  • При добавлении возможностей: сначала пишите тесты для новой возможности (разработка через тестирование)
  • При исправлении багов: добавьте тест, который воспроизводит баг, затем исправьте его

Этот простой паттерн — написание тестовых функций с утверждениями — основа профессионального тестирования ПО. По мере развития вы узнаете о тестовых фреймворках вроде pytest и unittest, но базовая идея остается той же: пишите функции, которые проверяют, что ваш код работает корректно.

41.5.6) Когда использовать утверждения, а когда исключения

Понимание того, когда использовать утверждения, а когда исключения, критически важно. Они служат принципиально разным целям:

Утверждения нужны, чтобы находить баги во время разработки:

  • Они проверяют вещи, которые никогда не должны быть ложными, если ваш код написан правильно
  • Они проверяют внутренние предположения и логику вашего собственного кода
  • Они помогают ловить программистские ошибки, пока вы пишете и тестируете код
  • Example: "At this point in my function, this list should never be empty"
  • Example: "All items in this list should be integers because I just filtered them"

Исключения нужны, чтобы обрабатывать ошибки, которые могут происходить при нормальной работе:

  • Они связаны с внешними условиями, которые вы не контролируете
  • Они обрабатывают ситуации, которые могут возникать даже при идеальном коде
  • Они позволяют программе корректно восстановиться или завершиться с понятным сообщением
  • Example: User enters text when you expected a number
  • Example: A file your code tries to open doesn't exist
  • Example: Network request times out

Ключевое различие: утверждения говорят «это должно быть невозможно», а исключения говорят «это может произойти, и вот как мы это обработаем».

Посмотрим это на практике:

python
# Example 1: Function used with USER INPUT
# Users might enter anything, including 0
def calculate_user_ratio(numerator, denominator):
    """Calculate ratio from user-provided numbers."""
    # User might enter 0, so use exception handling
    if denominator == 0:
        raise ValueError("Denominator cannot be zero")
    
    return numerator / denominator
 
# Example 2: Internal calculation where 0 should be impossible
def calculate_percentage(part, total):
    """Calculate what percentage 'part' is of 'total'."""
    # This is called internally after we've verified total > 0
    # If total is 0, it's a programming bug in our code
    assert total > 0, "total must be positive - check calling code"
    
    return (part / total) * 100

Еще примеры того, что должно обрабатывать каждое:

СитуацияИспользовать утверждениеИспользовать исключение
Пользователь вводит некорректные данные❌ Нет✅ Да
Файл не существует❌ Нет✅ Да
Сетевой запрос завершается ошибкой❌ Нет✅ Да
Функция получает неверный тип параметра из вашего кода✅ Да❌ Нет
Список должен содержать элементы, но пуст из-за логической ошибки✅ Да❌ Нет
Структура данных в неожиданном состоянии из-за бага✅ Да❌ Нет
Не удается подключиться к базе данных❌ Нет✅ Да
API возвращает неожиданный формат❌ Нет✅ Да
Ваш алгоритм дает математически невозможный результат✅ Да❌ Нет

Критическое ограничение утверждений:

Утверждения можно полностью отключить, если Python запускается с оптимизацией:

bash
python -O script.py  # All assert statements are ignored!

Когда утверждения отключены, они просто исчезают — Python вообще их не проверяет. Это означает:

  • Никогда не используйте утверждения для проверки пользовательского ввода
  • Никогда не используйте утверждения для проверок безопасности
  • Никогда не используйте утверждения для чего-либо, что всегда должно работать в production
python
# ОПАСНО — НЕ ДЕЛАЙТЕ ТАК:
def process_payment(amount):
    assert amount > 0, "Amount must be positive"  # WRONG! Gets disabled with -O
    # Process payment...
 
# ПРАВИЛЬНО — ДЕЛАЙТЕ ТАК:
def process_payment(amount):
    if amount <= 0:
        raise ValueError("Amount must be positive")  # Always checked!
    # Process payment...

Итого:

  • Утверждения = «я проверяю свой собственный код на наличие багов во время разработки»

    • Думайте: «это должно быть невозможно, если я все закодил правильно»
    • Они помогают находить ошибки в вашей логике
  • Исключения = «я обрабатываю реальные условия, которые действительно могут возникнуть»

    • Думайте: «это может произойти при обычном использовании, и мне нужно с этим справиться»
    • Они помогают вашей программе обрабатывать непредсказуемые ситуации

Утверждения — инструменты разработки и отладки, которые помогают писать корректный код. Исключения — инструменты production, которые помогают вашей программе справляться с «грязной» реальностью пользовательского ввода, файловых систем, сетей и других внешних факторов, которые вы не можете контролировать.


Теперь вы изучили основные техники отладки и тестирования, которые будут полезны вам на всем протяжении вашего пути в программировании:

  • Чтение traceback, чтобы быстро находить место возникновения ошибок
  • Мысленное трассирование кода, чтобы понимать, что ваш код делает, шаг за шагом
  • Стратегическое использование print-операторов, чтобы видеть значения и поток выполнения во время работы
  • Инспектирование объектов с type() и dir(), чтобы понимать, с чем вы работаете
  • Тестирование с помощью утверждений, чтобы проверять работу кода и рано ловить баги

Эти навыки работают вместе как полноценный набор инструментов для отладки. Когда вы сталкиваетесь с проблемой:

  1. Прочитайте traceback, чтобы найти, где произошел сбой
  2. Используйте print-отладку или мысленное трассирование, чтобы понять почему
  3. Используйте инспекцию type/dir, когда не уверены, что объект умеет
  4. Напишите утверждения, чтобы баг не вернулся

С практикой у вас появится интуиция, какую технику применять в каждой ситуации. Помните: каждый программист отлаживает код — разница в том, что опытные делают это систематически и эффективно. Эти техники помогут вам стать одним из них.

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