Антипаттерны тестирования: как не стоит писать тесты в Python. Часть 1
![5ac3cad5f0ed6997f1c32bb80be00113.png](https://habrastorage.org/getpro/habr/upload_files/5ac/3ca/d5f/5ac3cad5f0ed6997f1c32bb80be00113.png)
Привет, Хабр!
Тестирование — это не просто написание кода, который проверяет другой код. Это в самом деле настоящее мастерство, требующее тщательной проработки, чтобы избежать ошибок, способных затруднить работу.
В этой статье разберемся с тремя основными антипаттернами тестирования в Python.
Использование глобальных переменных в тестах
Глобальные переменные — это переменные, объявленные вне функций или классов, и доступные в любой части программы. В тестировании это может выглядеть примерно так:
# Глобальная переменная
global_state = 0
def increment_global_state():
global global_state
global_state += 1
def test_increment_global_state():
global global_state
global_state = 0 # сброс состояния
increment_global_state()
assert global_state == 1
На первый взгляд, такой подход кажется даже удобным. Но это только до тех пор, пока ваши тесты не начинают взаимодействовать друг с другом через эти глобальные состояния.
Глобальные переменные могут изменяться в одном тесте и сохранять эти изменения для других тестов. Это приводит к тому, что тесты становятся зависимыми друг от друга и результат одного теста может повлиять на результат другого.
Глобальные переменные нарушают принцип единственной ответственности, поскольку логика изменения состояний разбросана по всему коду, а не инкапсулирована в одном месте.
Решения
Вместо использования глобальных переменных, передавайте зависимости явно через параметры функций или конструкторы классов. Так тесты будут независимыми и изолированными:
def increment_state(state):
return state + 1
def test_increment_state():
state = 0
new_state = increment_state(state)
assert new_state == 1
Если используется pytest, фикстуры помогут создать предопределённое состояние для каждого теста, что дает некую изоляцию и повторяемость:
import pytest
@pytest.fixture
def initial_state():
return 0
def test_increment_state(initial_state):
state = initial_state
new_state = increment_state(state)
assert new_state == 1
Инкапсулируйте состояния внутри объектов или используйте локальные переменные, чтобы гарантировать, что изменения в одном тесте не повлияют на другие:
class StateManager:
def __init__(self):
self.state = 0
def increment(self):
self.state += 1
return self.state
def test_state_manager():
manager = StateManager()
assert manager.increment() == 1
assert manager.increment() == 2
Неиспользование контекстных менеджеров при работе с файлами
В Python открыть файл можно несколькими способами. Сравним дефолтный подход с использованием явного закрытия файла и с использованием контекстных менеджеров.
Открытие файла без контекстного менеджера:
file = open('example.txt', 'r')
try:
data = file.read()
finally:
file.close()
В этом примере файл открывается, читается, и затем явно закрывается с использованием блока finally
, чтобы гарантировать закрытие файла даже в случае возникновения исключения.
Открытие файла с использованием контекстного менеджера:
with open('example.txt', 'r') as file:
data = file.read()
Контекстный менеджер with
автоматически управляет открытием и закрытием файла. Как только блок кода внутри with
завершается, файл автоматом закрывается, даже если внутри блока произошла ошибка.
Проблемы
Когда файл открывается без использования контекстного менеджера, легко забыть вызвать close()
, особенно при возникновении исключений. Все это может привести к утечке файловых дескрипторов, что в конечном итоге исчерпает доступные ресурсы системы.
При использовании традиционного подхода нам часто приходится явно закрывать файл, что добавляет код, усложняет его и увеличивает вероятность ошибок
Решение
Естественно, нужно юзать контекст менеджеры:
with open('example.txt', 'r') as file:
data = file.read()
Также можно создавать свои кастомные контекстные менеджеры, используя методы __enter__
и __exit__
:
class FileManager:
def __init__(self, filename, mode):
self.filename = filename
self.mode = mode
def __enter__(self):
self.file = open(self.filename, self.mode)
return self.file
def __exit__(self, exc_type, exc_value, traceback):
self.file.close()
with FileManager('example.txt', 'r') as file:
data = file.read()
Смешивание разных типов возвращаемых значений в функциях
Рассмотрим ещё один распространённый антипаттерн — смешивание разных типов возвращаемых значений в функциях.
Смешивание различных типов возвращаемых значений — это ситуация, когда функция может возвращать значения разных типов в зависимости от условий. Например:
def get_data(condition):
if condition == 'int':
return 42
elif condition == 'str':
return 'Hello'
elif condition == 'list':
return [1, 2, 3]
else:
return None
Функция get_data
возвращает либо целое число, либо строку, либо список, либо None
. Так пользователю необходимо каждый раз проверять тип возвращаемого значения.
Когда функция может возвращать разные типы данных, отладка становится более сложной. Потребуется добавлять доп. проверки и условия, чтобы корректно обработать каждый возможный тип возвращаемого значения.
Код, который использует такие функции, становится трудным для понимания. Читатель вынужден разбираться с различными ветвлениями логики, что в перспективе особо затрудняют поддержку кода.
Решение
Старайтесь, чтобы функция возвращала значения одного типа:
def get_data(condition):
if condition == 'int':
return 42
elif condition == 'str':
return '42' # возвращаем строку
elif condition == 'list':
return ','.join(map(str, [1, 2, 3])) # возвращаем строку
else:
return ''
Если функция должна возвращать сложные данные, рассмотрите возможность использования классов или namedtuple для упаковки данных в один объект:
from collections import namedtuple
Result = namedtuple('Result', ['type', 'value'])
def get_data(condition):
if condition == 'int':
return Result('int', 42)
elif condition == 'str':
return Result('str', 'Hello')
elif condition == 'list':
return Result('list', [1, 2, 3])
else:
return Result('unknown', None)
Если функция не может вернуть корректное значение, лучше выбросить исключение, чем возвращать значение другого типа:
def get_data(condition):
if condition == 'int':
return 42
elif condition == 'str':
return 'Hello'
elif condition == 'list':
return [1, 2, 3]
else:
raise ValueError("Invalid condition")
Можно указать несколько типов данных с помощью Union
и Optional
:
from typing import Union, Optional
def get_data(condition: str) -> Optional[Union[int, str, list]]:
if condition == 'int':
return 42
elif condition == 'str':
return 'Hello'
elif condition == 'list':
return [1, 2, 3]
else:
return None
В заключение напоминаю про открытый урок 22 июля »Первый шаг в Django: создайте свой первый веб-проект». На занятии вы узнаете:
Основы Django: краткий обзор архитектуры Django, установка Django и создание нового проекта.
Ваше первое приложение: определение и регистрация простой модели данных, создание представления и маршрута для отображения информации на странице.
Работа с шаблонами: использование шаблонов для отображения данных в браузере.
Записывайтесь на урок по ссылке.