Русский
Русский
English
Статистика
Реклама

Pytest

Тесты в Python все основные подходы, плюсы и минусы. Доклад Яндекса

02.09.2020 12:08:46 | Автор: admin
Перед вами доклад Марии Зеленовой zelma разработчика в Едадиле. За час Маша рассказала, в чём состоит тестирование программ, какие тесты бывают, зачем их писать. На простых примерах можно узнать про библиотеки для тестирования Python-кода (unittest, pytest, mock), принципы их работы и отличия между ними.


Добрый вечер, меня зовут Маша, я работаю в отделе подготовки анализа данных Едадила, и сегодня у нас с вами лекция про тестирование.



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



Мне хотелось бы начать с примера. Я попробую на очень страшных примерах объяснить, почему тесты писать стоит.

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

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

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

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

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



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

Но модуль, который был на земле, отдавал команды в системе СИ, в метрической системе. А модуль на орбите Марса думал, что это британская система мер, неправильно это интерпретировал.

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

Теперь я поговорю про более прозаичные причины, зачем стоит писать тесты. Давайте поговорю про каждый пункт в отдельности:

  • Тесты проверяют работоспособность кода и немного вас успокаивают. В тех случаях, для которых вы тесты написали, вы можете быть уверены, что ваш код работает если, конечно, вы его нормально написали. Сон крепче. Это очень важно.
  • Тесты проверяют исполнение контрактов поведения кода. Поясню. Вот у вас есть код, который в каких-то краевых или редких условиях, непонятных кейсах, должен вести себя определенным образом. Вы это обговорили с заказчиком, или это написано в ТЗ. В общем, вы договорились, что он должен вести себя именно так.

    Дальше может прийти человек, который захочет написать код рядом или что-то порефакторить, вынести в отдельную функцию в общем, что-то с этим кодом сделать. И если он об этих договоренностях не знает, он их может легко сломать. Но если вы написали тесты, то у него сломается тест до того, как он успеет залить это в продакшен. Скорее всего, он пойдет, подумает головой, посмотрит git blame, спросит, что это было, или просто восстановит правильное поведение, что тоже неплохо.
  • Тесты позволяют проверять взаимодействие старого и нового кода. Представим ситуацию, похожую на второй пункт. У вас есть код, он работает. Пришел человек, пишет новый код. Захотел использовать кусочек старого кода. Что-то куда-то вынес, что-то где-то поправил. На случай, если он что-то сломал, хорошо бы, чтобы у вас были тесты, которые тоже сломаются. Потому что иначе вы можете обнаружить это в продакшене в какой-нибудь очень неприятный момент.
  • Тесты поощряют написание кода слабого зацепления. Что это значит? Слабое зацепление это когда ваш код распадается на отдельные обособленные кусочки. Почему это связано с тестированием? Потому что если у вас есть одна функция, которая написана простыней на четыре экрана, и там куча параметров, то вы на нее посмотрите и, скорее всего, подумаете: на нее очень неудобно писать тест, придется кучу всего передать. У вас там 500 тест-кейсов, вы смотрите на это, и у вас голова разрывается. Разбейте эту огромную функцию на много маленьких и протестируйте каждую в отдельности. Это намного проще и заодно сделает код более читаемым.
  • Часто тесты единственная понятная документация к коду. Такой небольшой лайфхак. Иногда вы читаете код. Это может быть опенсорсный код внешней библиотеки, про которую вы ничего не знаете, и сидите, смотрите на нее как баран на новые ворота и вообще не понимаете, что она делает и с какой стороны туда зайти.

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

    Бывают приемочные тесты: метрики качества или какие-нибудь еще. Если они прошли, это повод остановиться и пойти делать что-то следующее, код можно катить в продакшен. Возможно, стоит завести себе тикет, чтобы что-нибудь улучшить, когда появится свободное время.

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

Процесс тестирования делится на тестирование черного ящика, белого и серого.



Тестирование черного ящика процесс, когда тестировщику ничего не известно про то, что внутри. Он, как обычный пользователь, что-то делает, не зная никаких особенностей реализации.

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

Тестирование серого ящика нечто промежуточное. Это когда вам известны какие-то детали реализации, но не вся целиком.

Также процесс тестирования можно поделить на ручной, полуавтоматический и автоматический. Ручное тестирование делает человек. Допустим, кнопочки в браузере нажимает, куда-то кликает, смотрит, что у него сломалось или не сломалось. Полуавтоматическое тестирование это когда тестировщик запускает тестовые сценарии. Можно сказать, что мы с вами находимся в такой ситуации, когда локально свои тесты запускаем и прогоняем. Автоматическое тестирование не предполагает участия человека: тесты должны запускаться автоматически, а не руками.

Также тесты можно поделить по уровню детализации. Здесь их принято делить на юнит- и интеграционные тесты. Тут могут быть разночтения. Есть люди, которые любые автотесты называют юнит-тестами. Но более классическое деление примерно такое.

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

Тесты на наш код это юнит- и интеграционные тесты. Есть люди, которые считают, что надо писать только интеграционные тесты. Я к таким не отношусь, считаю, что все должно быть в меру, и полезны как юнит-тесты, когда вы тестируете одну компоненту, так и интеграционные тесты, когда вы тестируете что-то большое.

Почему я так считаю? Потому что юнит-тесты обычно быстрее. Когда надо что-то подебажить, вас будет очень раздражать, что вы нажали на кнопку запустить тест, а дальше три минуты ждете, пока стартанет база данных, сделаются миграции, произойдет что-то еще. Для таких случаев полезны юнит-тесты. Их можно запускать быстро и удобно, запускать по одному. Но когда вы юнит-тесты починили, прекрасно, давайте чинить интеграционные тесты.

Интеграционные тесты вещь тоже очень нужная, большой плюс в том, что они больше про систему. Еще один большой плюс: они более устойчивы к рефакторингу кода. Если вы с большей вероятностью перепишете какую-то маленькую функцию, то общий пайплайн вы вряд ли будете менять с такой же частотой.



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

Smoke-тесты тесты на критическую функциональность, самые первые и самые простые тесты. Если они сломались, то больше не надо тестировать, а надо идти их чинить. Допустим, приложение запустилось, не упало, отлично, smoke-тест прошел.

Бывают regression-тесты тесты на старую функциональность. Допустим, вы катите новый релиз и должны проверить, что в старом ничего не сломали. Это задача регрессионных тестов.

Бывают тесты совместимости, тесты установки. Они проверяют, что у вас все корректно работает в разных ОС и разных версиях ОС, в разных браузера и разных версиях браузера.

Acceptance-тесты приемочные тесты. Про них я уже говорила, они говорят о том, можно ваше изменение катить в прод или нет.

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

Дальше бывают performance- и стресс-тесты, нагрузочное тестирование. Они проверяют, допустим, как ваше приложение держит нагрузку. Есть какой-то код. Вы посчитали, сколько у него будет пользователей, запросов, какой РПС, сколько будет приходить запросов в секунду. Проэмулировали эту ситуацию, запустили, посмотрели держит, не держит. Если не держит думайте, что делать дальше. Возможно, оптимизировать код или увеличить количество железа, есть разные решения.

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

Немного отдельно тут стоят линтеры. Про линтеры я еще чуть позже немного скажу, это тесты оформления кода, стайл-гайд. В Python нам повезло, есть PEP8 понятный стайлгайд, которому все должны следовать. И когда вы что-то пишете, вам обычно сложно следить за кодом. Предположим, вы забыли поставить пустую строку или сделали лишнюю, или оставили слишком длинную строчку. Это мешает, потому что вы привыкаете, что у вас код написан в едином стиле. Линтеры позволяют такие вещи автоматически отловить.

С теорией все, дальше я буду рассказывать про то, что есть в Python.



Вот список некоторых библиотек. Я не буду рассказывать подробно про все из них, но про большую часть буду. Про unittest и pytest мы, конечно, поговорим. Это библиотеки, которые используются непосредственно для написания тестов. Mock вспомогательная библиотека по созданию mock-объектов. Про нее мы тоже поговорим. doctest модуль для тестирования документации, flake8 линтер, на них тоже посмотрим. Про pylama и tox я рассказывать не буду. Если вам будет интересно, можете посмотреть сами. Pylama тоже линтер, даже, металинтер, он объединяет в себе несколько пакетов, очень удобный и хороший. А библиотека tox нужна, если вам необходимо тестировать ваш код в разном окружении допустим, с разными версиями Python или с разными версиями библиотек. Tox в этом смысле очень помогает.

Но прежде чем рассказывать про разные библиотеки, я начну с банальности. Не стесняйтесь использовать в коде assert. Это не стыдно. Часто это помогает понять, что происходит.



Предположим, есть функция, которая считает порядковую статистику, к ней написано два assert. Assert стоит писать в функции в тех случаях, когда это совсем крайняя ерунда, которой не должно быть в коде. Это совсем крайние случаи, они, скорее всего, вам даже в продакшене не встретятся. То есть если вы накосячите в коде, оно у вас, скорее всего, должно упасть на тестах.

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

Предположим, вы по какой-то причине вы хотите отключить assert например, хотите, чтобы это никогда не стреляло в продакшене. Для этого в Python есть специальная опция.



Расскажу, что такое doctest. Это модуль, стандартная библиотека Python, предназначенная для тестирования документации. Почему это хорошо? Документация, которая написана в коде, имеет свойство очень часто ломаться. Здесь очень маленькая игрушечная функция, все видно. Но когда у вас большой код, много параметров и вы в конце что-то дописали, то с очень большой вероятностью вы забудете поправить docstrings. Doctest позволяет таких вещей избежать. Вы что-то поправите, здесь не обновите, запустите doctest, и он у вас упадет. Так вы вспомните, что именно вы не поправили, пойдете и поправите.

Как это выглядит? Doctest ищет в docstrings эти елочки, дальше исполняет их и сравнивает то, что получается.



Вот пример запуска doctest. Запустили, видим, что у нас два теста и один из них упал совершенно по делу. Отлично, мы увидели хорошую понятную информацию об ошибке.


Ссылка со слайда

У doctest есть полезные директивы, которые могут пригодиться. Про все из них я рассказывать не буду, но некоторые, которые мне показались наиболее употребительными, я вынесла на слайд. Директива SKIP позволяет не запускать тест на помеченном примере. Директива IGNORE_EXCEPTION_DETAIL игнорирует тест EXCEPTION. ELLIPSIS позволяет написать троеточие вместо любого места в выводе. FAIL_FAST останавливается после первого упавшего теста. Все остальное можно прочесть в документации, там очень много. Лучше покажу на примере.



В этом примере есть директива ELLIPSIS и директива IGNORE_EXCEPTION_DETAIL. Вы видите в директиве ELLIPSIS К-ю порядковую статистику, и мы ожидаем, что придет что-то, начинающееся с девятки и заканчивающееся на девятку. В середине может быть что угодно. Такой тест не упадет.

Ниже есть директива IGNORE_EXCEPTION_DETAIL, она будет проверять только то, что пришло в AssertionError. Видите, мы там написали бла-бла-бла. Тест пройдет, он не будет сравнивать бла-бла-бла с expected iterable as first argument. Он будет сравнивать только AssertionError с AssertionError. Это полезные вещи, которыми можно пользоваться.



Дальше план такой: я буду рассказывать вам про unittest, потом про pytest. Сразу скажу, что я, наверное, не знаю плюсов unittest, кроме того, что это часть стандартной библиотеки. Я не вижу ситуации, которая бы меня заставила сейчас пользоваться unittest. Но есть проекты, которые его используют, в любом случае полезно знать, как выглядит синтаксис и что оно из себя представляет.

Другой момент: тесты, написанные на unittest, умеют запускать pytest прямо из коробки. Ему все равно. ()

Unittest выглядит так. Есть класс, начинающийся со слова test. Внутри функция, начинающаяся со слова test. Тестовый класс наследован от unittest.TestCase. Сразу скажу, что один тест тут написан правильно, а другой тест неправильно.

Верхний тест, где написан обычный assert, упадет, но это будет выглядеть странно. Давайте посмотрим.



Команда запуска. Вы можете написать в сам код unittest main, можете вызвать его из Python.



Мы запустили этот тест и видим, что он написал AssertionError, но он не написал, в каком месте он упал в отличие от следующего теста, где использовался self.assertEqual. Тут явным образом написано: три не равно двум.



Надо чинить, конечно. Но тогда был не виден этот волшебный вывод на экране.

Давайте посмотрим еще раз. В первом случае мы написали assert, во втором self.assertEqual. К сожалению, в unittest только так. Есть специальные функции self.assertEqual, self.assertnotEqual и еще 100500 функций, которые нужно использовать, если вы хотите увидеть адекватное сообщение об ошибке.

Почему так происходит? Потому что assert оператор, которому приходит bool и, возможно, строка, но в данном случае bool. И он видит, что у него true или false, а левую и правую часть ему уже неоткуда взять. Поэтому в unittest есть специальные функции, которые будут корректно выводить сообщения об ошибке.

Это не очень удобно, на мой взгляд. Точнее, совсем не удобно, потому что это какие-то специальные методы, которые есть только в этой библиотеке. Они отличны от того, к чему мы привыкли в обычном языке.



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

Дальше поговорим про то, как в unittest написать фикстуры. Но для этого мне сначала нужно сказать, что такое фикстуры. Это функции, которые вызываются до или после выполнения теста. Они нужны, если тесту нужно выполнить специальную настройку создать временный файл после теста, удалить временный файл; создать базу данных, удалить базу данных; создать базу данных, написать в нее что-то. В общем, что угодно. Давайте посмотрим, как это выглядит в unittest.



Для написания фикстуры в unittest есть специальные методы setUp и tearDown. Почему они до сих пор написаны не по PEP8 для меня большая загадка. ()

SetUp это то, что выполняется до теста, tearDown то, что выполняется после теста. Мне кажется, это крайне неудобная конструкция. Почему? Потому что, во-первых, у меня рука не поднимается эти имена писать: я уже живу в мире, где все-таки есть PEP8. Во-вторых, у вас появился temp-файл, про который у вас в аргументах самого теста ничего нет. Откуда он взялся? Не очень понятно, почему он есть и что это вообще такое.

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

С фикстурами в unittest есть еще одна не очень удобная особенность. Предположим, у нас есть один класс тестов, которым нужен временный файл, и другой класс тестов, которым нужна база данных. Отлично. Вы написали один класс, сделали setUp, tearDown, сделали создание/удаление временного файла. Написали другой класс, в нем тоже написали setUp, tearDown, сделали в нем создание/удаление базы данных.

Вопрос. Есть третья группа тестов, которым нужно и то и то. Что с этим всем делать? Мне видится два варианта. Либо взять и скопипастить код, но это не очень удобно. Либо создать новый класс, наследовать его от двух предыдущих, вызвать super. В целом это тоже будет работать, но выглядит как дикий overkill для тестов.



Поэтому мне хочется, чтобы ваше знакомство с unittest осталось вот таким, на теоретическом уровне. Дальше мы поговорим про более удобный способ писать тесты, более удобную библиотеку, это pytest.

Вначале я вам попробую рассказать, почему pytest это удобно.


Ссылка со слайда

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

Третье: тесты это просто функции, которые начинаются на test_. То есть вам не нужного лишнего класса, вы просто пишете обычную функцию, называете ее на test_ и она будет запускаться через pytest. Это удобно, потому что чем проще писать тесты, тем больше вероятность, что вы тест напишете, а не забьете.

В pytest есть куча удобных фич. Можно писать параметризованные тесты, удобно писать фикстуры разных уровней, есть и просто красивости, которыми можно пользоваться: xfail, raises, skip, еще какие-то. В pytest есть много плагинов, плюс можно писать свои.



Давайте посмотрим на примере. Так выглядят тесты, которые написаны на pytest. По смыслу это то же самое, что и на unittest, только выглядит гораздо лаконичнее. Первый тест вообще две строчки.



Запускаем командой python -m pytest. Отлично. Два теста прошли, все хорошо, мы видим, что они прошли и за какое время.



Теперь давайте сломаем один тест и сделаем так, чтобы у нас вывелась информация об ошибке. Вывелось assert 3 == 2 и ошибка. То есть мы видим: несмотря на то, что мы написали обычный assert, у нас корректно вывелась информация об ошибке, хотя до этого в unittest мы говорили, что assert принимает bool в строку или bool, так что информацию об ошибке вывести проблематично.

Можно задаться вопросом, почему это все работает? Потому что в pytest постарались и прибрали некрасивую часть за интерфейс. Pytest сначала делает синтаксический разбор вашего кода, и он представляется в виде некой древовидной структуры, абстрактного синтаксического дерева. В этой структуре у вас в вершинах стоят операторы, в листьях операнды. Assert это оператор. Он стоит в вершине дерева, и в этот момент, прежде чем отдать всё интерпретатору, можно этот assert подменить на внутреннюю функцию, которая делает интроспекцию и понимает, что у вас в левой и правой части. На самом деле интерпретатору скармливается уже вот это, с подмененным assert.

Подробно рассказывать не буду, есть ссылка, по ней можно прочитать, как они это сделали. Но мне нравится, что это все работает под капотом. пользователь этого не видит. Он пишет assert, как привык, все остальное делает сама библиотека. Можно об этом даже не задумываться.

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



Посмотрим, как в pytest выглядят фикстуры. Если в unittest это необходимость писать setUp и tearDown, то здесь называйте обычную функцию как угодно. Написали сверху декоратор pytest.fixture отлично, это фикстура.

Причем здесь еще не самый простой пример. Фикстура может просто делать return, что-то возвращать, это будет аналог setUp. В данном случае она сделает еще как бы tearDown, то есть именно здесь, после окончания теста, она вызовет close, и временный файлик удалится.

Кажется, это удобно. У вас есть произвольная функция, которую вы можете как угодно назвать. Вы ее явно в тест передаете. Передали filled_file, знаете, что это она. От вас не требуется ничего специального. В общем, пользуйтесь. Это намного удобнее, чем в unittest.



Еще немного про фикстуры. В pytest очень легко создать фикстуры разных scope. По дефолту фикстура создается с уровнем function. Это значит, что она будет вызываться на каждый тест, куда вы ее передали. То есть если есть yield или что-то еще а-ля tearDown, это тоже будет происходить после каждого теста.

Вы можете объявить scope='module', и тогда фикстура будет выполняться один раз на модуль. Допустим, вы хотите один раз создать базу данных и не хотите после каждого теста удалять и накатывать все миграции.

Еще в фикстурах есть возможность указать аргумент autouse=True, и тогда фикстура будет вызываться независимо от того, попросили вы ее или нет. Кажется, что этой опцией не нужно пользоваться никогда, или нужно, но очень осторожно, потому что это неявная вещь. Неявного лучше избегать.



Мы запустили этот код посмотрим, что получилось. Есть test one, который зависит от фикстуры call me once use when needed, call me every time. При этом call me once use when needed фикстура уровня модуля. Видим, что первый раз у нас вызвались фикстуры call me once use when needed, call me every time, которые это выводят, но еще вызвалась фикстура с autouse, потому что ей все равно, она всегда вызывается.

Второй тест зависит от тех же самых фикстур. Видим, что у нас второй раз call me once use when needed не напечаталась, потому что она уровня модуля, она один раз уже вызвалась и она больше вызываться не будет.

Кроме того, из этого примера видно, что в pytest нет таких проблем, о которых мы с вами говорили в unittest, когда в одном тесте вам может быть нужна база данных, в другом временный файл. Как их нормально сагрегировать, непонятно. Вот ответ на этот вопрос в pytest. Если передали две фикстуры, то внутри и будет две фикстуры.

Прекрасно, очень удобно, никаких проблем. Фикстуры очень гибкие, они могут зависеть от других фикстур. В этом нет никакого противоречия, и pytest сам вызовет их в нужном порядке.



На самом деле внутри можно отнаследовать фикстуры от других фикстур, сделать их разного scope, и autouse без autouse. Он сам их расставит в правильном порядке и вызовет.

Здесь у нас есть первый тест, test one, который зависит от rare_dependency_for_test_one, где эта фикстура зависит от другой фикстуры и еще от одной. Давайте посмотрим, что будет на выхлопе.



Мы видели, что они вызываются, причем в порядке наследования. Тут все фикстуры уровня функции, поэтому все они вызываются на каждый тест. Второй тест зависит от rare_dependency, а rare_dependency зависит от some_common_dependency. Смотрим на выхлоп и видим, что перед тестом вызвались две фикстуры.

В pytest есть специальный конфигурационный файл conftest.py, куда вы можете положить все фикстуры, и хорошо, если положите: обычно, когда человек смотрит на чужой код, он обычно пойдет смотреть фикстуры в conftest.

Это не обязательно. Если есть фикстура, которая вам нужна только в этом файле, и вы точно знаете, что она специфичная, узкоприменимая, и в другом файле не понадобится, то можете объявить ее в файле. Либо создавать много conftest, и они все будут работать на разных уровнях.



Поговорим про фичи, которые бывают в pytest. Как я уже говорила, очень легко делать параметризацию тестов. Здесь мы видим тест, у которого три набора параметров: два входных и тот, что ожидается. Мы их передаем в аргументы функции и смотрим, совпадает ли то, что мы передали на вход, с тем, что ожидается.



Посмотрим, как это выглядит. Видим, что здесь три теста. То есть pytest считает, что это три теста. Два прошло, один упал. Что здесь хорошо? Для того теста, который упал, мы видим аргументы, видим, на каком наборе параметров он упал.

Опять же, когда у вас маленькая функция и в parametrize написано три штуки, возможно, вы и глазами увидите, что именно упало. Но когда наборов в параметрах много, вы это глазами не увидите. Вернее, увидите, но вам будет очень сложно. И очень удобно, что pytest так все выводит вы сразу можете посмотреть, в каком именно случае тест упал.



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

В pytest есть еще много всяких удобных штук. Если о них рассказывать, лекции явно не хватит, поэтому я покажу, опять же, только несколько. В первом тесте используется pytest.raises(), чтобы показать, что вы ожидаете исключения. То есть в данном случае, если вызовется AssertionError, тест пройдет. У вас должно броситься исключение.

Вторая удобная штука xfail. Это декоратор, который разрешает тесту падать. Допустим, у вас есть много тестов, много кода. Вы что-то порефакторили, тест начал падать. При этом вы понимаете, что либо он не критичный, либо чинить его придется очень дорого. И вы такие: ладно, навешу на него декоратор, он станет зелененьким, починю его потом. Или предположим, тест начал флакать. Понятно, что это договоренность с собственной совестью, но иногда это бывает нужно. Причем xfail в таком виде будет зелененьким независимо от того, упал тест или нет. Ему еще можно передать в параметр Strict = True, тогда это будет немножко другая ситуация, pytest будет ждать, что тест упадет. Если тест пройдет, то вернется сообщение об ошибке, и, наоборот.

Еще одна полезная штука skipif. Есть просто skip, который не будет запускать тесты. И есть skipif. Если вы навесите этот декоратор, тест не будет запускаться при определенных условиях.

В данном случае написано, что если у меня платформа Mac, то не запускайся, потому что тест почему-то падает. Бывает. Но в целом бывают платформозависимые тесты, которые всегда будут падать на определенной платформе. Тогда это полезно.



Давайте запустим. Увидели буковку X, увидели S. X у нас относится к xfail, S к skipif. То есть pytest показывает, какой тест мы совсем пропустили, а какой запустили, но не смотрим на результат.



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

Вот полезная опция --collect-only. Она выводит список найденных тестов. Есть опция -k фильтрация по имени теста. Это одна из моих самых любимых опций: если у вас один тест свалился, особенно если он сложный и вы пока не знаете, как его чинить, пофильтруйте и запускайте его.

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

Еще есть очень хорошая опция -s, она включает вывод из stdout и stderr в тестах. По дефолту pytest будет выводить stdout и stderr только для упавших тестов. Но есть моменты, обычно на этапе отладки, когда вы хотите что-то в тесте вывести и не знаете, упадет ли тест. Может, и не упадет, но вы хотите в самом тесте увидеть, что туда приходит, и вывести. Тогда запускайте с -s и вы увидите то, что хотели.

-v стандартная опция verbose, повысить детализацию.

--lf, --last-failed опция, которая позволяет перезапустить только те тесты, которые упали в последнем запуске. --sw, --stepwise тоже полезная функция, как и -k. Если вы чините тесты последовательно, то запускаете со --stepwise, она проходит по зелененьким, а как только видит упавший тест, останавливается. И когда вы еще раз запустите --sw, она запустится с этого теста, который падал. Если опять упадет, она опять остановится, если не упадет пойдет дальше до следующего падения.


Ссылка со слайда

В pytest есть основной конфигурационный файл pytest.ini. В нем можно изменить поведение pytest по умолчанию. Я здесь привела опции, которые очень часто встречаются в конфигурационном файле.

Testpaths пути, в которых pytest будет искать тесты. addopts то, что добавляется в командную строку при запуске. Здесь у меня в addopts добавлены плагины flake8 и coverage. Мы чуть позже на них посмотрим.


Ссылка со слайда

В pytest есть очень много разных плагинов. Я написала те, которые, опять же, используются повсеместно. flake8 это линтер, coverage покрытие кода тестами. Дальше есть целый набор плагинов, которые облегчают работу с теми или иными фреймворками: pytest-flask, pytest-django, pytest-twisted, pytest-tornado. Наверное, еще что-нибудь есть.

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



Давайте посмотрим. Я в pytest.ini добавила coverage и flake8. Сoverage мне выдал отчет, у меня там файл с тестами, что-то из него не вызвалось, но это ничего :)

Вот файл k_stat.py, в нем нашлось целых пять стейтментов. Это примерно то же самое, что пять строчек кода. И покрытие 100%, но это потому, что у меня файлик очень маленький.

На самом деле покрытие обычно не бывает стопроцентным, и более того, не стоит его добиваться всеми способами. Субъективно кажется, что покрытие тестами 60-70% это вполне достаточно и нормально для работы.

Сoverage такая метрика, которая даже будучи стопроцетной не говорит, что вы молодец. То, что вы вызвали этот код, не значит, что вы что-то проверили. Вы можете еще assert True в конце написать. К coverage нужно подходить разумно, для стопроцентного покрытия тестами есть файзинг и роботы, а людям так делать не надо.

В pytest.ini я подключила еще один плагин. Здесь видно --flake8, это линтер, который показывает мои стилевые ошибки, и некоторые другие, уже не из PEP8, а из pyflakes.



Тут в выхлопе написан номер ошибки в PEP8 или в pyflakes. В целом все понятно. Строчка слишком длинная, для redefinition нужно две пустые строки, нужна пустая строка в конце файла. В конце написано, что у меня CitizenImport не используется. В общем, линтеры позволяют ловить грубые ошибки и ошибки в оформлении кода.



Мы с вами уже говорили про плагин timeout, он позволяет ограничить время работы теста. Для некоторых перфтестов важно время работы. И вы можете ограничить его внутри тестов с помощью time.time и timeit. Либо с помощью плагина timeout, что тоже очень удобно. Если тест работает слишком много, его можно попрофилировать разными способами, например cProfile, но про это будет рассказывать Юра в своей лекции.



Если вы пользуетесь IDE, а пользоваться вспомогательными средствами стоит, у меня тут, в частности, PyCharm, то тесты очень легко запустить прямо из него.



Осталось поговорить про mock. Представим, у нас есть модуль A, мы хотим его протестировать и есть другие модули, которые мы тестировать не хотим. Один из них ходит в сеть, другой в базу данных, а третий простой модуль, который нам ничем не мешает. В таких случаях нам поможет mock. Опять же, если мы пишем интеграционный тест, то, скорее всего, поднимем тестовую базу данных, напишем тестового клиента, и это тоже прекрасно. Это просто интеграционный тест.

Бывают случаи, когда мы хотим сделать unittest, когда мы хотим протестировать только один кусочек. Тогда нам нужен mock.



Mock это набор объектов, которыми можно подменить настоящий объект. На любое обращение к методам, к атрибутам он возвращает тоже mock.



В этом примере у нас есть простой модуль. Мы его оставим, а какие-то более сложные заменим на mock. Сейчас мы посмотрим, как это работает.



Тут показано наглядно. Мы его проимпортировали, говорим, что m это mock. Вызвали, вернулся mock. Сказали, что у m есть метод f. Вызвали, вернулся mock. Сказали, что m есть атрибут is_alive. Отлично, вернулся еще один mock. И мы видим, что m и f вызвались по одному разу. То есть это такой хитрый объект, внутри у которого переписан метод getattr.



Давайте посмотрим на более понятном примере. Допустим, есть AliveChecker. Он использует какую-то http_session, ему нужен таргет, и у него есть функция do_check, которая возвращает True или false в зависимости от того, что ему пришло: 200 или не 200. Это немножко искусственный пример. Но предположим, что внутри do_check можно накрутить сложную логику.

Допустим, мы не хотим ничего тестировать про сессию, не хотим ничего знать про метод get. Мы хотим протестировать только do_check. Отлично, давайте протестируем.



Можно это сделать так. Мокаем http_session, здесь она называется pseudo_client. Мокаем у нее метод get, говорим, что get это такой mock, который возвращает 200. Запускаем, создаем от этого всего AliveChecker, запускаем. Этот тест будет работать.

В дополнение давайте проверим, что get вызвался один раз и ровно с такими аргументами, как там написано. То есть мы вызвали do_check, ничего не зная ни про то, что это за сессия, ни про то, что у него за методы. Мы их просто замокали. Единственное, что мы знаем, что он вернул 200.



Другой пример. Он очень похож на предыдущий. Единственное, здесь вместо return_value написан side_effect. Но это что-то, что mock выполняет. В данном случае он бросает исключение. Строчка с assert поменяна на assert not AliveChecker.do_check(). То есть мы видим, что проверка не пройдет.

Это два примера, как протестировать функцию do_check, не зная ничего про то, что в нее сверху пришло, что пришло в этот класс.



Пример, конечно, выглядит искусственным: не совсем понятно, зачем проверять, 200 или не 200, здесь просто минимум логики. Но давайте представим, что в зависимости от кода возврата делается нечто хитрое. И тогда такой тест начинает казаться гораздо более осмысленным. Мы увидели, что приходит 200, а дальше проверяем обрабатывающую логику. Если не 200 то же самое.



Еще с помощью mock можно патчить библиотеки. Допустим, у вас уже есть библиотека, и в ней надо что-то поменять. Тут пример, мы запатчили синус. Теперь он у нас всегда возвращает двойку. Отлично.

Еще мы видим, что m вызвалась два раза. Mock ничего, конечно, не знает про внутренние API методов, которые вы мокаете и вообще, не обязан с ними совпадать. Но mock позволяет проверить, что вы вызвали, сколько раз и с какими аргументами. В этом смысле он помогает тестировать код.



Я хочу предостеречь вас от случая, когда остается один модуль и огромный mock. Подходите, пожалуйста, ко всему разумно. Если у вас есть простые штуки, не надо их мокать. Чем больше mock у вас в тесте, тем больше вы уходите от реальности: у вас может не совпадать API, и вообще это не совсем то, что вы тестируете. Без необходимости не надо мокать все подряд. Подходите к процессу разумно.



У нас осталась последняя маленькая часть про Continuous Integration. Когда вы одни разрабатываете пет-проджект, вы можете запускать тесты локально, и ничего страшного, они будет работать.

Как только проект вырастает и разработчиков в нем становится больше одно, это перестает работать. Во-первых, половина не будет запускать тесты локально. Во-вторых, они будут запускать их на своих версиях. Где-то будут конфликты, все будет постоянно ломаться.

Для этого есть Continuous Integration, практика разработки, которая заключается в быстром вливании кандидатов в основную ветку. Но при этом они должны пройти некую автосборку или автотесты в специальной системе. У вас есть код в репозитории, коммиты, которые вы хотите влить в вашу ветку основную проекта. На этих коммитах в специальной системе проходят тесты. Если тесты зелененькие, то либо коммит вливается сам, либо у вас появляется возможность его влить.

У такой схемы, конечно, есть свои недостатки, как и у всего. Как минимум вам нужно дополнительное железо не факт, что CI будет бесплатным. Но в любой более-менее крупной компании, да и не крупной тоже, без CI никуда не уйти.



Как пример скриншот из TeamCity, одной из CI. Есть сборка, она завершилась успешно. В ней было много изменений, она запустилась на таком-то агенте во столько-то. Это пример того, когда все хорошо и можно вливать.

Существуют много разных CI-систем. Я написала список, если интересно, посмотрите: AppVeyor, Jenkins, Travis, CircleCI, GoCD, Buildbot. Спасибо.



Другие лекции видеокурса по Python в посте на Хабре.
Подробнее..

Перевод Как протестировать блокноты Jupyter с помощью pytest и nbmake

20.05.2021 16:23:11 | Автор: admin

Файлы блокнотов Jupyter, в смысле количества одного из самых быстрорастущих типов файлов на Github, предоставляют простой интерфейс для итераций при решении визуальных задач, будь то анализ наборов данных или написание документов с большим объёмом кода. Однако популярность блокнотов Jupyter сопровождается проблемами: в репозитории накапливается большое количество файлов ipynb, многие из которых находятся в нерабочем состоянии. В результате другим людям трудно повторно запустить или даже понять ваши блокноты. В этом руководстве рассказывается, как для автоматизации сквозного (end-to-end) тестирования блокнотов можно воспользоваться плагином pytest nbmake.

К старту флагманского курса о Data Science области, в которой блокноты Jupyter незаменимы делимся переводом статьи из блога CI Semaphore о том, как при помощи Semaphore проверить, что ваши блокноты Jupyter находятся в рабочем состоянии и для этого больше не запускать блокноты вручную.


Предварительные требования

Это руководство предполагает владение основными навыками тестирования проектов на Python, о них рассказывается в статьеНепрерывная интеграция и развёртывание на Python с нуля. Прежде чем продолжить, пожалуйста, убедитесь, что вы знакомы с основами и на вашем компьютере установлен набор инструментов Python 3, таких как pip + virtualenv.

Демонстрационное приложение

Обычно проекты Python содержат папки с файлами блокнотов с расширением .ipynb, это может быть:

  • код доказательства концепции;

  • документация с примерами применения API;

  • длинный научный туториал.

В этой статье мы разберём, как автоматизировать простые сквозные тесты некоторых блокнотов, содержащих упражнения на Python. Благодарим pytudes за предоставленные для нашего примера материалы; воспользуемся этим примером проекта, не стесняйтесь делать форк и клонировать его с GitHub:

fig:fig:

Внутри репозитория вы найдёте содержащую блокноты папку, она называется ipynb. Установите зависимости из requirements.txt, а при необходимости сначала создайте виртуальную среду.

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

Локальное тестирование блокнота

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

Давайте начнём автоматизировать этот процесс в вашей среде разработки; первым шагом в автоматизации станет nbmake. Nbmake это пакет python и плагин к pytest. Он разработан автором этого руководства и используется известными научными организациями, такими как Dask, Quansight и Kitware. Вы можете установить nbmake с помощью pip:

pip install nbmake==0.5

Перед первым тестированием блокнотов давайте проверим, всё ли правильно настроено, указав pytest просто собрать (но не запускать) все тест-кейсы для блокнотов.

 pytest --collect-only --nbmake "./ipynb" ================================ test session starts =================================platform darwin -- Python 3.8.2, pytest-6.2.4, py-1.10.0, pluggy-0.13.1rootdir: /Users/a/git/alex-treebeard/semaphore-demo-python-jupyter-notebooksplugins: nbmake-0.5collected 3 items           ============================= 3 tests collected in 0.01s =============================

Мы видим, что pytest собрал несколько элементов.

Примечание: если вы получаете сообщение unrecognized arguments: --nbmake, оно означает, что плагин nbmake не установлен. Такое может произойти, если ваш CLI вызывает двоичный файл pytest за пределами текущей виртуальной среды. Проверьте, где находится ваш двоичный файл pytest, с помощью команды which pytest.

Теперь, когда мы проверили, что nbmake и pytest видят ваши блокноты и работают вместе, давайте выполним настоящие тесты:

 pytest --nbmake "./ipynb"================================ test session starts =================================platform darwin -- Python 3.8.2, pytest-6.2.4, py-1.10.0, pluggy-0.13.1rootdir: /Users/a/git/alex-treebeard/semaphore-demo-python-jupyter-notebooksplugins: nbmake-0.5collected 3 items                                                                    ipynb/Boggle.ipynb .                                                           [ 33%]ipynb/Cheryl-and-Eve.ipynb .                                                   [ 66%]ipynb/Differentiation.ipynb .                                                  [100%]================================= 3 passed in 37.65s =================================

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

Игнорирование ожидаемых ошибок в блокноте

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

К счастью, чтобы указать nbmake, что такие блокноты нужно игнорировать и продолжать работу после возникновения ошибок, в метаданные нашего блокнота мы можем поместить директивы. Возможно, вы раньше не использовали метаданные блокнота, поэтому самое время упомянуть, что, несмотря на их расширение ipynb, блокноты это просто JSON-файлы. Чтобы добиться от них нового поведения, добавьте новые поля:

  • откройте файл .ipynb в простом текстовом редакторе, например Sublime;

  • в метаданных блокнота найдите поле kernelspec;

  • добавьте объект execution в качестве родственного элемента kernelspec.

Должно получиться что-то вроде этого:

{  "cells": [ ... ],  "metadata": {    "kernelspec": { ... },    "execution": {      "allow_errors": true,      "timeout": 300    }  }}

Теперь можно перезапустить блокнот, чтобы проверить, что JSON валиден, а ошибки игнорируются.

Ускорение тестов с помощью pytest-xdist

В крупных проектах может быть много блокнотов, каждый из которых требует много времени на установку пакетов, извлечение данных из сети и выполнение анализа. Если ваши тесты занимают более нескольких секунд, стоит проверить, как на время выполнения повлияет распараллеливание. Сделать это можно с помощью другого плагина pytest pytest-xdist. Сначала установите пакет xdist. Это похожий на nbmake плагин pytest, он добавит новые параметры командной строки:

pip install pytest-xdist

Запустите команду ниже с количеством рабочих процессов, равным auto:

pytest --nbmake -n=auto "./ipynb"

Запись выполненных блокнотов обратно в репозиторий

По умолчанию тесты доступны только для чтения, то есть ваш файл ipynb на диске остаётся неизменным. Обычно это хорошее значение по умолчанию, потому что во время выполнения блокнота ваш редактор может быть открыт. Однако в некоторых ситуациях может потребоваться сохранить результаты теста на диске. Например, если вам нужно:

  • отладить неработающие блокноты путём просмотра выходных данных;

  • создать коммит в вашем репозитории с блокнотами в воспроизводимом состоянии;

  • встроить выполненные блокноты в сайт с документацией при помощи nbsphinx или jupyter book.

Мы можем направить nbmake на сохранение выполненных блокнотов на диск с помощью флага overwrite:

pytest --nbmake --overwrite "./ipynb"

Исключение блокнотов из тестов

Некоторые блокноты может быть трудно протестировать автоматически из-за необходимости ввода данных через stdin самим пользователем или длительного времени запуска. Чтобы отменить выбор, воспользуйтесь флагом игнорирования pytest:

pytest --nbmake docs --overwrite --ignore=docs/landing-page.ipynb

Примечание: это не сработает, если вы выбираете все блокноты с помощью шаблона поиска, например (*ipynb), вручную переопределяющего флаги игнорирования pytest.

Автоматизация тестирования блокнотов на Semaphore CI

С помощью вышеописанных методов можно создать автоматизированный процесс тестирования с использованием непрерывной интеграции (CI) Semaphore. Ключ здесь прагматизм: нужно найти способ протестировать большую часть ваших блокнотов и проигнорировать сложные блокноты; это хороший шаг к улучшению качества.

Начните с создания содержащего ваши блокноты проекта для репозитория. Выберите Choose repository:

Затем подключите Semaphore к репозиторию с вашими блокнотами:

Пока пропустите Add People, чтобы мы могли настроить рабочий процесс с нуля (даже если вы работаете с деморепозиторием). Semaphore предоставляет некоторую начальную конфигурацию, настроим её перед запуском:

Создайте простой рабочий процесс в один блок; ниже о процессе в деталях:

  1. Названием блока будет Test.

  2. Названием задачи Test Notebooks.

  3. В поле Commands введите команды ниже:

checkoutcache restorepip install -r requirements.txtpip install nbmake pytest-xdistcache storepytest --nbmake -n=auto ./ipynb

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

  1. checkout клонирует репозиторий с GitHub;

  2. cache restore загружает зависимости Python из кэша Semaphore. Это ускоряет процесс установки;

  3. pip устанавливает зависимости и инструменты;

  4. cache store сохраняет загруженные зависимости обратно в кэш;

  5. pytest запускает тесты nbmake.

Теперь мы можем запустить рабочий процесс. Не беспокойтесь, если конвейер выйдет из строя: далее мы рассмотрим распространённые проблемы.

Конвейер должен заработать сразу же:

Устранение некоторых распространённых ошибок

Добавление отсутствующего ядра Jupyter в среду CI

Если вы используете имя ядра, отличное от имени по умолчанию python3, то при выполнении ноутбуков в свежей среде CI увидите сообщение об ошибке: Error - No such kernel: 'mycustomkernel'. Чтобы установить пользовательское ядро, воспользуйтесь командой ipykernel:

python -m ipykernel install --user --name mycustomkernel

Если в своих блокнотах вы работаете с другим языком, например С++, ваш процесс установки ядра может быть другим.

Добавление недостающих секретов в среду CI

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

Добавление недостающих зависимостей в среду CI

Стек Data Science на Python часто требует установки собственных библиотек. Если вы работаете с CONDA, вполне вероятно, что они будут присутствовать в вашем нормальном процессе установки. Если же вы используете стандартные библиотеки Python, их присутствие немного менее вероятно.

Если вы пытаетесь установить необходимые вам библиотеки, посмотрите на статью о создании образа docker в CI. С ним тестирование упрощается, и по сравнению со стандартными средами Semaphore этот подход стабильнее во времени.

Пожалуйста, помните совет о прагматизме; 90 % полезности часто можно достичь за 10 % времени. Следование совету может привести к компромиссам, таким как игнорирование блокнотов, на которых запускаются требующие GPU модели ML.

Заключение

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

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

  • для удаления громоздких выходных данных блокнота перед коммитом запустите pre-commit и nbstripout;

  • для компиляции ваших блокнотов в красивый сайт с документацией используйте jupyter book;

  • для просмотра блокнотов в пул-реквестах используйте ReviewNB.

Действительно, процессы в разработке ПО и в научных исследованих всё дальше уходят с локальных компьютеров на арендуемые мощности самого разного рода, в том числе потому, что требуют всё больших ресурсов. То же касается и растущих объёмов данных, работа с которыми преобразилась в несколько смежных, но очень разных областей; среди них центральное место занимает Data Science. Если вам интересна эта сфера, вы можете присмотреться к нашему курсу Data Science, итог которого это 16 проектов, то есть 2-3 года постоянного самостоятельного изучения науки о данных.

Узнайте, как прокачаться и в других специальностях или освоить их с нуля:

Другие профессии и курсы
Подробнее..

Мониторинг демон на Asyncio Dependency Injector руководство по применению dependency injection

09.08.2020 08:06:15 | Автор: admin
Привет,

Я создатель Dependency Injector. Это dependency injection фреймворк для Python.

Это еще одно руководство по построению приложений с помощью Dependency Injector.

Сегодня хочу показать как можно построить асинхронный демон на базе модуля asyncio.

Руководство состоит из таких частей:

  1. Что мы будем строить?
  2. Проверка инструментов
  3. Структура проекта
  4. Подготовка окружения
  5. Логирование и конфигурация
  6. Диспетчер
  7. Мониторинг example.com
  8. Мониторинг httpbin.org
  9. Тесты
  10. Заключение

Завершенный проект можно найти на Github.

Для старта желательно иметь:

  • Начальные знания по asyncio
  • Общее представление о принципе dependency injection

Что мы будем строить?


Мы будем строить мониторинг демон, который будет следить за доступом к веб-сервисам.

