Тесты не лгут — прислушивайтесь к ним. Часть 1

от автора

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

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

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

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

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

Мы напишем тесты для примитивного скрипта на 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 тестом из первого шага и более целевыми тестами из второго шага. 

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

Выводы

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/930742/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *