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