Демон будет посылать запросы к example.com и httpbin.org каждые несколько секунд. При получении ответа он будет записывать в лог такие данные:

  • Код ответа
  • Количество байт в ответе
  • Время, затраченное на выполнение запроса



Проверка инструментов


Мы будем использовать Docker и docker-compose. Давайте проверим, что они установлены:

docker --versiondocker-compose --version

Вывод должен выглядеть приблизительно так:

Docker version 19.03.12, build 48a66213fedocker-compose version 1.26.2, build eefe0d31

Если Docker или docker-compose не установлены, их нужно установить перед тем как продолжить. Следуйте этим руководствам:


Инструменты готовы. Переходим к структуре проекта.

Структура проекта


Создаем папку проекта и переходим в нее:

mkdir monitoring-daemon-tutorialcd monitoring-daemon-tutorial

Теперь нам нужно создать начальную структуру проекта. Создаем файлы и папки следуя структуре ниже. Все файлы пока будут пустыми. Мы наполним их позже.

Начальная структура проекта:

./ monitoringdaemon/    __init__.py    __main__.py    containers.py config.yml docker-compose.yml Dockerfile requirements.txt

Начальная структура проекта готова. Мы расширим ее с следующих секциях.

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

Подготовка окружения


В этом разделе мы подготовим окружение для запуска нашего демона.

Для начала нужно определить зависимости. Мы будем использовать такие пакеты:

  • dependency-injector dependency injection фреймворк
  • aiohttp веб фреймворк (нам нужен только http клиент)
  • pyyaml библиотека для парсинга YAML файлов, используется для чтения конфига
  • pytest фреймворк для тестирования
  • pytest-asyncio библиотека-помогатор для тестирования asyncio приложений
  • pytest-cov библиотека-помогатор для измерения покрытия кода тестами

Добавим следующие строки в файл requirements.txt:

dependency-injectoraiohttppyyamlpytestpytest-asynciopytest-cov

И выполним в терминале:

pip install -r requirements.txt

Далее создаем Dockerfile. Он будет описывать процесс сборки и запуска нашего демона. Мы будем использовать python:3.8-buster в качестве базового образа.

Добавим следующие строки в файл Dockerfile:

FROM python:3.8-busterENV PYTHONUNBUFFERED=1WORKDIR /codeCOPY . /code/RUN apt-get install openssl \ && pip install --upgrade pip \ && pip install -r requirements.txt \ && rm -rf ~/.cacheCMD ["python", "-m", "monitoringdaemon"]

Последним шагом определим настройки docker-compose.

Добавим следующие строки в файл docker-compose.yml:

version: "3.7"services:  monitor:    build: ./    image: monitoring-daemon    volumes:      - "./:/code"

Все готово. Давайте запустим сборку образа и проверим что окружение настроено верно.

Выполним в терминале:

docker-compose build

Процесс сборки может занять несколько минут. В конце вы должны увидеть:

Successfully built 5b4ee5e76e35Successfully tagged monitoring-daemon:latest

После того как процесс сборки завершен запустим контейнер:

docker-compose up

Вы увидите:

Creating network "monitoring-daemon-tutorial_default" with the default driverCreating monitoring-daemon-tutorial_monitor_1 ... doneAttaching to monitoring-daemon-tutorial_monitor_1monitoring-daemon-tutorial_monitor_1 exited with code 0

Окружение готово. Контейнер запускается и завершает работу с кодом 0.

Следующим шагом мы настроим логирование и чтение файла конфигурации.

Логирование и конфигурация


В этом разделе мы настроим логирование и чтение файла конфигурации.

Начнем с добавления основной части нашего приложения контейнера зависимостей (дальше просто контейнера). Контейнер будет содержать все компоненты приложения.

Добавим первые два компонента. Это объект конфигурации и функция настройки логирования.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )

Мы использовали параметры конфигурации перед тем как задали их значения. Это принцип, по которому работает провайдер Configuration.

Сначала используем, потом задаем значения.

Настройки логирования будут содержаться в конфигурационном файле.

Отредактируем config.yml:

log:  level: "INFO"  format: "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"

Теперь определим функцию, которая будет запускать наш демон. Её обычно называют main(). Она будет создавать контейнер. Контейнер будет использован для чтения конфигурационного файла и вызова функции настройки логирования.

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main() -> None:    """Run the application."""    container = ApplicationContainer()    container.config.from_yaml('config.yml')    container.configure_logging()if __name__ == '__main__':    main()

Контейнер первый объект в приложении. Он используется для получения всех остальных объектов.

Логирование и чтение конфигурации настроено. В следующем разделе мы создадим диспетчер мониторинговых задач.

Диспетчер


Пришло время добавить диспетчер мониторинговых задач.

Диспетчер будет содержать список мониторинговых задач и контролировать их выполнение. Он будет выполнять каждую задачу в соответствии с расписанием. Класс Monitor базовый класс для мониторинговых задач. Для создания конкретных задач нужно добавлять дочерние классы и реализовывать метод check().


Добавим диспетчер и базовый класс мониторинговой задачи.

Создадим dispatcher.py и monitors.py в пакете monitoringdaemon:

./ monitoringdaemon/    __init__.py    __main__.py    containers.py    dispatcher.py    monitors.py config.yml docker-compose.yml Dockerfile requirements.txt

Добавим следующие строки в файл monitors.py:

"""Monitors module."""import loggingclass Monitor:    def __init__(self, check_every: int) -> None:        self.check_every = check_every        self.logger = logging.getLogger(self.__class__.__name__)    async def check(self) -> None:        raise NotImplementedError()

и в файл dispatcher.py:

""""Dispatcher module."""import asyncioimport loggingimport signalimport timefrom typing import Listfrom .monitors import Monitorclass Dispatcher:    def __init__(self, monitors: List[Monitor]) -> None:        self._monitors = monitors        self._monitor_tasks: List[asyncio.Task] = []        self._logger = logging.getLogger(self.__class__.__name__)        self._stopping = False    def run(self) -> None:        asyncio.run(self.start())    async def start(self) -> None:        self._logger.info('Starting up')        for monitor in self._monitors:            self._monitor_tasks.append(                asyncio.create_task(self._run_monitor(monitor)),            )        asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, self.stop)        asyncio.get_event_loop().add_signal_handler(signal.SIGINT, self.stop)        await asyncio.gather(*self._monitor_tasks, return_exceptions=True)        self.stop()    def stop(self) -> None:        if self._stopping:            return        self._stopping = True        self._logger.info('Shutting down')        for task, monitor in zip(self._monitor_tasks, self._monitors):            task.cancel()        self._logger.info('Shutdown finished successfully')    @staticmethod    async def _run_monitor(monitor: Monitor) -> None:        def _until_next(last: float) -> float:            time_took = time.time() - last            return monitor.check_every - time_took        while True:            time_start = time.time()            try:                await monitor.check()            except asyncio.CancelledError:                break            except Exception:                monitor.logger.exception('Error executing monitor check')            await asyncio.sleep(_until_next(last=time_start))

Диспетчер нужно добавить в контейнер.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            # TODO: add monitors        ),    )

Каждый компонент добавляется в контейнер.

В завершении нам нужно обновить функцию main(). Мы получим диспетчер из контейнера и вызовем его метод run().

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main() -> None:    """Run the application."""    container = ApplicationContainer()    container.config.from_yaml('config.yml')    container.configure_logging()    dispatcher = container.dispatcher()    dispatcher.run()if __name__ == '__main__':    main()

Теперь запустим демон и проверим его работу.

Выполним в терминале:

docker-compose up

Вывод должен выглядеть так:

Starting monitoring-daemon-tutorial_monitor_1 ... doneAttaching to monitoring-daemon-tutorial_monitor_1monitor_1  | [2020-08-08 16:12:35,772] [INFO] [Dispatcher]: Starting upmonitor_1  | [2020-08-08 16:12:35,774] [INFO] [Dispatcher]: Shutting downmonitor_1  | [2020-08-08 16:12:35,774] [INFO] [Dispatcher]: Shutdown finished successfullymonitoring-daemon-tutorial_monitor_1 exited with code 0

Все работает верно. Диспетчер запускается и выключается так как мониторинговых задач нет.

К концу этого раздела каркас нашего демона готов. В следующем разделе мы добавим первую мониторинговую задачу.

Мониторинг example.com


В этом разделе мы добавим мониторинговую задачу, которая будет следить за доступом к http://example.com.

Мы начнем с расширения нашей модели классов новым типом мониторинговой задачи HttpMonitor.

HttpMonitor это дочерний класс Monitor. Мы реализуем метод check(). Он будет отправлять HTTP запрос и логировать полученный ответ. Детали выполнения HTTP запроса будут делегированы классу HttpClient.


Сперва добавим HttpClient.

Создадим файл http.py в пакете monitoringdaemon:

./ monitoringdaemon/    __init__.py    __main__.py    containers.py    dispatcher.py    http.py    monitors.py config.yml docker-compose.yml Dockerfile requirements.txt

И добавим в него следующие строки:

"""Http client module."""from aiohttp import ClientSession, ClientTimeout, ClientResponseclass HttpClient:    async def request(self, method: str, url: str, timeout: int) -> ClientResponse:        async with ClientSession(timeout=ClientTimeout(timeout)) as session:            async with session.request(method, url) as response:                return response

Далее нужно добавить HttpClient в контейнер.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import http, dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    http_client = providers.Factory(http.HttpClient)    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            # TODO: add monitors        ),    )

Теперь мы готовы добавить HttpMonitor. Добавим его в модуль monitors.

Отредактируем monitors.py:

"""Monitors module."""import loggingimport timefrom typing import Dict, Anyfrom .http import HttpClientclass Monitor:    def __init__(self, check_every: int) -> None:        self.check_every = check_every        self.logger = logging.getLogger(self.__class__.__name__)    async def check(self) -> None:        raise NotImplementedError()class HttpMonitor(Monitor):    def __init__(            self,            http_client: HttpClient,            options: Dict[str, Any],    ) -> None:        self._client = http_client        self._method = options.pop('method')        self._url = options.pop('url')        self._timeout = options.pop('timeout')        super().__init__(check_every=options.pop('check_every'))    @property    def full_name(self) -> str:        return '{0}.{1}(url="{2}")'.format(__name__, self.__class__.__name__, self._url)    async def check(self) -> None:        time_start = time.time()        response = await self._client.request(            method=self._method,            url=self._url,            timeout=self._timeout,        )        time_end = time.time()        time_took = time_end - time_start        self.logger.info(            'Response code: %s, content length: %s, request took: %s seconds',            response.status,            response.content_length,            round(time_took, 3)        )

У нас все готово для добавления проверки http://example.com. Нам нужно сделать два изменения в контейнере:

  • Добавить фабрику example_monitor.
  • Передать example_monitor в диспетчер.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import http, monitors, dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    http_client = providers.Factory(http.HttpClient)    example_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.example,    )    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            example_monitor,        ),    )

Провайдер example_monitor имеет зависимость от значений конфигурации. Давайте добавим эти значения:

Отредактируем config.yml:

log:  level: "INFO"  format: "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"monitors:  example:    method: "GET"    url: "http://example.com"    timeout: 5    check_every: 5

Все готово. Запускаем демон и проверяем работу.

Выполняем в терминале:

docker-compose up

И видим подобный вывод:

Starting monitoring-daemon-tutorial_monitor_1 ... doneAttaching to monitoring-daemon-tutorial_monitor_1monitor_1  | [2020-08-08 17:06:41,965] [INFO] [Dispatcher]: Starting upmonitor_1  | [2020-08-08 17:06:42,033] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET http://example.commonitor_1  |     response code: 200monitor_1  |     content length: 648monitor_1  |     request took: 0.067 secondsmonitor_1  |monitor_1  | [2020-08-08 17:06:47,040] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET http://example.commonitor_1  |     response code: 200monitor_1  |     content length: 648monitor_1  |     request took: 0.073 seconds

Наш демон может следить за наличием доступа к http://example.com.

Давайте добавим мониторинг https://httpbin.org.

Мониторинг httpbin.org


В этом разделе мы добавим мониторинговую задачу, которая будет следить за доступом к http://example.com.

Добавление мониторинговой задачи для https://httpbin.org будет сделать легче, так как все компоненты уже готовы. Нам просто нужно добавить новый провайдер в контейнер и обновить конфигурацию.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import http, monitors, dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    http_client = providers.Factory(http.HttpClient)    example_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.example,    )    httpbin_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.httpbin,    )    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            example_monitor,            httpbin_monitor,        ),    )

Отредактируем config.yml:

log:  level: "INFO"  format: "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"monitors:  example:    method: "GET"    url: "http://example.com"    timeout: 5    check_every: 5  httpbin:    method: "GET"    url: "https://httpbin.org/get"    timeout: 5    check_every: 5

Запустим демон и проверим логи.

Выполним в терминале:

docker-compose up

И видим подобный вывод:

Starting monitoring-daemon-tutorial_monitor_1 ... doneAttaching to monitoring-daemon-tutorial_monitor_1monitor_1  | [2020-08-08 18:09:08,540] [INFO] [Dispatcher]: Starting upmonitor_1  | [2020-08-08 18:09:08,618] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET http://example.commonitor_1  |     response code: 200monitor_1  |     content length: 648monitor_1  |     request took: 0.077 secondsmonitor_1  |monitor_1  | [2020-08-08 18:09:08,722] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET https://httpbin.org/getmonitor_1  |     response code: 200monitor_1  |     content length: 310monitor_1  |     request took: 0.18 secondsmonitor_1  |monitor_1  | [2020-08-08 18:09:13,619] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET http://example.commonitor_1  |     response code: 200monitor_1  |     content length: 648monitor_1  |     request took: 0.066 secondsmonitor_1  |monitor_1  | [2020-08-08 18:09:13,681] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET https://httpbin.org/getmonitor_1  |     response code: 200monitor_1  |     content length: 310monitor_1  |     request took: 0.126 seconds

Функциональная часть завершена. Демон следит за наличием доступа к http://example.com и https://httpbin.org.

В следующем разделе мы добавим несколько тестов.

Тесты


Было бы неплохо добавить несколько тестов. Давайте сделаем это.

Создаем файл tests.py в пакете monitoringdaemon:

./ monitoringdaemon/    __init__.py    __main__.py    containers.py    dispatcher.py    http.py    monitors.py    tests.py config.yml docker-compose.yml Dockerfile requirements.txt

и добавляем в него следующие строки:

