Привет, меня зовут Кристина Климовских, я — Python Developer в команде DataMining. Главная задача моей команды — поддерживать бесперебойный флоу добычи данных для обогащения и актуализации справочника 2ГИС.

Ежедневно наши парсеры собирают разношёрстные данные из более 700 источников — это поставщики адресов магазинов, отзывов к заведениям, расписания общественного транспорта, ссылок на запись в салоны красоты и всё остальное, что можно впоследствии найти в 2ГИС. Поддерживать этот «зоопарк» парсеров нам помогают тесты — в каждом из проектов добычи мы стараемся покрывать новый функционал тестами и использовать их при раскатке в CI.

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

Далее детально про архитектуру проекта для погружения в больные места.

Внутри проекта — микросервисная архитектура с бэком, фронтом, базой, воркерами, на которых крутятся парсеры, и task creator'ом, который создаёт в базе таски для парсинга источников по расписанию. Всё это собирается по контейнерам и деплоится в кубер.

UI — TypeScript и React, база — Postgres 12.9, API — Flask + Gunicorn.

Task Creator — в основе питонячий класс, наследуемый от библиотеки Advanced Python Scheduler (APScheduler), который раз в минуту опрашивает апиху про имеющееся расписание запусков в базе, и, в соответствии с этим расписанием, либо создаёт таски на парсинг, либо корректирует свои джобы запуска.

Worker'ы — слушают канал в базе Postgres. При появлении в очереди таски на парсинг поднимают отдельный питонячий процесс с парсером, следят за процессом и обрабатывают ошибки.

[Optional] Garbage Collector — самописное решение на питоне. Помогает нам вычищать таблицы от старых версий данных. Для мониторинга мы сохраняем все результаты запуска парсеров в БД — версионность позволяет нам вовремя реагировать на аномальные потери данных. Бесконтрольное сохранение таких объёмов может привести к распуханию таблиц, так у нас получается избежать негативного влияния БД на производительность всего сервиса.

Итого для полной боевой готовности нашего ресурса требуется 5 сервисов (6 с Garbage Collector'ом).

И так, наша цель — замедлить разработку так, чтобы фичи докатывались до продукта как можно дольше.

Для этого я подготовила 7 советов с примерами для каждого из них.

Пишите только интеграционные тесты

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

В проекте, о котором идёт речь, «живут» около 70 парсеров под различные источники данных. До определённого времени в команде не было соглашения писать тесты под каждый новый парсер, вместо этого использовался один большой интеграционный тест, который повторяет весь флоу — от создания таски на парсинг до складывания итоговых результатов в БД. Выглядит он примерно так:

class TestClass:
    @pytest.mark.parametrize(
   	"source, source_key, override_setting",
   	[("1", "supermarkets", {})]
    )
    def test_source(self, source, source_key, override_setting):
      mocked_url = f"http://external-api-mock/by-source/{source_key}"
      
      # создаём задачу на парсинг c mocked_url в настройках парсера
      create_task(source_key, mocked_url, override_setting)
      wait_until_complete()
  
     	# сверяем с ожидаемым эталоном
      actual_result = api.get_last_result(source_key)
      expected = json.load(open(f"_results/by_source/{source_key}/result"))
      assert result == expected

external‑api‑mock — адрес nginx контейнера в docker сети. Его мы подставляем при создании таски в тесте вместо настоящего урла источника.

by‑source/{source_key} — локальное расположение тестовых данных для конкретного источника. В папке мы храним файлы, соответствующие ответам по конкретным endpoint'ам — например, если нам нужно сходить на https://<new_source_url>/api/points/?city=6b52c82b‑f1f3–4b51–8b5c-751a9dcf53c2, в папке будет следующая структура:

Иными словами в тесте происходит подмена урла из настроек парсера в базе на адрес nginx'а, который отдаёт парсеру подготовленные нами ранее данные. Получается, что для каждого нового парсера мы:

  1. создаём таску;

  2. ожидаем, когда воркеры возьмут её в работу;

  3. воркеры берут таску, запускают парсер с изменённым урлом;

  4. парсеры обращаются на адрес nginx'а и получают мокнутые тестовые данные;

  5. производят парсинг;

  6. складывают данные в базу;

  7. тест «понимает», что таска завершена, сравнивает результаты в базе с эталонными данными в соседней папке.

Пункты 2–6 — ожидание тестом, когда появятся данные для сравнения. При изменении логики парсера в текущей парадигме приходилось пересобирать слои в контейнерах воркеров, снова запускать тест по источнику и ожидать пробегания всего флоу.

С добавлением нового парсера параметризация разрасталась — мы просто докидывали tuple c появившимся парсером. Такой тест прост и понятен в употреблении, но у него есть свои критичные моменты:

  1. требуется заблаговременно добавить источник в базу;

  2. нужно пересобирать контейнер с воркером заново после каждого изменения;

  3. занимает много времени при деплое;

  4. тестируем только положительные кейсы;

  5. не умеем тестировать POST‑запросы — по некоторым источникам парсеры ходят за разными данными по одному и тому же endpoint'у, изменяя только body запроса, настроить nginx на такой случай мы не смогли.

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

Из этого кейса вытекают последующие антисоветы.

Храните полный слепок данных для тестирования

Чтобы тесты бежали медленнее, нагружайте их большим количеством одинаковых тест‑кейсов.

Наш кейс — для интеграционного теста мы брали полный ответ от источника и складывали в оригинальном виде без каких‑либо сокращений к себе в проект. Для оценки результатов парсинга дополнительно хранили эталонный результат из БД в json файле.

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

При очистке тестовых данных мы ориентировались на максимально уникальные вариации — например расписание автобуса в будние или праздничные дни, отзывы с фотографиями и без и т. д. После сокращения данных мы ускорили пробегание интеграционника до 7–10 минут.

Используйте как можно больше внешних сервисов для тестирования

Чем больше контейнеров, которые нужно уметь «готовить», тем больше должен быть шаринг знаний и тем медленнее будут решаться проблемы.

На скрине синим изображены «кубики» сервисов, необходимых для работоспособности всего проекта. Оранжевым — контейнеры, которые мы поднимаем дополнительно для интеграционных тестов. При переводе парсеров на юнит‑тестирование все правые кубики, логично, не нужны.

Как я писала раньше, работоспособность парсеров мы проверяли, прогоняя через весь флоу от создания таски на парсинг до сверки результатов парсинга с эталоном. Имитацией источника здесь выступает nginx и, чтобы его использовать, нужно правильно его подготовить — прокинуть вольюм в docker‑compose‑файл и настроить через конфигурационный файл. Но настроить его на проксирование POST‑запросов мы не смогли и поэтому некоторые парсеры остались непокрытыми даже таким уровнем тестирования.

Выход — мы решили не проксировать хождение на внешние источники, а прокидывать внутрь парсеров самописные классы http‑клиентов в зависимости от окружения.

Базовый класс клиента содержит абстрактные методы .get() и .post(), которые должны быть реализованы наследниками.

class BaseHTTPResourceClient(ABC):
   type: HTTPClientType
   settings_type: Type[Any]

   def __init__(self, base_url: str, settings: dict):
       self._base_url = base_url
       self.settings = self.settings_type(**settings)

   @abstractmethod
   def get(self, endpoint=None, params=None, **kwargs) -> Response:
       ...

   @abstractmethod
   def post(self, endpoint=None, data=None, json=None, **kwargs) -> Response:
       ...

Так изначально у нас получилось два типа клиентов — HTTPClientType.REAL и HTTPClientType.FAKE. Первый — обёртка над библиотекой requests, второй — реализация чтения файлов с тестовыми данными.

class FakeHTTPResourceClient(BaseHTTPResourceClient):
 	type = HTTPClientType.FAKE

 	def get(self, endpoint=None, params=None, **kwargs):
      path = self._get_path(endpoint, params)
      return MockedResponse(self._get_file_content(path))

 	def post(self, endpoint=None, data=None, **kwargs):
      params = self._data_to_str(data or json) if data or json else ''
      path = self._get_path(endpoint, params)
      return MockedResponse(self._get_file_content(path))
    
    def _get_path(self, endpoint: Optional[str] = None, params: Optional[str] = None) -> str:
      if endpoint and not endpoint.startswith('/'):
        endpoint = '/' + endpoint
      return ''.join(filter(None, [self._base_url, endpoint, params]))

Так с помощью фейкового клиента мы смогли весь body POST‑реквеста превращать в путь до нужного файла.

Далее гибкость в управлении клиентами позволила нам не только справиться с проблемой в тестах, но и усовершенствовать клиент для реального хождения на источники. Учитывая возможность прокидывания дополнительных настроек, мы смогли вынести логику ретраев из парсеров в http‑клиента.

Таким образом мы постепенно вытесняем nginx как дополнительный контейнер для тестирования и развязываем себе руки для развития собственных фичей.

Тестируйте только положительные кейсы

Отлов ошибок в продакшене — отличный способ занять разработчиков.

Для эмуляции нестабильного поведения источника, на который наши парсеры ходят за данными, в одном тесте мы использовали внешний HTTP‑прокси — toxy. При этом каждый запуск этого теста сопровождался реальным хождением на источник, что недопустимо. Для замены toxy мы написали ещё одного клиента — HTTPClientType.CUSTOM_500. В его реализации методов .get() и .post() мы «докрутили» логику с управляемыми пятисотками — сколько их нужно сделать до получения 200 и нужно ли получать 200 вообще.

Так мы не только полностью избавились от toxy, но и смогли поставить на рельсы таких проверок все парсеры, которые поддерживают прокидывание разных типов клиентов. В дальнейшем нам видится добавление также и HTTPClientType.CUSTOM_400 клиента для контроля поведения парсеров при клиентских ошибках.

Обязательно используйте контейнеры для запуска линтеров и юнит-тестов

Так нам обеспечена пересборка контейнеров каждый раз после множественных mypy фиксов.

До определённого момента запуск линтеров у нас пробегал только в собранном контейнере с кодом — так был настроен флоу работы, в котором всё взаимодействие с кодом шло через docker. Мы вытащили запуск линтеров и юнит тестов в Makefile.

local-checks:
  @echo "--- FORMAT CHECKING: flake8"
  @PYTHONPATH=./:${PYTHONPATH} flake8 ./
  @echo "--- UNIT TESTS: pytest"
  @PYTHONPATH=./:${PYTHONPATH} pytest -vv --junitxml=report_unit.xml tests/unit/

Это позволило нам воспользоваться помощью прекоммитов.

Не используйте прекоммиты

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

Прекоммиты запускаются автоматически при коммите изменений и позволяют не создавать коммит, если что‑то в скрипте пошло не так. Пример нашего pre‑commit.sh с вызовом make local-checks из секции выше:

#!/usr/bin/env bash

# HOWTO:
# cp pre-commit.sh .git/hooks/pre-commit
# chmod +x .git/hooks/pre-commit
set -e
make local-checks

Такое решение позволяет отловить возможные ошибки ещё до пуша в репозиторий и не плодить множественные коммиты с линт фиксами. Тем не менее в случае пожара всегда можно закоммиться без прекоммитов, используя ключ ‑-no‑verify.

Удалите тесты

С ними столько проблем, что они, кажется, не помогают, а только отдаляют нас от релиза.

Проекту вредят не тесты, а неоправданное усложнение системы. Разработка архитектуры, неважно, архитектуры тестирования или сервисов, должна всегда учитывать динамику — проект постоянно развивается и однажды одно гениальное решение может застопорить всю команду. Всегда стоит искать способы упрощения и оптимизации.

И напоследок — полезный совет: приходите в 2ГИС, у нас много разных вакансий для тестировщиков.

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


  1. Silmakrium
    22.12.2023 10:40

    Спасибо, на 10 минут погрузился в шкуру того, кем хочу стать в перспективе:) Приятно почитать, как это работает в живую и как решаются подобные проблемы не в теории.


  1. SergeyMizrael
    22.12.2023 10:40

    Очень крутая статья! Особенно будет полезна для тех, кто думает как организовать архитектуру тестирования для своего проекта!