Привет, Хабр!

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

В этой статье разберемся с тремя основными антипаттернами тестирования в 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)


  1. eulampius
    19.07.2024 06:29
    +2

    Вот совершенно не захотелось "записаться на урок по ссылке" после этой статьи... Интересно, почему?

    Ещё поймал себя на мысли, что не могу припомнить ни одного случая, когда мне в Python захотелось бы применить "неиспользование контекстного менеджера для открытия файла". Вот ни разу! Видимо, я пропустил раздачу этого "антипаттерна" /s


    1. weirded
      19.07.2024 06:29

      У меня есть пример, когда осмысленно не закрывал файлы. Это утилиты, единственная задача которых - обработать файл. Контекстный менеджер в таком случае уже существует и называется "процесс операционной системы". Завершается он - закрываются и все его файлы :)


      1. MechanicZelenyy
        19.07.2024 06:29

        Только если открывали их на чтение. Если вы открываете их запись то в менее продающих пользователя языках типа с++ это может привести к потопе данных. В питоне тоже может но надо постараться, там защиты от дурака побольше.


      1. Andrey_Solomatin
        19.07.2024 06:29

        А потом этот код захотят переиспользовать и полезу сюрпризы. Завернуть в контекстный менеджер ничего не стоит.


  1. icya
    19.07.2024 06:29
    +4

    Тема статьи - "Антипаттерны тестирования: как не стоит писать тесты в Python".

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