"""Tests module."""import asyncioimport dataclassesfrom unittest import mockimport pytestfrom .containers import ApplicationContainer@dataclasses.dataclassclass RequestStub:    status: int    content_length: int@pytest.fixturedef container():    container = ApplicationContainer()    container.config.from_dict({        'log': {            'level': 'INFO',            'formant': '[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s',        },        'monitors': {            'example': {                'method': 'GET',                'url': 'http://fake-example.com',                'timeout': 1,                'check_every': 1,            },            'httpbin': {                'method': 'GET',                'url': 'https://fake-httpbin.org/get',                'timeout': 1,                'check_every': 1,            },        },    })    return container@pytest.mark.asyncioasync def test_example_monitor(container, caplog):    caplog.set_level('INFO')    http_client_mock = mock.AsyncMock()    http_client_mock.request.return_value = RequestStub(        status=200,        content_length=635,    )    with container.http_client.override(http_client_mock):        example_monitor = container.example_monitor()        await example_monitor.check()    assert 'http://fake-example.com' in caplog.text    assert 'response code: 200' in caplog.text    assert 'content length: 635' in caplog.text@pytest.mark.asyncioasync def test_dispatcher(container, caplog, event_loop):    caplog.set_level('INFO')    example_monitor_mock = mock.AsyncMock()    httpbin_monitor_mock = mock.AsyncMock()    with container.example_monitor.override(example_monitor_mock), \            container.httpbin_monitor.override(httpbin_monitor_mock):        dispatcher = container.dispatcher()        event_loop.create_task(dispatcher.start())        await asyncio.sleep(0.1)        dispatcher.stop()    assert example_monitor_mock.check.called    assert httpbin_monitor_mock.check.called

Для запуска тестов выполним в терминале:

docker-compose run --rm monitor py.test monitoringdaemon/tests.py --cov=monitoringdaemon

Должен получиться подобный результат:

platform linux -- Python 3.8.3, pytest-6.0.1, py-1.9.0, pluggy-0.13.1rootdir: /codeplugins: asyncio-0.14.0, cov-2.10.0collected 2 itemsmonitoringdaemon/tests.py ..                                    [100%]----------- coverage: platform linux, python 3.8.3-final-0 -----------Name                             Stmts   Miss  Cover----------------------------------------------------monitoringdaemon/__init__.py         0      0   100%monitoringdaemon/__main__.py         9      9     0%monitoringdaemon/containers.py      11      0   100%monitoringdaemon/dispatcher.py      43      5    88%monitoringdaemon/http.py             6      3    50%monitoringdaemon/monitors.py        23      1    96%monitoringdaemon/tests.py           37      0   100%----------------------------------------------------TOTAL                              129     18    86%

Обратите внимание как в тесте test_example_monitor мы подменяем HttpClient моком с помощью метода .override(). Таким образом можно переопределить возвращаемое значения любого провайдера.

Такие же действия выполняются в тесте test_dispatcher для подмены моками мониторинговых задач.


Заключение


Мы построили мониторинг демон на базе asyncio применяя принцип dependency injection. Мы использовали Dependency Injector в качестве dependency injection фреймворка.

Преимущество, которое вы получаете с Dependency Injector это контейнер.

Контейнер начинает окупаться, когда вам нужно понять или изменить структуру приложения. С контейнером это легко, потому что все компоненты приложения и их зависимости в одном месте:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import http, monitors, dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    http_client = providers.Factory(http.HttpClient)    example_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.example,    )    httpbin_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.httpbin,    )    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            example_monitor,            httpbin_monitor,        ),    )


Контейнер как карта вашего приложения. Вы всегда знайте что от чего зависит.

Что дальше?


Подробнее..

CLI приложение Dependency Injector руководство по применению dependency injection Вопросы ответы

14.08.2020 02:18:06 | Автор: admin
Привет,

Я создатель Dependency Injector. Это dependency injection фреймворк для Python.

Это завершающее руководство по построению приложений с помощью Dependency Injector. Прошлые руководства рассказывают как построить веб-приложение на Flask, REST API на Aiohttp и мониторинг демона на Asyncio применяя принцип dependency injection.

Сегодня хочу показать как можно построить консольное (CLI) приложение.

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

Руководство состоит из таких частей:

  1. Что мы будем строить?
  2. Подготовка окружения
  3. Структура проекта
  4. Установка зависимостей
  5. Фикстуры
  6. Контейнер
  7. Работа с csv
  8. Работа с sqlite
  9. Провайдер Selector
  10. Тесты
  11. Заключение
  12. PS: вопросы и ответы

Завершенный проект можно найти на Github.

Для старта необходимо иметь:

  • Python 3.5+
  • Virtual environment

И желательно иметь общее представление о принципе dependency injection.

Что мы будем строить?


Мы будем строить CLI (консольное) приложение, которое ищет фильмы. Назовем его Movie Lister.

Как работает Movie Lister?

  • У нас есть база данных фильмов
  • О каждом фильме известна такая информация:
    • Название
    • Год выпуска
    • Имя режиссёра
  • База данных распространяется в двух форматах:
    • Csv файл
    • Sqlite база данных
  • Приложение выполняет поиск по базе данных по таким критериям:
    • Имя режиссёра
    • Год выпуска
  • Другие форматы баз данных могут быть добавлены в будущем

Movie Lister это приложение-пример, которое используется в статье Мартина Фаулера о dependency injection и inversion of control.

Вот как выглядит диаграмма классов приложения Movie Lister:


Обязанности между классами распределены так:

  • MovieLister отвечает за поиск
  • MovieFinder отвечает за извлечение данных из базы
  • Movie класс сущности фильм

Подготовка окружения


Начнём с подготовки окружения.

В первую очередь нам нужно создать папку проекта и virtual environment:

mkdir movie-lister-tutorialcd movie-lister-tutorialpython3 -m venv venv

Теперь давайте активируем virtual environment:

. venv/bin/activate

Окружение готово. Теперь займемся структурой проекта.

Структура проекта


В этом разделе организуем структуру проекта.

Создадим в текущей папке следующую структуру. Все файлы пока оставляем пустыми.

Начальная структура:

./ movies/    __init__.py    __main__.py    containers.py venv/ config.yml requirements.txt

Установка зависимостей


Пришло время установить зависимости. Мы будем использовать такие пакеты:

  • dependency-injector dependency injection фреймворк
  • pyyaml библиотека для парсинга YAML файлов, используется для чтения конфига
  • pytest фреймворк для тестирования
  • pytest-cov библиотека-помогатор для измерения покрытия кода тестами

Добавим следующие строки в файл requirements.txt:

dependency-injectorpyyamlpytestpytest-cov

И выполним в терминале:

pip install -r requirements.txt

Установка зависимостей завершена. Переходим к фикстурам.

Фикстуры


В это разделе мы добавим фикстуры. Фикстурами называют тестовые данные.

Мы создадим скрипт, который создаст тестовые базы данных.

Добавляем директорию data/ в корень проекта и внутрь добавляем файл fixtures.py:

./ data/    fixtures.py movies/    __init__.py    __main__.py    containers.py venv/ config.yml requirements.txt

Далее редактируем fixtures.py:

"""Fixtures module."""import csvimport sqlite3import pathlibSAMPLE_DATA = [    ('The Hunger Games: Mockingjay - Part 2', 2015, 'Francis Lawrence'),    ('Rogue One: A Star Wars Story', 2016, 'Gareth Edwards'),    ('The Jungle Book', 2016, 'Jon Favreau'),]FILE = pathlib.Path(__file__)DIR = FILE.parentCSV_FILE = DIR / 'movies.csv'SQLITE_FILE = DIR / 'movies.db'def create_csv(movies_data, path):    with open(path, 'w') as opened_file:        writer = csv.writer(opened_file)        for row in movies_data:            writer.writerow(row)def create_sqlite(movies_data, path):    with sqlite3.connect(path) as db:        db.execute(            'CREATE TABLE IF NOT EXISTS movies '            '(title text, year int, director text)'        )        db.execute('DELETE FROM movies')        db.executemany('INSERT INTO movies VALUES (?,?,?)', movies_data)def main():    create_csv(SAMPLE_DATA, CSV_FILE)    create_sqlite(SAMPLE_DATA, SQLITE_FILE)    print('OK')if __name__ == '__main__':    main()

Теперь выполним в терминале:

python data/fixtures.py

Скрипт должен вывести OK при успешном завершении.

Проверим, что файлы movies.csv и movies.db появились в директории data/:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py venv/ config.yml requirements.txt

Фикстуры созданы. Продолжаем.

Контейнер


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

Контейнер позволяет описать структуру приложения в декларативном стиле. Он будет содержать все компоненты приложения их зависимости. Все зависимости будут указаны явно. Для добавления компонентов приложения в контейнер используются провайдеры. Провайдеры управляют временем жизни компонентов. При создании провайдера не происходит создание компонента. Мы указываем провайдеру как создавать объект, и он создаст его как только в этом будет необходимость. Если зависимостью одного провайдера является другой провайдер, то он будет вызван и так далее по цепочке зависимостей.

Отредактируем containers.py:

"""Containers module."""from dependency_injector import containersclass ApplicationContainer(containers.DeclarativeContainer):    ...

Контейнер пока пуст. Мы добавим провайдеры в следующих секциях.

Давайте еще добавим функцию main(). Её обязанность запускать приложение. Пока она будет только создавать контейнер.

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main():    container = ApplicationContainer()if __name__ == '__main__':    main()

Контейнер первый объект в приложении. Он используется для получения всех остальных объектов.

Работа с csv


Теперь добавим все что нужно для работы с csv файлами.

Нам понадобится:

  • Сущность Movie
  • Базовый класс MovieFinder
  • Его реализация CsvMovieFinder
  • Класс MovieLister

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



Создаем файл entities.py в пакете movies:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py    entities.py venv/ config.yml requirements.txt

и добавляем внутрь следующие строки:

"""Movie entities module."""class Movie:    def __init__(self, title: str, year: int, director: str):        self.title = str(title)        self.year = int(year)        self.director = str(director)    def __repr__(self):        return '{0}(title={1}, year={2}, director={3})'.format(            self.__class__.__name__,            repr(self.title),            repr(self.year),            repr(self.director),        )

Теперь нам нужно добавить фабрику Movie в контейнер. Для этого нам понадобиться модуль providers из dependency_injector.

Отредактируем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import entitiesclass ApplicationContainer(containers.DeclarativeContainer):    movie = providers.Factory(entities.Movie)

Не забудьте убрать эллипсис (...). В контейнере уже есть провайдеры и он больше не нужен.

Переходим к созданию finders.

Создаем файл finders.py в пакете movies:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py    entities.py    finders.py venv/ config.yml requirements.txt

и добавляем внутрь следующие строки:

"""Movie finders module."""import csvfrom typing import Callable, Listfrom .entities import Movieclass MovieFinder:    def __init__(self, movie_factory: Callable[..., Movie]) -> None:        self._movie_factory = movie_factory    def find_all(self) -> List[Movie]:        raise NotImplementedError()class CsvMovieFinder(MovieFinder):    def __init__(            self,            movie_factory: Callable[..., Movie],            path: str,            delimiter: str,    ) -> None:        self._csv_file_path = path        self._delimiter = delimiter        super().__init__(movie_factory)    def find_all(self) -> List[Movie]:        with open(self._csv_file_path) as csv_file:            csv_reader = csv.reader(csv_file, delimiter=self._delimiter)            return [self._movie_factory(*row) for row in csv_reader]

Теперь добавим CsvMovieFinder в контейнер.

Отредактируем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )

У CsvMovieFinder есть зависимость от фабрики Movie. CsvMovieFinder нуждается в фабрике так как будет создавать объекты Movie по мере того как будет читать данные из файла. Для того чтобы передать фабрику мы используем атрибут .provider. Это называется делегирование провайдеров. Если мы укажем фабрику movie как зависимость, она будет вызвана когда csv_finder будет создавать CsvMovieFinder и в качестве инъекции будет передан объект Movie. Используя атрибут .provider в качестве инъекции будет передам сам провайдер.

У csv_finder еще есть зависимость от нескольких опций конфигурации. Мы добавили провайдер Сonfiguration чтобы передать эти зависимости.

Мы использовали параметры конфигурации перед тем как задали их значения. Это принцип, по которому работает провайдер Configuration.

Сначала используем, потом задаем значения.

Теперь давайте добавим значения конфигурации.

Отредактируем config.yml:

finder:  csv:    path: "data/movies.csv"    delimiter: ","

Значения установлены в конфигурационный файл. Обновим функцию main() чтобы указать его расположение.

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main():    container = ApplicationContainer()    container.config.from_yaml('config.yml')if __name__ == '__main__':    main()

Переходим к listers.

Создаем файл listers.py в пакете movies:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py    entities.py    finders.py    listers.py venv/ config.yml requirements.txt

и добавляем внутрь следующие строки:

"""Movie listers module."""from .finders import MovieFinderclass MovieLister:    def __init__(self, movie_finder: MovieFinder):        self._movie_finder = movie_finder    def movies_directed_by(self, director):        return [            movie for movie in self._movie_finder.find_all()            if movie.director == director        ]    def movies_released_in(self, year):        return [            movie for movie in self._movie_finder.find_all()            if movie.year == year        ]

Обновляем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, listers, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )    lister = providers.Factory(        listers.MovieLister,        movie_finder=csv_finder,    )

Все компоненты созданы и добавлены в контейнер.

В завершение обновляем функцию main().

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main():    container = ApplicationContainer()    container.config.from_yaml('config.yml')    lister = container.lister()    print(        'Francis Lawrence movies:',        lister.movies_directed_by('Francis Lawrence'),    )    print(        '2016 movies:',        lister.movies_released_in(2016),    )if __name__ == '__main__':    main()

Все готово. Теперь запустим приложение.

Выполним в терминале:

python -m movies

Вы увидите:

Francis Lawrence movies: [Movie(title='The Hunger Games: Mockingjay - Part 2', year=2015, director='Francis Lawrence')]2016 movies: [Movie(title='Rogue One: A Star Wars Story', year=2016, director='Gareth Edwards'), Movie(title='The Jungle Book', year=2016, director='Jon Favreau')]

Наше приложение работает с базой данных фильмов в формате csv. Нам нужно еще добавить поддержку формата sqlite. Разберемся с этим в следующем разделе.

Работа с sqlite


В это разделе мы добавим другой тип MovieFinder SqliteMovieFinder.

Отредактируем finders.py:

"""Movie finders module."""import csvimport sqlite3from typing import Callable, Listfrom .entities import Movieclass MovieFinder:    def __init__(self, movie_factory: Callable[..., Movie]) -> None:        self._movie_factory = movie_factory    def find_all(self) -> List[Movie]:        raise NotImplementedError()class CsvMovieFinder(MovieFinder):    def __init__(            self,            movie_factory: Callable[..., Movie],            path: str,            delimiter: str,    ) -> None:        self._csv_file_path = path        self._delimiter = delimiter        super().__init__(movie_factory)    def find_all(self) -> List[Movie]:        with open(self._csv_file_path) as csv_file:            csv_reader = csv.reader(csv_file, delimiter=self._delimiter)            return [self._movie_factory(*row) for row in csv_reader]class SqliteMovieFinder(MovieFinder):    def __init__(            self,            movie_factory: Callable[..., Movie],            path: str,    ) -> None:        self._database = sqlite3.connect(path)        super().__init__(movie_factory)    def find_all(self) -> List[Movie]:        with self._database as db:            rows = db.execute('SELECT title, year, director FROM movies')            return [self._movie_factory(*row) for row in rows]

Добавляем провайдер sqlite_finder в контейнер и указываем его в качестве зависимости для провайдера lister.

Отредактируем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, listers, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )    sqlite_finder = providers.Singleton(        finders.SqliteMovieFinder,        movie_factory=movie.provider,        path=config.finder.sqlite.path,    )    lister = providers.Factory(        listers.MovieLister,        movie_finder=sqlite_finder,    )

У провайдера sqlite_finder есть зависимость от опций конфигурации, которые мы еще не определили. Обновим файл конфигурации:

Отредактируем config.yml:

finder:  csv:    path: "data/movies.csv"    delimiter: ","  sqlite:    path: "data/movies.db"

Готово. Давайте проверим.

Выполняем в терминале:

python -m movies

Вы увидите:

Francis Lawrence movies: [Movie(title='The Hunger Games: Mockingjay - Part 2', year=2015, director='Francis Lawrence')]2016 movies: [Movie(title='Rogue One: A Star Wars Story', year=2016, director='Gareth Edwards'), Movie(title='The Jungle Book', year=2016, director='Jon Favreau')]

Наше приложение поддерживает оба формата базы данных: csv и sqlite. Каждый раз когда нам нужно изменить формат нам приходится менять код в контейнере. Мы улучшим это в следующем разделе.

Провайдер Selector


В этом разделе мы сделаем наше приложение более гибким.

Больше не нужно будет делать изменения в коде для переключения между csv и sqlite форматами. Мы реализуем переключатель на базе переменной окружения MOVIE_FINDER_TYPE:

  • Когда MOVIE_FINDER_TYPE=csv приложения использует формат csv.
  • Когда MOVIE_FINDER_TYPE=sqlite приложения использует формат sqlite.

В этом нам поможет провайдер Selector. Он выбирает провайдер на основе опции конфигурации (документация).

Отредактрируем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, listers, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )    sqlite_finder = providers.Singleton(        finders.SqliteMovieFinder,        movie_factory=movie.provider,        path=config.finder.sqlite.path,    )    finder = providers.Selector(        config.finder.type,        csv=csv_finder,        sqlite=sqlite_finder,    )    lister = providers.Factory(        listers.MovieLister,        movie_finder=finder,    )

Мы создали провайдер finder и указали его в качестве зависимости для провайдера lister. Провайдер finder выбирает между провайдерами csv_finder и sqlite_finder во время выполнения. Выбор зависит от значения переключателя.

Переключателем является опция конфигурации config.finder.type. Когда ее значение csv используется провайдер из ключа csv. Аналогично для sqlite.

Теперь нам нужно считать значение config.finder.type из переменной окружения MOVIE_FINDER_TYPE.

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main():    container = ApplicationContainer()    container.config.from_yaml('config.yml')    container.config.finder.type.from_env('MOVIE_FINDER_TYPE')    lister = container.lister()    print(        'Francis Lawrence movies:',        lister.movies_directed_by('Francis Lawrence'),    )    print(        '2016 movies:',        lister.movies_released_in(2016),    )if __name__ == '__main__':    main()

Готово.

Выполним в терминале следующие команды:

MOVIE_FINDER_TYPE=csv python -m moviesMOVIE_FINDER_TYPE=sqlite python -m movies

Вывод при выполнении каждой команды будет выглядеть так:

Francis Lawrence movies: [Movie(title='The Hunger Games: Mockingjay - Part 2', year=2015, director='Francis Lawrence')]2016 movies: [Movie(title='Rogue One: A Star Wars Story', year=2016, director='Gareth Edwards'), Movie(title='The Jungle Book', year=2016, director='Jon Favreau')]

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

Подсказка:
Переопределение значения конфигурации из другого провайдера позволяет реализовать перегрузку конфигурации в приложении без перезапуска, на горячую.
Для этого нужно использовать делегирование провайдеров и метод .override().

В следующем разделе добавим несколько тестов.

Тесты


В завершение добавим несколько тестов.

Создаём файл tests.py в пакете movies:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py    entities.py    finders.py    listers.py    tests.py venv/ config.yml requirements.txt

и добавляем в него следующие строки:

"""Tests module."""from unittest import mockimport pytestfrom .containers import ApplicationContainer@pytest.fixturedef container():    container = ApplicationContainer()    container.config.from_dict({        'finder': {            'type': 'csv',            'csv': {                'path': '/fake-movies.csv',                'delimiter': ',',            },            'sqlite': {                'path': '/fake-movies.db',            },        },    })    return containerdef test_movies_directed_by(container):    finder_mock = mock.Mock()    finder_mock.find_all.return_value = [        container.movie('The 33', 2015, 'Patricia Riggen'),        container.movie('The Jungle Book', 2016, 'Jon Favreau'),    ]    with container.finder.override(finder_mock):        lister = container.lister()        movies = lister.movies_directed_by('Jon Favreau')    assert len(movies) == 1    assert movies[0].title == 'The Jungle Book'def test_movies_released_in(container):    finder_mock = mock.Mock()    finder_mock.find_all.return_value = [        container.movie('The 33', 2015, 'Patricia Riggen'),        container.movie('The Jungle Book', 2016, 'Jon Favreau'),    ]    with container.finder.override(finder_mock):        lister = container.lister()        movies = lister.movies_released_in(2015)    assert len(movies) == 1    assert movies[0].title == 'The 33'

Теперь запустим тестирование и проверим покрытие:

pytest movies/tests.py --cov=movies

Вы увидите:

platform darwin -- Python 3.8.3, pytest-5.4.3, py-1.9.0, pluggy-0.13.1plugins: cov-2.10.0collected 2 itemsmovies/tests.py ..                                              [100%]---------- coverage: platform darwin, python 3.8.3-final-0 -----------Name                   Stmts   Miss  Cover------------------------------------------movies/__init__.py         0      0   100%movies/__main__.py        10     10     0%movies/containers.py       9      0   100%movies/entities.py         7      1    86%movies/finders.py         26     13    50%movies/listers.py          8      0   100%movies/tests.py           24      0   100%------------------------------------------TOTAL                     84     24    71%

Мы использовали метод .override() провайдера finder. Провайдер переопределяется моком. При обращении к провайдеру finder теперь будет возвращен переопределяющий мок.

Работа закончена. Теперь давайте подведем итоги.

Заключение


Мы построили консольное (CLI) приложение применяя принцип dependency injection. Мы использовали Dependency Injector в качестве dependency injection фреймворка.

Преимущество, которое вы получаете с Dependency Injector это контейнер.

Контейнер начинает окупаться, когда вам нужно понять или изменить структуру приложения. С контейнером это легко, потому что все компоненты приложения и их зависимости определены явно и в одном месте:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, listers, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )    sqlite_finder = providers.Singleton(        finders.SqliteMovieFinder,        movie_factory=movie.provider,        path=config.finder.sqlite.path,    )    finder = providers.Selector(        config.finder.type,        csv=csv_finder,        sqlite=sqlite_finder,    )    lister = providers.Factory(        listers.MovieLister,        movie_finder=finder,    )


Контейнер как карта вашего приложения. Вы всегда знайте что от чего зависит.

PS: вопросы и ответы


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

Я подготовил ответы:

Что такое dependency injection?

  • это принцип который уменьшает связывание (coupling) и увеличивает сцепление (cohesion)

Зачем мне применять dependency injection?

  • твой код становится более гибким, понятным и лучше поддается тестированию
  • у тебя меньше проблем когда тебе нужно понять как он работает или изменить его

Как мне начать применять dependency injection?

  • ты начинаешь писать код следуя принципу dependency injection
  • ты регистрируешь все компоненты и их зависимости в контейнере
  • когда тебе нужен компонент, ты получаешь его из контейнера

Зачем мне для этого фреймворк?

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

Какаю цену я плачу?

  • тебе нужно явно указывать зависимости в контейнере
  • это дополнительная работа
  • это начнет приносить дивиденды когда проект начнет расти
  • или через 2 недели после его завершения (когда ты забудешь какие решения принимал и какова структура проекта)

Концепция Dependency Injector


В дополнение опишу концепцию Dependency Injector как фреймворка.

Dependency Injector основан на двух принципах:

  • Явное лучше неявного (PEP20).
  • Не делать никакой магии с вашим кодом.

Чем Dependency Injector отличается от другим фреймворков?

  • Нет автоматического связывания. Фреймворк не делает автоматического связывания зависимостей. Не используется интроспекция, связывание по именам аргументов и / или типам. Потому что явное лучше неявного (PEP20).
  • Не загрязняет код вашего приложения. Ваше приложение не знает о наличии Dependency Injector и не зависит от него. Никаких @inject декораторов, аннотаций, патчинга или других волшебных трюков.

Dependency Injector предлагает простой контракт:

  • Вы показываете фреймворку как собирать объекты
  • Фреймворк их собирает

Сила Dependency Injector в его простоте и прямолинейности. Это простой инструмент для реализации мощного принципа.

Что дальше?


Если вы заинтересовались, но сомневайтесь, моя рекомендация такая:

Попробуйте применить этот подход на протяжении 2-х месяцев. Он неинтуитивный. Нужно время чтобы привыкнуть и прочувствовать. Польза стает ощутимой, когда проект вырастает до 30+ компонентов в контейнере. Если не понравится много не потеряйте. Если понравится получите существенное преимущество.


Буду рад фидбеку и отвечу на вопросы в комментариях.
Подробнее..

Robot Framework vs Pytest

01.10.2020 16:13:06 | Автор: admin

Я активный сторонник Robot Framework. Уже писал на Хабре о том, что с его помощью можно решить практически любую задачу по автоматизации тестирования, особенно когда разработка ведется на Python. В той же статье я упоминал, что на смежных проектах в компании используется Pytest. Мне пришлось довольно близко познакомиться с этим инструментом, так что теперь я готов провести его полноценное сравнение с Robot Framework, конечно же, со своей персональной колокольни.

