
(Статья — результат совместной работы с Максимом Степановым)
Когда начинаешь писать тесты к коду, иногда возникает ощущение, что пытаешься расчесать запутанные волосы, и чем больше дёргаешь, тем больше узлов находишь. Это полезный сигнал, к которому стоит прислушиваться: плохая тестируемость подсказывает, что у кода есть изъяны в архитектуре.
Связанный код, который сложно поддерживать и расширять, сложно и тестировать. Как сказал Боб Мартин:
«Тестируемый код — синоним разъединённого кода»
А значит, тестируемость может быть маркером хорошей архитектуры. Именно это мы и попробуем здесь продемонстрировать.
Мы напишем тесты для примитивного скрипта на 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 )
(источник)
Он выполняет большую часть кода; но для тестирования важен не только хороший показатель покрытия строк. Лучше думать о покрытии поведения — с какими системами взаимодействует код и какие у него случаи использования.
Наш код делает следующее:
-
вызывает внешние сервисы для получения данных;
-
сохраняет данные и загружает прошлые изменения;
-
генерирует сообщение на основе данных;
-
показывает сообщение пользователю.
Сейчас мы не можем протестировать ничего из этого отдельности, потому что всё свалено в одну функцию.
Другими словами, нам сложно протестировать различные пути выполнения кода. Например, было бы полезно проверить поведение в случае, когда сервис возвращает пустое значение города. Даже если бы этот случай был обработан в коде (а мы это сделать забыли), хорошо было бы протестировать вариант, когда 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, которые находятся в новом модуле типизации. Визуально новую структуру кода можно представить так:
В результате изменений код стал более согласованным — содержимое каждой функции, как правило, относится к выполнению только одной задачи. Это принцип единственной ответственности («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/
Добавить комментарий