(Статья — результа т совместной работы с Максимом Степановым)

Когда начинаешь писать тесты к коду, иногда возникает ощущение, что пытаешься расчесать запутанные волосы, и чем больше дёргаешь, тем больше узлов находишь. Это полезный сигнал, к которому стоит прислушиваться: плохая тестируемость подсказывает, что у кода есть изъяны в архитектуре. 

Связанный код, который сложно поддерживать и расширять, сложно и тестировать. Как сказал Боб Мартин

«Тестируемый код — синоним разъединённого кода»

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

Мы напишем тесты для примитивного скрипта на Python, который проверяет IP пользователя, определяет их регион и сообщает текущую погоду в регионе. Нас будет интересовать, как эти тесты заставят нас изменить код. Они, как расчёска, помогут нам методично разобрать проблемные места, чтобы код (как и волосы) стал гладким и послушным. Полный пример доступен здесь, каждый основной шаг находится в отдельной ветке.

В первой части статьи мы сделаем простейшее преобразование — разобъём скрипт на отдельные функции, а потом выясним, какие недостатки кода нам пока не удалось устранить. Во второй части мы от них избавимся с помощью разъединения зависимостей и модульной архитектуры. 

Шаг 1: Сырая версия

Рассмотрим простой, но плохой и не тестируемый пример — так обычно и выглядят скрипты в начале. Чтобы понять, почему первая версия плохая, спросим себя: как бы мы протестировали этот код?

def local_weather():  
    # Сначала получаем IP 
    url = "https://api64.ipify.org?format=json"  
    response = requests.get(url).json()  
    ip_address = response["ip"]  
  
    # Определяем город с помощью IP
    url = f"https://ipinfo.io/{ip_address}/json"  
    response = requests.get(url).json()  
    city = response["city"]  
  
    with open("secrets.json", "r", encoding="utf-8") as file:  
        owm_api_key = json.load(file)["openweathermap.org"]  
  
    # Обращаемся к погодному сервису за погодой в этом городе   
    url = (  
        "https://api.openweathermap.org/data/2.5/weather?q={0}&"  
        "units=metric&lang=ru&appid={1}"    
    ).format(city, owm_api_key)  
    weather_data = requests.get(url).json()  
    temperature = weather_data["main"]["temp"]  
    temperature_feels = weather_data["main"]["feels_like"]  
  
    # Если есть прошлые измерения, сравниваем их с текущими результатами 
    has_previous = False  
    history = {}  
    history_path = Path("history.json")  
    if history_path.exists():  
        with open(history_path, "r", encoding="utf-8") as file:  
            history = json.load(file)  
        record = history.get(city)  
        if record is not None:  
            has_previous = True  
            last_date = datetime.fromisoformat(record["when"])  
            last_temp = record["temp"]  
            last_feels = record["feels"]  
            diff = temperature - last_temp  
            diff_feels = temperature_feels - last_feels  
  
    # Записываем текущий результат, если прошло достаточно времени  
    now = datetime.now()  
    if not has_previous or (now - last_date) > timedelta(hours=6):  
        record = {  
            "when": datetime.now().isoformat(),  
            "temp": temperature,  
            "feels": temperature_feels  
        }  
        history[city] = record  
        with open(history_path, "w", encoding="utf-8") as file:  
            json.dump(history, file)  
  
    # Выводим результат 
    msg = (  
        f"Temperature in {city}: {temperature:.0f} °C\n"  
        f"Feels like {temperature_feels:.0f} °C"    
    )  
    if has_previous:  
        formatted_date = last_date.strftime("%c")  
        msg += (  
            f"\nLast measurement taken on {formatted_date}\n"  
            f"Difference since then: {diff:.0f} (feels {diff_feels:.0f})"  
        )  
    print(msg)

(источник)

Пока что мы можем написать только E2E тест:

def test_local_weather(capsys: pytest.CaptureFixture):  
    local_weather()  
  
    assert re.match(  
        (            
            r"^Temperature in .*: -?\d+ °C\n"  
            r"Feels like -?\d+ °C\n"  
            r"Last measurement taken on .*\n"  
            r"Difference since then: -?\d+ \(feels -?\d+\)$"  
        ),  
        capsys.readouterr().out  
    )

(источник)

Он выполняет большую часть кода; но для тестирования важен не только хороший показатель покрытия строк. Лучше думать о покрытии поведения — с какими системами взаимодействует код и какие у него случаи использования.

Наш код делает следующее:

  • вызывает внешние сервисы для получения данных;

  • сохраняет данные и загружает прошлые изменения;

  • генерирует сообщение на основе данных;

  • показывает сообщение пользователю.

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

Trying to test bad code
Пытаемся тестировать плохой код

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

Как это сделать в текущей версии кода?

  • Можно физически отправиться в место, которое используемый нами сервис не сможет распознать. Это трудоёмкий и нестабильный способ воспроизведения пограничного случая, с которым мы не построим рабочую стратегию тестирования.

  • Можно использовать мок. Библиотека requests-mock для Python предоставляет  модуль requests, который не делает никаких запросов, а просто возвращает нужные значения.

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

Как сделать код более тестируемым? Для начала, разобьём его на отдельные функции в соответствии с зонами ответственности (I/O, логика приложения и т.д.), чтобы каждую можно было выполнить по отдельности.

Шаг 2: Создание отдельных функций

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

class DatetimeJSONEncoder(json.JSONEncoder):
    # Ввод/вывод: сохранение истории измерений
    def default(self, o: Any) -> Any:
        if isinstance(o, datetime):
            return o.isoformat()
        elif is_dataclass(o):
            return asdict(o)
        return super().default(o)


def get_my_ip() -> str:
    # Ввод-вывод: получение IP от сервиса
    url = "https://api64.ipify.org?format=json"
    response = requests.get(url).json()
    return response["ip"]


def get_city_by_ip(ip_address: str) -> str:
    # Ввод-вывод: получение города по IP от сервиса
    url = f"https://ipinfo.io/{ip_address}/json"
    response = requests.get(url).json()
    return response["city"]


def measure_temperature(city: str) -> Measurement:
    # Ввод-вывод: загрузка API-ключа из файла
    with open("secrets.json", "r", encoding="utf-8") as file:
        owm_api_key = json.load(file)["openweathermap.org"]

    # Ввод-вывод: загрузка измерения из сервиса погоды
    url = (
        "https://api.openweathermap.org/data/2.5/weather?q={0}&"
        "units=metric&lang=ru&appid={1}"
    ).format(city, owm_api_key)
    weather_data = requests.get(url).json()
    temperature = weather_data["main"]["temp"]
    temperature_feels = weather_data["main"]["feels_like"]
    return Measurement(
        city=city,
        when=datetime.now(),
        temp=temperature,
        feels=temperature_feels
    )


def load_history() -> History:
    # Ввод-вывод: загрузка истории из файла
    history_path = Path("history.json")
    if history_path.exists():
        with open(history_path, "r", encoding="utf-8") as file:
            history_by_city = json.load(file)
            return {
                city: HistoryCityEntry(
                    when=datetime.fromisoformat(record["when"]),
                    temp=record["temp"],
                    feels=record["feels"]
                ) for city, record in history_by_city.items()
            }
    return {}


def get_temp_diff(history: History, measurement: Measurement) -> TemperatureDiff|None:
    # Логика приложения: вычисление разности температур
    entry = history.get(measurement.city)
    if entry is not None:
        return TemperatureDiff(
            when=entry.when,
            temp=measurement.temp - entry.temp,
            feels=measurement.feels - entry.feels
        )


def save_measurement(history: History, measurement: Measurement, diff: TemperatureDiff|None):
    # Логика приложения: проверка необходимости сохранения измерения
    if diff is None or (measurement.when - diff.when) > timedelta(hours=6):
        # Ввод-вывод: сохранение нового измерения в файл
        new_record = HistoryCityEntry(
            when=measurement.when,
            temp=measurement.temp,
            feels=measurement.feels
        )
        history[measurement.city] = new_record
        history_path = Path("history.json")
        with open(history_path, "w", encoding="utf-8") as file:
            json.dump(history, file, cls=DatetimeJSONEncoder)


def print_temperature(measurement: Measurement, diff: TemperatureDiff|None):
    # Ввод-вывод: форматирование и вывод сообщения пользователю
    msg = (
        f"Температура в {measurement.city}: {measurement.temp:.0f} °C\n"
        f"Ощущается как {measurement.feels:.0f} °C"
    )
    if diff is not None:
        last_measurement_time = diff.when.strftime("%c")
        msg += (
            f"\nПоследнее измерение выполнено {last_measurement_time}\n"
            f"Разность с тех пор: {diff.temp:.0f} (ощущается {diff.feels:.0f})"
        )
    print(msg)