Robot vs. Snake by Beanhex (http://personeltest.ru/aways/www.weasyl.com/~beanhex)Robot vs. Snake by Beanhex (http://personeltest.ru/aways/www.weasyl.com/~beanhex)

Кстати, на идею статьи меня натолкнул один из комментариев к предыдущей статье, в котором читатель сравнил jUnit и Robot Framework для проекта на Java. На мой взгляд, в этих условиях Robot Framework (основанный на Python) изначально был не очень применим. Но сама идея сравнения ближайшего аналога jUnit в мире Python (того самого Pytest) и Robot Framework мне понравилась.

Что это за инструменты?

Pytest

Фактически, Pytest - реализация xUnit фреймворка для Python. Если вы всю жизнь работали с jUnit или nUnit (двумя самыми распространенными представителями этого семейства для Java и .NET соответственно), то в Pytest найдете ровно те же стандарты - быстро поймете, что происходит, будете следовать привычным подходам написания юнит-тестов. xUnit-фреймворки - это надстройки над языком программирования или библиотеки в них, которые удобно использовать самим разработчикам. За счет этого они довольно распространены и популярны.

Для понимания ситуации вместе с Pytest, как и вместе со многими xUnit-фреймворками, необходимо использовать инструмент генерации логов. Уже традиционно в этой роли применяется Allure. Наверное, сегодня это стандарт для логов автотестов, который хочет видеть бизнес. Но надо понимать, что Pytest разрабатывается своим сообществом, а Allure - своим, и векторы этих команд могут в какой-то момент развернуться в разные стороны.

Robot Framework

В отличие от Pytest, Robot Framework - это domain specific language (DSL) - язык, специфичный для своей предметной области. Это не Python, хотя он на нем построен. Зато на Python можно написать все, что угодно для Robot Framework. И все возможности, о которых я здесь говорю (а также интеграции, настройки), доступны из коробки.

Robot Framework не настолько удобен разработчикам, поскольку требует более глубокого погружения. По сути, это другой подход написания автотестов. Как Cucumber для Java. Зато Robot Framework самостоятельно генерирует развернутые отчеты (мы об этом еще поговорим), т.е. фактически он содержит в себе также слой генерации отчетов, и развивается продукт одним сообществом - неделимо и гармонично.

Кстати, сообщество открытое и довольно отзывчивое. У него есть открытый канал в Slack, где каждый желающий может задать вопрос по Robot Framework и получить на него ответ. Я и сам иногда там отвечаю. Я даже иногда контрибьючу в Robot Framework.

Аргументы в пользу Robot Framework

Отсутствие лишних ограничений на наименования

В Pytest и ряде других xUnit фреймворков название тест-кейса всегда должно начинаться с test. Мне кажется логичным, когда используется метод test. Но в остальных случаях ограничения на наименования не помогают процессу.

Предполагаю, что эти ограничения в Pytest появились из желания разделить то, что относится к тест-кейсам, и то, что касается общих методов, вызываемых из тестов. Поскольку Pytest заточен под юнит-тестирование, реальные и тестовые методы приложения могут лежать в одном классе, так что возможна неразбериха.

Но на мой взгляд, здесь проблема именно в выбранном подходе, который тащит за собой то, что в XXI веке нам приходится ставить какие-то ограничения на названия тест-кейсов.

Robot Framework никак не заточен на юнит-тесты. В нем предусмотрен раздел Keywords, а сами тесты могут называться как угодно. Мне кажется, так работать удобнее. Более того, в Robot Framework любые сущности, в том числе keywords, можно называть по-русски. Грамотно подобрав названия, текст теста можно превратить в рассказ (Открой сайт, введи логин, убедись, что результат такой-то). Это позволяет не писать длинные комментарии, рассказывающие о том, что там происходит. Любой участник разработки или руководитель, никогда не залезавший в недра тестирования, может открыть этот тест и понять его. И время экономится, и понимание ситуации даже через полгода сохраняется.

Suite setup

Бывает, что для набора тест-кейсов (тест-сьюта) требуются однотипные настройки, например выполнение метода, который готовит некие данные, создает сущности и сохраняет их идентификаторы для использования в тестах. В Robot Framework предусмотрены отдельные настройки suite setup, которые применяются для всех тестов в наборе. Отдельно есть suite teardown, который выполняется после всех тестов в наборе, а также аналогичные настройки для каждого тест-кейса (test setup и test teardown). Это удобно, логично и не требует изобретения велосипедов.

В xUnit фреймворках есть аннотации, заменяющие настройки suite setup, а в Pytest есть фикстуры со scope=class.

В Pytest тест-кейсы могут быть просто методами (и тогда у них нет никакого suite setup - т.е. нет единой подготовки). Если же нужна единая подготовка, мы можем обернуть эти методы в класс. Но если мы сделаем фикстуру со scope=class для этого класса (т.е. попытаемся реализовать suite setup), то получим отдельный инстанс класса для каждого теста, так что данные из suite setup никак не попадут в тест-кейсы. Отдельные инстансы, вероятно, создаются из предположения, что данные из разных тест-кейсов не должны влиять друг для друга. Но из-за этого настроить среду для выполнения тестов намного сложнее, чем в Robot Framework, где suite setup предусмотрен априори.

Обычно этот вопрос в Pytest приходится обходить через создание отдельной фикстуры, куда загружаются данные. Эта фикстура впоследствии подтягивается в тесты. Другой путь - воспользоваться средствами Python, создав статическое поле у класса, которое будет общим для всех его инстансов (например, self.__class__.test_id = 2). Но на мой взгляд, это тоже костыль - к полям класса через подчеркивание не стоит обращаться извне.

Логи

Как я отметил выше, в Pytest для генерации логов чаще всего используется Allure. Это красиво и модно. Но из-за описанной выше особенности с инстансами, Allure не понимает, что и как связано с тестом. В отчет не попадают действия из suite setup. Приходится это обходить через написание обработчиков. И с моей точки зрения это уже не просто костыль, а настоящий костылесипед.

Кстати, в других xUnit фреймворках эта проблема тоже есть.

На фоне Pytest+Allure у Robot Framework логи максимально подробны и даже избыточны. Они включают даже то, о чем ты никогда не задумаешься - Robot пишет все, что ты делаешь. С помощью этого лога гораздо проще отлавливать плавающие ошибки, которые так просто не воспроизведешь. Ты точно знаешь, где и какое значение было у переменной, какой API вызвали. Зачастую и перезапускать тест не надо, чтобы понять, что происходит. Для Pytest в таких сложных случаях приходится придумывать инструменты, которые помогают генерировать лог, как у Robot Framework.

Синтаксический сахар

В Robot Framework не нужно придумывать усложняющих конструкций. Есть выражения, спасающие от лишнего кода.

Я люблю писать keyword-обертки и в них оборачивать другие keyword-ы. Например, в keyword, который из ответа API берет ID, можно будет подсовывать keyword, который дергает разные API (если в компании есть стандарты написания кода, то ответы API будут сходными - поле ID скорее всего будет во всех).

Например, можно написать: Идентификатор из создание сущности. Здесь Идентификатор из - keyword-обертка, создание сущности - это keyword, который вызывает API и отдает весь ответ. В другом случае можно написать Идентификатор из создание таблицы, где создание таблицы - это другой keyword.

Для меня писать подобным образом удобнее и понятнее. А если изменить keyword с создание сущности на создания сущности, то конструкция будет читаться даже с литературной точки зрения (Идентификатор из создания сущности).

Теггирование

В заголовке лога Robot Framework показывает статистику по каждому тегу. Если изначально правильно расставить теги, достаточно будет взглянуть на эту статистику, чтобы увидеть, в чем проблема, не забивая себе голову тем, что происходит внутри. Я писал про это в предыдущей статье. Там же я упоминал, что теги можно привязывать к идентификаторам багов в Jira или в любом другом треккере, который у вас используется. Как раз недавно у меня был случай, когда на одном из поддерживаемых тестов, о которых я и думать забыл с полгода назад, сработали старые забытые баги, привязанные к тегам. И на их поиски ушло несколько секунд, вместо минут и часов.

В Pytest присутствует теггирование, но оно не попадает в лог. Снова нужны какие-то костыли, чтобы реализовать это своими руками. Поэтому, насколько я знаю, так почти никто не делает.

Кстати, и Allure не дает возможности отобразить статистику по тегам. Вероятно, если бы в нем появилась такая возможность, она в моих глазах приблизила бы связку Pytest+Allure к Robot Framework по функциональности. Плюс был бы в том, что связка Pytest+Allure не требовала бы забивать головы консервативных разработчиков новым DSL. К сожалению, появление таких инструментов маловероятно из-за того, что Pytest и Allure развиваются разными группами.

Аргументы в пользу Pytest

В моем списке всего два аргумента в пользу Pytest. Зато от сторонника другого инструмента они должны прозвучать весомо.

Отладка

Об этом писал один из читателей в комментариях к предыдущей статье. К сожалению, ты не можешь приостановить выполнение в Robot Framework, чтобы посмотреть значения переменных. Это факт, который можно записать в плюс Pytest.

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

Параметрические тесты Pytest

Я нашел то, чего нет в Robot Framework - благодаря параметризации Pytest может создавать тест-кейсы на лету. В Robot Framework 10 тестов всегда будут 10-ю тестами, ни больше, ни меньше. В Pytest в некоторых случаях с помощью одного параметрического теста можно покрыть огромное количество кейсов.

На примере реального проекта. Предположим, у нас есть API с двумя параметрами, каждый из которых может принимать несколько вариантов значений (например, один принимает 7 значений, другой - 10). И эти значения друг друга не исключают. В соответствии с теорией тестирования в таком случае надо выбрать несколько кейсов, более-менее равномерно покрывающих сетку из 70 пересечений (метод pair-wise). Но я с помощью метода product из модуля itertools (который перемножает списки) написал тест-сетап, который подготавливает 70 комбинаций данных, а потом ходит в API и обеспечивает то самое утопическое exhaustive testing. При появлении еще одного варианта в начальных данных мне достаточно просто добавить строчку в один из первоначальных списков.

В Robot Framework так сделать нельзя. Там можно написать тест-шаблон, который принимает два значения, и сделать 70 вызовов. При появлении новых значений количество вызовов придется увеличивать.

В целом плюсов за Robot Framework получилось больше - я остаюсь его ярым сторонником. Но, конечно, это лишь мое мнение.

Автор статьи: Владимир Васяев

P.S. Мы публикуем наши статьи на нескольких площадках Рунета. Подписывайтесь на наши страницы в VK, FB, Instagramили Telegram-канал, чтобы узнавать обо всех наших публикациях и других новостях компании Maxilect.

Подробнее..

Прокачиваем скрипты симуляции HDL с помощью Python и PyTest

17.01.2021 20:13:46 | Автор: admin

Все делают это. Ну ладно, не все, но большинство. Пишут скрипты, чтобы симулировать свои проекты на Verilog, SystemVerilog и VHDL. Однако, написание и поддержка таких скриптов часто бывает довольно непроста для типично используемых Bash/Makefile/Tcl. Особенно, если необходимо не только открывать GUI для одного тестбенча и смотреть в диаграммы, но и запускать пачки параметризированных тестов для различных блоков, контролировать результат, параллелизировать их выполнение и т.д. Оказалось, что всё это можно закрыть довольно прозрачным и легко поддерживаемым кодом на Python, что мне даже обидно становится от того, как я страдал ранее и сколько странного bash-кода родил.

Конечно, я не первый кто задумывается о подобном. Уже даже существует целый фреймворк VUnit. Однако, как показывает практика и опросы в профильных чатах, такие фреймворки используются нечасто. Вероятно потому, что они предъявляют требования к внутренней структуре самих тестбенчей, с чем наверное можно мириться только на новых проектах, без обширной кодовой базы. Ну и вообще, куда ж без таких вещей как "у нас так не принято" и "not invented here".

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

Задачи

Попробую перечислить большинство задач, которые так или иначе возникают в процессе разработки и эволюции HDL проекта. От более простых к более сложным:

  • Одиночный запуск. Запустить симуляцию выбранного тестбенча, посмотреть временные диаграммы, внести коррективы, повторить.

  • Одиночный запуск без GUI. То же самое, но без временных диаграмм, оценить результат по выводу в консоль.

  • Параметризированный запуск. Запуск симуляции с GUI или без, но с параметрами (дефайнами), передаваемыми в скрипт из консоли.

  • Запуск с пре-/постпроцессингом. Например, для теста должны быть подготовлены данные. Или сам тест порождает данные, которые должны быть проверены вне HDL.

  • Массовый запуск. Прогнать симуляцию всех существующих тестбенчей.

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

  • Параллельные запуски. Тесты могут легко идти минуты/часы и последовательное исполнение может занимать слишком много времени. Скрипты должны позволять параллельное исполнение нескольких тестбенчей.

  • Поддержка нескольких симуляторов. Сделать так, чтобы всё вышеперечисленное работало в разных симуляторах.

  • Поддержка CI. Массовый запуск должен сочетаться в том числе с выбранной стратегией CI (прогон всех тестов после каждого пуша, "ночные сборки" и т.д.).

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

Идея

Все симуляторы запускаются примерно одинаково в большинстве случаев:

  • собираем список всех исходников (опционально делим на несколько списков по языку);

  • собираем список всех директорий для поиска исходников (нужно для include);

  • собираем список всех дефайнов;

  • сообщаем имя библиотеки, куда всё будем компилировать (или нескольких);

  • сообщаем имя верхнего модуля (обычно это имя тестбенча);

  • передаём это всё симулятору в виде ключей, файлов со списками и т.д.

А что если написать модуль на Python, в котором обернуть нужные симуляторы в один класс Simulator, вынести общие вещи в атрибуты и реализовать метод run(), который запустит симуляцию с помощью выбранного симулятора? В целом, именно это я и сделал для Icarus Verilog, Modelsim и Vivado Simulator, используя модуль subprocessпод капотом. Также я добавил класс CliArgs, основанный на модуле argparse, чтобы иметь возможность управлять запуском из консоли. Ну и написал некоторое количество вспомогательных функций, которые пригодятся в процессе. Получился файл sim.py.

Фактически, я постарался свести всё к тому, что в новом проекте нужно всего-лишь закинуть этот файл, создать рядом еще один скрипт на Python, импортировать необходимое из sim.py и начать работу.

Тестовый проект

Для демонстрации я вытянул модуль пошагового вычисления квадратного корня из одного старого проекта, чтобы тестовый дизайн был хоть чуточку сложнее счётчика или сумматора. Код основан на публикации An FPGA Implementation of a Fixed-Point Square Root Operation.

Репозиторий проекта pyhdlsim на GitHub.

Иерархия проекта проста:

$ tree -a -I .git. .github    workflows # Github Actions        icarus-test.yml # запуск всех тестов в Icarus Verilog после каждого пуша на github        modelsim-test.yml # запуск всех тестов в Modelsim после каждого пуша на github .gitignore LICENSE.txt README.md sim # скрипты для запуска симуляции    conftest.py    sim.py    test_sqrt.py src # исходники     beh # поведенчесие описания и модели        sqrt.py     rtl # синтезируемый HDL код        sqrt.v     tb # HDL код тестбенчей         tb_sqrt.sv

Сам тестбенч tb_sqrt.sv тоже довольно примитивен: подготавливается массив входных значений, вычисляются "идеальные" значения с помощью $sqrt(), входные значения проталкиваются в модуль корня, выходные значения сохраняются в массив, происходит сравнение ожидаемых значений и фактических.

В принципе, краткое описание есть в самом репозитории и на этом можно закругляться, однако, думаю что будет гораздо нагляднее, если показать весь путь написания тестового окружения (будем считать что весь HDL и файл sim.py уже написаны). Всё действо будет происходить внутри папки sim. Осторожно, букв много впереди ожидает.

Одиночный запуск

Создадим файл test_sqrt.py для запуска тестбенча.

#!/usr/bin/env python3from sim import Simulatorsim = Simulator(name='icarus', gui=True, cwd='work')sim.incdirs += ["../src/tb", "../src/rtl", sim.cwd]sim.sources += ["../src/rtl/sqrt.v", "../src/tb/tb_sqrt.sv"]sim.top = "tb_sqrt"sim.setup()sim.run()

Тест будем прогонять в Icarus с открытием GTKWave для просмотра диаграмм. Пути до исходников задаются относительно самого скрипта. Задавать директории поиска инклудов для данного проекта не обязательно, и сделано лишь для демонстрации. Чтобы не загрязнять директорию со скриптами - с помощью sim.setup() будет создана рабочая папка work (а если она существовала, то она будет удалена и создана заново) внутри которой симулятор и будет запущен (sim.run()).

Делаем скрипт исполняемым и запускаем:

chmod +x test_sqrt.py./test_sqrt.py

Симуляция должна пройти успешно и должно появиться окно GTKWave.

Одиночный запуск без GUI

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

#!/usr/bin/env python3from sim import Simulator, CliArgsdef test(tmpdir, defines, simtool, gui):    sim = Simulator(name=simtool, gui=gui, cwd=tmpdir)    sim.incdirs += ["../src/tb", "../src/rtl", sim.cwd]    sim.sources += ["../src/rtl/sqrt.v", "../src/tb/tb_sqrt.sv"]    sim.defines += defines    sim.top = "tb_sqrt"    sim.setup()    sim.run()if __name__ == '__main__':    # run script with key -h to see help    args = CliArgs(default_test="test").parse()    test(tmpdir='work', simtool=args.simtool, gui=args.gui, defines=args.defines)

Посмотрим что нам доступно:

$ ./test_sqrt.py -husage: test_sqrt.py [-h] [-t <name>] [-s <name>] [-b] [-d <def> [<def> ...]]optional arguments:  -h, --help            show this help message and exit  -t <name>             test <name>; default is 'test'  -s <name>             simulation tool <name>; default is 'icarus'  -b                    enable batch mode (no GUI)  -d <def> [<def> ...]  define <name>; option can be used multiple times

Теперь мы можем запустить тест в консольном режиме:

$ ./test_sqrt.py -bRun Icarus (cwd=/space/projects/pyhdlsim/simtmp/work)TOP_NAME=tb_sqrt SIMiverilog -I /space/projects/pyhdlsim/src/tb -I /space/projects/pyhdlsim/src/rtl -I /space/projects/pyhdlsim/simtmp/work -D TOP_NAME=tb_sqrt -D SIM -g2005-sv -s tb_sqrt -o worklib.vvp /space/projects/pyhdlsim/src/rtl/sqrt.v /space/projects/pyhdlsim/src/tb/tb_sqrt.svvvp worklib.vvp -lxt2LXT2 info: dumpfile dump.vcd opened for output.Test started. Will push 8 words to DUT.!@# TEST PASSED #@!

Или запустить в другом симуляторе:

# как в консоли./test_sqrt.py -s modelsim -b# так и с GUI./test_sqrt.py -s modelsim

Параметризированный запуск

Также теперь можно контролировать дефайны из консоли, и, например, увеличить количество подаваемых данных:

$ ./test_sqrt.py -b -d ITER_N=42Run Icarus (cwd=/space/projects/pyhdlsim/simtmp/work)TOP_NAME=tb_sqrt SIMiverilog -I /space/projects/pyhdlsim/src/tb -I /space/projects/pyhdlsim/src/rtl -I /space/projects/pyhdlsim/simtmp/work -D TOP_NAME=tb_sqrt -D SIM -g2005-sv -s tb_sqrt -o worklib.vvp /space/projects/pyhdlsim/src/rtl/sqrt.v /space/projects/pyhdlsim/src/tb/tb_sqrt.svvvp worklib.vvp -lxt2LXT2 info: dumpfile dump.vcd opened for output.Test started. Will push 42 words to DUT.!@# TEST PASSED #@!

Запуск с пре-/постпроцессингом

Часто бывает так, что сгенерировать данные для теста невозможно внутри тестбенча и должны быть применены внешние генераторы. Сделаем еще один тест, где будем сверять работу модуля на Verilog с идеальной моделью, написанной на Python. Алгоритм работы уже был представлен выше - просто перепишем его на Python, не забывая проверить что он на самом деле работает. Результатом будет файл src/beh/sqrt.py. Оттуда нам нужна будет лишь одна функция nrsqrt().

Переименуем старый тест, который ориентируется на данные, полученные внутри тестбенча, в test_sv. И создадим новый test_py, который будет готовить данные с помощью функции nrsqrt().

#!/usr/bin/env python3from sim import Simulator, CliArgs, path_join, write_memfileimport randomimport syssys.path.append('../src/beh')from sqrt import nrsqrtdef create_sim(cwd, simtool, gui, defines):    sim = Simulator(name=simtool, gui=gui, cwd=cwd)    sim.incdirs += ["../src/tb", "../src/rtl", cwd]    sim.sources += ["../src/rtl/sqrt.v", "../src/tb/tb_sqrt.sv"]    sim.defines += defines    sim.top = "tb_sqrt"    return simdef test_sv(tmpdir, defines, simtool, gui):    sim = create_sim(tmpdir, simtool, gui, defines)    sim.setup()    sim.run()def test_py(tmpdir, defines, simtool, gui=False, pytest_run=True):    # prepare simulator    sim = create_sim(tmpdir, simtool, gui, defines)    sim.setup()    # prepare model data    try:        din_width = int(sim.get_define('DIN_W'))    except TypeError:        din_width = 32    iterations = 100    stimuli = [random.randrange(2 ** din_width) for _ in range(iterations)]    golden = [nrsqrt(d, din_width) for d in stimuli]    write_memfile(path_join(tmpdir, 'stimuli.mem'), stimuli)    write_memfile(path_join(tmpdir, 'golden.mem'), golden)    sim.defines += ['ITER_N=%d' % iterations]    sim.defines += ['PYMODEL', 'PYMODEL_STIMULI="stimuli.mem"', 'PYMODEL_GOLDEN="golden.mem"']    # run simulation    sim.run()if __name__ == '__main__':    args = CliArgs(default_test="test_sv").parse()    try:        globals()[args.test](tmpdir='work', simtool=args.simtool, gui=args.gui, defines=args.defines)    except KeyError:        print("There is no test with name '%s'!" % args.test)

Теперь, когда тестов несколько, можно воспользоваться ключом выбора теста:

# аргумент должен совпадать с именем функции./test_sqrt.py -t test_py

Аналогичным образом можно организовать и постпроцессинг внутри запускающего скрипта при желании.

Массовый запуск

Сейчас у нас уже есть 2 теста, а ведь завтра может быть и 202, а значит уже можно переживать о том, что нужен способ как их все прогнать за раз. И вот тут на сцене появляется pytest.

Нано-ликбез по pytest.

  • Начиная с директории запуска, pytest рекурсивно ищёт всё начинающееся на test* и исполняет: модули, функции, классы, методы.

  • Тест считается выполненным, если не возникло исключений (типичным является использование assert для контроля).

  • Для формирования тестового окружения используются фикстуры (fixtures). Например, что подставлять в тест test_a(a) в качестве аргумента при выполнении как раз определяется фикстурой.

  • Можно создать дополнительный файл conftest.py, в котором разместить код для более тонкого контроля выполнения тестов и их окружения.

Типичные сценарии запуска:

  • pytest - рекурсивный поиск и исполнение всех тестов, начиная с текущей директории;

  • pytest -v - выполнить тесты и показать болеe подробную информацию о ходе выполнении тестов;

  • pytest -rP - выполнить тесты и показать вывод в stdout тех тестов, что завершились успешно;

  • pytest test_sqrt.py::test_sv - выполнить указанный тест.

Для того чтобы адаптировать текущий скрипт под pytest нужно совсем немного. Импортируем сам pytest. Добавим пару фикстур для таких аргументов теста как simtool и defines. Значение, возвращаемое фикстурами будет использовано в качестве аргумента во всех тестах. Два других аргумента gui и pytest_run снабжаем значениями по умолчанию. Фактически их тоже можно было сделать фикстурами, но т.к. для запуска pytest они не должны принимать никакое другое значение, то сделал так.

Да, кстати, появился аргумент pytest_run который сообщает о том, выполняется ли сейчас pytest, или просто тест запущен отдельно из консоли.

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

Имена тестов тоже изменять не нужно - они будут найдены pytest, т.к. имеют префикс test_.

Решение о том, прошел тест или нет принимается по состоянию аттрибута is_passed симулятора. Он возвращает истину, если увидел ключевую фразу !@# TEST PASSED #@! в stdout. Очевидно, если компиляция была неудачной, или тест завершился с ошибкой, то этой фразы выведено не будет. Это самый простой способ оценить результат, но возможности для его кастомизации здесь ограничены лишь фантазией. Можно получить stdout через sim.stdout и искать там что угодно.

#!/usr/bin/env python3import pytestfrom sim import Simulator, CliArgs, path_join, write_memfileimport randomimport syssys.path.append('../src/beh')from sqrt import nrsqrt@pytest.fixture()def defines():    return []@pytest.fixturedef simtool():    return 'icarus'def create_sim(cwd, simtool, gui, defines):    sim = Simulator(name=simtool, gui=gui, cwd=cwd, passed_marker='!@# TEST PASSED #@!')    sim.incdirs += ["../src/tb", "../src/rtl", cwd]    sim.sources += ["../src/rtl/sqrt.v", "../src/tb/tb_sqrt.sv"]    sim.defines += defines    sim.top = "tb_sqrt"    return simdef test_sv(tmpdir, defines, simtool, gui=False, pytest_run=True):    sim = create_sim(tmpdir, simtool, gui, defines)    sim.setup()    sim.run()    if pytest_run:        assert sim.is_passeddef test_py(tmpdir, defines, simtool, gui=False, pytest_run=True):    # prepare simulator    sim = create_sim(tmpdir, simtool, gui, defines)    sim.setup()    # prepare model data    try:        din_width = int(sim.get_define('DIN_W'))    except TypeError:        din_width = 32    iterations = 100    stimuli = [random.randrange(2 ** din_width) for _ in range(iterations)]    golden = [nrsqrt(d, din_width) for d in stimuli]    write_memfile(path_join(tmpdir, 'stimuli.mem'), stimuli)    write_memfile(path_join(tmpdir, 'golden.mem'), golden)    sim.defines += ['ITER_N=%d' % iterations]    sim.defines += ['PYMODEL', 'PYMODEL_STIMULI="stimuli.mem"', 'PYMODEL_GOLDEN="golden.mem"']    # run simulation    sim.run()    if pytest_run:        assert sim.is_passedif __name__ == '__main__':    args = CliArgs(default_test="test_sv").parse()    try:        globals()[args.test](tmpdir='work', simtool=args.simtool, gui=args.gui, defines=args.defines, pytest_run=False)    except KeyError:        print("There is no test with name '%s'!" % args.test)

Прогоним все тесты несколько раз:

$ pytest========== test session starts ===========platform linux -- Python 3.8.5, pytest-6.2.1, py-1.10.0, pluggy-0.13.1rootdir: /space/projects/misc/habr-publications/pyhdlsim/pyhdlsim/simtmpplugins: xdist-2.2.0, forked-1.3.0collected 2 itemstest_sqrt.py ..                    [100%]=========== 2 passed in 0.08s ============$ pytest -v========== test session starts ===========platform linux -- Python 3.8.5, pytest-6.2.1, py-1.10.0, pluggy-0.13.1 -- /usr/bin/python3cachedir: .pytest_cacherootdir: /space/projects/misc/habr-publications/pyhdlsim/pyhdlsim/simtmpplugins: xdist-2.2.0, forked-1.3.0collected 2 itemstest_sqrt.py::test_sv PASSED       [ 50%]test_sqrt.py::test_py PASSED       [100%]=========== 2 passed in 0.08s ============

Массовый параметризированный запуск

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

Модификация будет минимальной, нужно лишь обновить фикстуру defines:

# заменим это@pytest.fixture()def defines():    return []# на это@pytest.fixture(params=[[], ['DIN_W=16'], ['DIN_W=18'], ['DIN_W=25'], ['DIN_W=32']])def defines(request):    return request.param

Теперь фикстура может принимать одно из 5 значений. Запустим тесты:

$ pytest -v================== test session starts ==================platform linux -- Python 3.8.5, pytest-6.2.1, py-1.10.0, pluggy-0.13.1 -- /usr/bin/python3cachedir: .pytest_cacherootdir: /space/projects/misc/habr-publications/pyhdlsim/pyhdlsim/simtmpplugins: xdist-2.2.0, forked-1.3.0collected 10 itemstest_sqrt.py::test_sv[defines0] PASSED            [ 10%]test_sqrt.py::test_sv[defines1] PASSED            [ 20%]test_sqrt.py::test_sv[defines2] PASSED            [ 30%]test_sqrt.py::test_sv[defines3] PASSED            [ 40%]test_sqrt.py::test_sv[defines4] PASSED            [ 50%]test_sqrt.py::test_py[defines0] PASSED            [ 60%]test_sqrt.py::test_py[defines1] PASSED            [ 70%]test_sqrt.py::test_py[defines2] PASSED            [ 80%]test_sqrt.py::test_py[defines3] PASSED            [ 90%]test_sqrt.py::test_py[defines4] PASSED            [100%]================== 10 passed in 0.28s ===================

Как видим, теперь каждый тест запустился по 5 раз с разным дефайном.

Параллельные запуски

Тут тоже всё довольно просто и работает почти из коробки. Ставим один плагин:

python3 -m pip install pytest-xdist

И теперь можем запускать тесты в несколько параллельных потоков, например, в 4:

# можно также использовать значение auto, pytest задействует все доступные ядраpytest -n 4

Для того, чтобы проверить работу этого механизма добавим еще один длительный тест:

def test_slow(tmpdir, defines, simtool, gui=False, pytest_run=True):    sim = create_sim(tmpdir, simtool, gui, defines)    sim.defines += ['ITER_N=500000']    sim.setup()    sim.run()    if pytest_run:        assert sim.is_passed

Запустим последовательное и параллельное исполнение (тестов теперь стало 3*5=15):

$ pytest=================== test session starts ====================platform linux -- Python 3.8.5, pytest-6.2.1, py-1.10.0, pluggy-0.13.1rootdir: /space/projects/misc/habr-publications/pyhdlsim/pyhdlsim/simtmpplugins: xdist-2.2.0, forked-1.3.0collected 15 itemstest_sqrt.py ...............                         [100%]============== 15 passed in 242.74s (0:04:02) ==============$ pytest -n auto=================== test session starts ====================platform linux -- Python 3.8.5, pytest-6.2.1, py-1.10.0, pluggy-0.13.1rootdir: /space/projects/misc/habr-publications/pyhdlsim/pyhdlsim/simtmpplugins: xdist-2.2.0, forked-1.3.0gw0 [15] / gw1 [15] / gw2 [15] / gw3 [15]...............                                      [100%]============== 15 passed in 145.66s (0:02:25) ==============

Результат, как говорится, видно невооруженным взглядом.

Поддержка нескольких симуляторов

Ранее уже было показано, что при выполнении теста без pytest можно было выбрать симулятор с помощью ключа -s. Теперь же добавим выбор симулятора для pytest. Очевидно, нужно что-то сделать с фикстурой simtool.

Тут нам пригодится знание о существовании файла conftest.py, необходимого для кастомизации запусков pytest. Создадим такой файл рядом с sim.py и добавим туда следующий код:

def pytest_addoption(parser):    parser.addoption("--sim", action="store", default="icarus")

В файле теста test_sqrt.py обновим фикстуру simtool:

@pytest.fixturedef simtool(pytestconfig):    return pytestconfig.getoption("sim")

Теперь можно прогнать все тесты в другом симуляторе:

pytest --sim modelsim -n auto

Поддержка CI. Github Actions + (Modelsim | Icarus)

Ну и бонусом будет часть о непрерывной интеграции (CI). В репозиторий добавлены два файла .github/workflows/icarus-test.yml и .github/workflows/modelsim-test.yml. Это так называемые Github Actions - по определенному событию будет выполнено их содержимое внутри виртуального окружения, предоставляемого Github. В данном случае, после каждого пуша будут прогнаны все тесты в двух симуляторах.

В Icarus Verilog:

- name: Install dependencies  run: |    python -m pip install --upgrade pip    pip install pytest pytest-xdist    sudo apt-get install iverilog- name: Test code  working-directory: ./sim  run: |    pytest -n auto

И в Modelsim Intel Starter Pack:

- name: Install dependencies  run: |    python -m pip install --upgrade pip      pip install pytest pytest-xdist      sudo dpkg --add-architecture i386      sudo apt update      sudo apt install -y libc6:i386 libxtst6:i386 libncurses5:i386 libxft2:i386 libstdc++6:i386 libc6-dev-i386 lib32z1 libqt5xml5 liblzma-dev    wget https://download.altera.com/akdlm/software/acdsinst/20.1std/711/ib_installers/ModelSimSetup-20.1.0.711-linux.run        chmod +x ModelSimSetup-20.1.0.711-linux.run    ./ModelSimSetup-20.1.0.711-linux.run --mode unattended --accept_eula 1 --installdir $HOME/ModelSim-20.1.0 --unattendedmodeui none    echo "$HOME/ModelSim-20.1.0/modelsim_ase/bin" >> $GITHUB_PATH- name: Test code  working-directory: ./sim  run: |    pytest -n auto --sim modelsim

Тут кстати очень порадовала последняя версия Modelsim. Они наконец-то починили её! Каждый кто хоть раз устанавливал его на Ubuntu/Fedora поймёт о чём я (вот, например, инструкция для Quartus+Modelsim 19.1 и Fedora 29).

Ну и сравнение времени выполнения после очередного пуша в репозиторий:

Даже не смотря на то, что скачивание 1.3GB установочника Modelsim и его распаковка занимают некоторое время (которое тем не менее, очень мало!), он оказывается в итоге ещё и быстрее моментально развертываемого Icarus.

Тут конечно можно пойти дальше и подготовить Docker-образ с Modelsim, чтобы не качать его каждый раз сайта, но я, пожалуй, здесь остановлюсь.

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

Все финальные версии скриптов лежат в репозитории pyhdlsim на GitHub.

Подробнее..
Категории: Python , Fpga , Verilog , Vhdl , Modelsim , Pytest , Vivado , Simulation , Systemverilog , Icarus

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru