Привет, Хабр!
Тестирование — это не просто написание кода, который проверяет другой код. Это в самом деле настоящее мастерство, требующее тщательной проработки, чтобы избежать ошибок, способных затруднить работу.
В этой статье разберемся с тремя основными антипаттернами тестирования в 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 и создание нового проекта.
Ваше первое приложение: определение и регистрация простой модели данных, создание представления и маршрута для отображения информации на странице.
Работа с шаблонами: использование шаблонов для отображения данных в браузере.
Комментарии (5)
icya
19.07.2024 06:29+4Тема статьи - "Антипаттерны тестирования: как не стоит писать тесты в Python".
В статье же описаны плохие практики написания кода вообще и кое-как затронуты тесты в самом начале. А антипаттерны в тестах когда будут?
eulampius
Вот совершенно не захотелось "записаться на урок по ссылке" после этой статьи... Интересно, почему?
Ещё поймал себя на мысли, что не могу припомнить ни одного случая, когда мне в Python захотелось бы применить "неиспользование контекстного менеджера для открытия файла". Вот ни разу! Видимо, я пропустил раздачу этого "антипаттерна" /s
weirded
У меня есть пример, когда осмысленно не закрывал файлы. Это утилиты, единственная задача которых - обработать файл. Контекстный менеджер в таком случае уже существует и называется "процесс операционной системы". Завершается он - закрываются и все его файлы :)
MechanicZelenyy
Только если открывали их на чтение. Если вы открываете их запись то в менее продающих пользователя языках типа с++ это может привести к потопе данных. В питоне тоже может но надо постараться, там защиты от дурака побольше.
Andrey_Solomatin
А потом этот код захотят переиспользовать и полезу сюрпризы. Завернуть в контекстный менеджер ничего не стоит.