def local_weather():
    # Логика приложения (Пользовательский сценарий)
    ip_address = get_my_ip() # Ввод-вывод
    city = get_city_by_ip(ip_address) # Ввод-вывод
    measurement = measure_temperature(city) # Ввод-вывод
    history = load_history() # Ввод-вывод
    diff = get_temp_diff(history, measurement) # Приложение
    save_measurement(history, measurement, diff) # Приложение, Ввод-вывод
    print_temperature(measurement, diff) # Ввод-вывод

(источник)

Мы использовали конструкцию dataclass, чтобы сделать возвращаемые значения функций менее запутанными. Это классы Measurement, HistoryCityEntry и TemperatureDiff, которые находятся в новом модуле типизации. Визуально новую структуру кода можно представить так:

схема от
схема от @VadimLunin

В результате изменений код стал более согласованным — содержимое каждой функции, как правило, относится к выполнению только одной задачи. Это принцип единственной ответственности («S» из SOLID, «single responsibility principle»).

Правда, нам в этом отношении всё ещё есть куда расти: в measure_temperature мы выполняем операции ввода-вывода и для файловой системы (чтение секрета с диска), и для веб (отправка запроса к сервису). К этому мы вернёмся позже. 

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

Тесты для шага 2

@pytest.mark.slow
def test_city_of_known_ip():
    assert get_city_by_ip("69.193.168.152") == "Astoria"


@pytest.mark.fast
def test_get_temp_diff_unknown_city():
    assert get_temp_diff({}, Measurement(
        city="New York",
        when=datetime.now(),
        temp=10,
        feels=10
    )) is None

Благодаря тому, что функции стали более специализированными, у нас отделились друг от друга более быстрые (логика и вывод на консоль) и медленные (другие операции ввода-вывода) части приложения. Соответственно, мы можем пометить отдельные тесты как быстрые или медленные (с помощью пользовательской маркировки Pytest, определённой в конфигурационном файле проекта — например, pytest.mark.fast). 

Обратите внимание на тест к выводу на печать:

@pytest.mark.fast
def test_print_temperature_without_diff(capsys: pytest.CaptureFixture):
    print_temperature(
        Measurement(
            city="My City",
            when=datetime(2023, 1, 1),
            temp=21.4,
            feels=24.5,
        ),
        None
    )

В прошлой версии чтобы проверить вывод, нам пришлось бы выполнять всё приложение, и управлять выводом было бы очень трудно. Теперь же мы можем передать функции print_temperature всё что угодно.

Несмотря на сделанные улучшения, у текущей версии кода по-прежнему много недостатков. 

Хрупкость

Наши тесты для высокоуровневой функциональности напрямую взаимодействуют с низкоуровневой логикой. Например, E2E тест, который мы написали в первом шаге (test_local_weather), полагается на то, что вывод отправляется именно в консоль. Если мы отправим вывод в другой канал, тест сломается. То же произойдёт, если изменится сервис, определяющий IP.

Эта критика не относится к тестам, написанным специально для низкоуровневых подробностей (например, test_print_temperature_without_diff) — логично, что их нужно менять, когда меняется соответствующий код. Но E2E тест был написан не для тестирования печати или сервисов.

Кроме того, наши тесты очень чувствительны к реализации некоторых функций — например, если бы мы разбили функцию measure_temperature на две для улучшения согласованности, вызывающие её тесты сломались бы.

Итак, написанные нами тесты очень хрупкие, а это увеличивает трудозатраты при изменении кода, так как тесты тоже приходится менять.

Зависимость от внешних систем

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

Например, если перестанет работать сервис IP, то весь код, следующий за определением IP внутри local_weather, окажется недосягаемым, и тесты для него работать не будут. А если у вас возникнут проблемы с интернет-соединением, то не запустится вообще ни один тест, хотя тестируемый код может быть в полном порядке.

Нельзя протестировать пользовательский сценарий в отдельности

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

Избыточное покрытие

Эта проблема связана с предыдущей. С каждым запуском нашей тестовой сюиты сетевые функции и функции чтения/записи выполняются дважды: E2E тестом из первого шага и более целевыми тестами из второго шага. 

Избыточное покрытие обычно не лучший симптом. Во-первых, сервисы, которые мы используем, считают вызовы, поэтому лучше относиться к ним экономно. Во-вторых, в долгосрочной перспективе избыточное покрытие означает излишнюю ресурсоёмкость.

Выводы

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

Мы добились большей согласованности кода благодаря тому, что разбили его на отдельные функции. Сама по себе эта операция в общем-то очевидная, но важно то, что к ней нас подтолкнула необходимость протестировать отдельные пути выполнения кода. Тесты подсказали нам, что в структуре кода есть изъяны, и заставили их исправить.

Комментарии (0)