Крах на финишной прямой, или как написать нетестируемый код

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Это история о том, как потерпеть фиаско, имея хорошо написанный и протестированный в боевых условиях работающий код и даже написанную документацию. Изначально я собирался делать анонс своей библиотеки, но что-то пошло не так. Поэтому начнём за здравие -- постановка и формализация задачи, описание возможностей и батареек. А закончим за упокой -- вопросами, как всё это теперь тестировать?

Логотип systempy
Логотип systempy

Неудавшийся анонс. SystemPY, в девичестве lifecycle

Начну с формализации проблемы. Современное asyncio приложение состоит из множества компонентов типа Redis, БД, брокера и прочих. Эти компоненты необходимо инициализовать и останавливать в правильном порядке. Также очень хочется из коробки работающий reload. Вишенкой же на торте будет точно так же инициализорованный REPL, а также простота написания одноразовых скриптов для разрешения косяков на проде. Читать статью я рекомендую, глядя одним глазом в документацию. Согласитесь, нет смысла её просто переводить, вместо этого её стоит расширить

Ещё раз и внимательно

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

Я с ходу настаиваю на добавлении REPL -- ещё одной точки входа

Анализ проблемы и подхода

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

Этапы жизненного цикла приложения

Я выделил 6 основных этапов жизненного цикла приложения:

  • on_init -- код исполняется единожды только в момент инициализации

  • pre_startup -- код исполняется до запуска event loop в том числе во время reload

  • on_startup -- код исполняется сразу после инициализации event loop

  • on_shutdown -- код исполняется, когда приложение приняло решение остановиться, но event loop ещё работает

  • post_shutdown -- код исполняется сразу после остановки / очистки event loop. В случае reload следующим этапом будет pre_startup

  • on_exit -- код исполняется единожды только в момент остановки приложения

Сценарии работы приложения

Я выделил 3 основных сценария:

  • Ведомое приложение, запускаемое каким-то фреймворком

  • "Сам себе хозяин" -- демоны, скрипты и REPL

  • Ведущее приложение или фреймворк, запускающий клиентское приложение

Кастомные этапы жизненного цикла

Есть необходимость выполнить код прямо перед или прямо после какого-то существующего этапа жизненного цикла? Легко

что они там на самом деле все кастомные. Только тсс
что они там на самом деле все кастомные. Только тсс

Допустим, есть необходимость дважды вклиниться прямо перед on_startup. Для этого необходимо создать интерфейс:

@util.register_target
class MyCustomTarget(Target):
    @util.register_hook_before(Target.on_startup)
    @util.register_target_method("forward")
    async def before_on_startup(self) -> None: ...

    @util.register_hook_before(before_on_startup)
    @util.register_target_method("gather")
    async def before_2_on_startup(self) -> None: ...

После от этого интерфейса наследоваться. В результате методы будут выполнены в следующем порядке: before_2_on_startup -> before_on_startup -> on_startup

Естественно, вклинивать этапы можно пока не надоест

Хороший пример кастомных этапов жизненного цикла рассмотрен в документации

Боль, ненависть, REPL

Казалось бы, что может пойти не так, когда батарейки в комплекте? Пройдёмся по болевым точкам и откроем стандартный asyncio REPL:

python3 -m asyncio
asyncio REPL 3.9.2 (default, Feb 28 2021, 17:03:44) 
[GCC 10.2.1 20210110] on linux
Use "await" directly instead of "asyncio.run()".
Type "help", "copyright", "credits" or "license" for more information.
>>> import asyncio
>>> 

Номинально работает, но:

  • меня бесит некорректная обработка Ctrl+C

  • А что с автодополнениями по Tab? Почему не работают?

  • Окей, как теперь инициализировать окружение? Правильно, ручками

Так вот, требования к REPL

Ничего сверхестественного:

  • Корректная обработка Ctrl+C

  • Автодополнения должны работать, если есть readline

  • Окружение должно инициализироваться компонентно и так же останавливаться

  • Добавление собственных глобальных переменных (импортированных модулей) в неймспейс

Всё это тоже работает. Больше примеров в документации. В процессе пришлось через ctypes лезть в недра readline и заниматься знаменитым брутфорс-программированием. Питонячий readline -- это стыдоба

Ну и как я умудрился зайти в тупик?

Согласитесь, тестировать своим продом не совсем правильно. Вопрос необходимо формализовать. С учётом возможностей, простейшая с виду задача превращается в рак мозга. Есть непроверенный функционал -- метод reload, вызов которого должен запустить процесс остановки и последующего перезапуска. Очевидно, он должен вести себя одинаково во всех сценариях, а все нюансы -- половые трудности уже systempy. А различий есть у меня! В REPL вместо штатного reload необходимо выполнять reload_threadsafe -- и это совсем не одно и то же. У меня ломается мозг, как проверить работу метода reload в других сценариях работы

В то же время есть казалось бы простые моменты, которые протестировать и можно. Например, регистрируется ли Target, и даже в каким порядке выполняются методы, где systempy кидает исключения, и так далее. Смех в том, что там отсутствуют оси изменений

А ещё очень неприятно ломается REPL, и в случае ошибок забирает за собой и терминал -- спасает только закрытие и открытие нового

Если ли у хабра идеи? Или воспользоваться запасным сценарием?

Запасной сценарий для тестов
В принципе можно и не тестировать
В принципе можно и не тестировать

Источник: https://habr.com/ru/post/684286/


Интересные статьи

Интересные статьи

О, статьи — их тысячи! Их пишут сейчас в огромных масштабах все, кто только пожелает. Но как начать их писать, если вы программист? Каким должно быть введение и как не погубить текст? И э...
Лор (от англ. Lore) — это история вселенной игры, описывающая почему события и персонажи в игре именно такие, что происходило до момента, когда игрок познакомился с персонажами. Это в...
Многие компании в определенный момент приходят к тому, что ряд процессов в бизнесе нужно автоматизировать, чтобы не потерять свое место под солнцем и своих заказчиков. Поэтому все...
Добрый день, уважаемые коллеги! Меня зовут Александр, я разработчик HTML5 игр. В одной из компаний, куда я отправлял свое резюме, мне предложили выполнить тестовое задание. Я согласился и, ...
Каждый лишний элемент на сайте — это кнопка «Не купить», каждая непонятность или трудность, с которой сталкивается клиент — это крестик, закрывающий в браузере вкладку с вашим интернет-магазином.