Хабрахабр

Модульное тестирование и Python

Ru. Меня зовут Вадим, я ведущий разработчик в Поиске Mail. Статья состоит из трёх частей: в первой расскажу, чего мы вообще добиваемся с помощью модульного тестирования; во второй части описаны принципы, которым мы следуем; а из третьей части вы узнаете, как упомянутые принципы реализованы на Python.
Я поделюсь нашим опытом проведения модульного тестирования.

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

В своих проектах мы преследуем несколько целей.

Хотя, на самом деле, это не так просто, как звучит. Первое — банальная регрессия: поправить что-нибудь в коде, запустить тесты и узнать, что ничего не сломалось.

Если в проекте вы вводите обязательное модульное тестирование, или просто договариваетесь с разработчиками о применении модульных тестов, это немедленно отразится на стиле написания кода. Вторая цель — оценить влияние архитектуры. Кроме того, благодаря этим тестам станут понятнее интерфейсы и проявятся какие-то проблемные места. Невозможно писать функции на 300 строк с 50 локальными переменными и 15 параметрами, если эти функции будут подвергаться модульному тестированию. Ведь если код не ахти, то и тест будет кривой, и это сразу бросится в глаза.

Допустим, вы пришли в новый проект и вам дали 50 Мб исходников. Третья цель — сделать код понятнее. Если модульных тестов нет, то единственный способ познакомиться с работой кода, помимо чтения исходников, это «метод тыка». Возможно, вы просто не сможете в них разобраться. А благодаря модульным тестам вы можете посмотреть, как код исполняется из любого места. Но если система достаточно сложная, то чтобы добраться через интерфейс до нужных кусков кода, может понадобиться много времени.

К примеру, вы нашли какой-то класс и хотите его отладить. Четвёртая цель — упростить отладку. Мне довелось участвовать в проекте, где для тестирования некоторых фич нужно было полчаса создавать пользователя, начислять ему деньги, менять ему статус, запускать какой-нибудь cron, чтобы этот статус перевелся ещё куда-нибудь, потом еще что-нибудь нажимать в интерфейсе, запускать еще какой-нибудь cron… Через полчаса наконец появлялась бонусная программа для этого пользователя. Если вместо модульных тестов есть только системные, или вообще никаких тестов нет, то остается только добираться до нужного места через интерфейс. А если бы у меня были модульные тесты, то я мог бы сразу попасть в нужное место.

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

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

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

Ведь реализация может меняться, а интерфейс не может. Стандартный совет, который вы можете встретить во всех книгах и статьях: «надо тестировать не реализацию, а интерфейс». Совет, вроде, неплохой, и всё кажется логичным. Давайте-ка мы его будем тестировать, чтобы тесты не падали сплошь и рядом по каждому поводу. Обычно при тестировании функции выделяют так называемые классы эквивалентности: множество значений, при которых функция ведет себя единообразно. Но мы прекрасно знаем: чтобы тестировать что-то, надо выбрать какие-то тестовые значения. Но чтобы знать, какие у нас классы эквивалентности, необходима реализация. Грубо говоря, по тесту на каждый if. Вы её не тестируете, но она вам нужна, вы должны в нее заглянуть, чтобы знать, какие тестовые значения выбрать.

Он по опыту прекрасно понимает, где обычно ошибаются программисты. Поговорите с любым тестировщиком: он вам скажет, что при ручном тестировании всегда представляет себе реализацию. Он проверяет 5, abc, –7, и число на 100 знаков, поскольку знает, что реализация при этих значениях может отличаться, а при 6 и 7 — вряд ли. Тестировщик не проверяет всё подряд, сначала вводя 5, потом 6, потом 7.

Нельзя просто взять, закрыть глаза и написать тест. Так что непонятно, как следовать принципу «тестируй интерфейс, а не реализацию». Теория предлагает вводить классы эквивалентности по одному и писать для них тесты. Частично эту проблему пытается решить TDD. Однако я согласен с тезисом, что тесты надо писать первым делом. Я прочитал на эту тему много книг и статей, но всё как-то не клеится. У нас нет TDD, а в связи с вышесказанным, тесты пишутся не до создания кода, а параллельно с ним. Мы называем этот принцип «test first».

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

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

Мы используем стандартную библиотеку unittest из семейства xUnit. История такая: был язык SmallTalk, и в нём библиотека SUnit. Она всем понравилась, её начали копировать. Библиотеку импортировали в Java под названием Junit, оттуда в С++ под названием CppUnit и в Ruby под названием RUnit (потом переименовали в RSpec). Наконец, из Java библиотека «переехала» в Python под названием unittest. Причём импортировали её настолько буквально, что даже CamelCase остался, хотя это не соответствует PEP 8.

В ней рассказывается, как работать с фреймворками этого семейства. Про xUnit есть замечательная книга «xUnit Test Patterns». А первая треть книги просто замечательная, это одна из лучших книг по IT, что я встречал. Единственный недостаток книги заключается в её размере: она огромная, но примерно 2/3 содержимого — это каталог паттернов.

Все модульные тесты состоят из трех этапов: setup, exercise и verify. Модульный тест — это обычный код, которому присуща некая стандартная архитектура. Вы подготавливаете данные, запускаете тесты и смотрите, всё ли пришло в нужное состояние.

Setup

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

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

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

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

Там же есть функция patch, которая позволяет вместо честного внедрения зависимости просто сказать: «в этом пакете тот импорт подмени на другой». Для заглушек мы используем обычный mock из unittest. Правда, потом непонятно, кто и что подменил, так что пользуйтесь аккуратно. Удобно, потому что не надо ничего никуда пробрасывать.

Есть модуль io c io. Что касается файловой системы, то ее подделать достаточно просто. BytesIO. StringIO и io. Но если вам вдруг этого мало, то есть прекрасный модуль tempfile с контекст-менеджерами для временных файлов, директорий, именованных файлов, чего угодно. Вы можете создавать file-like-объекты, которые на самом деле не обращаются к диску. Tempfile — супермодуль, если вам по какой-то причине не подошел IO.

Есть стандартная рекомендация: «используйте не настоящую, а поддельную базу». C базой данных всё сложнее. Каждый раз, когда я спрашивал совета, что конкретно мне взять под Python или Perl, отвечали, что ничего готового никто не знает, и предлагали написать что-то свое. Не знаю, как вы, но я в своей жизни ни одной поддельной и достаточно функциональной базы не видел. Другой совет: «тогда возьми SQLite». Я не представляю, как можно написать эмулятор, например, PostgreSQL. Кроме того, если вы пользуетесь чем-то вроде MySQL или PostgreSQL, то наверняка в SQLite ничего работать не будет. Но ведь это нарушит изоляцию, потому что SQLite работает с файловой системой. Наверняка даже для банальных вещей, типа работы с датами, вы используете специфические возможности, которые поддерживает только ваша СУБД. Если вам кажется, что вы не используете специфические возможности конкретных продуктов, то вы, скорее всего, ошибаетесь.

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

Но вопрос в том, как туда попадут данные? Чуть хуже ситуация, когда от вас требуется, чтобы была запущена локальная БД, которая и будет использована. Откуда им там взяться — это непростой вопрос. Мы уже говорили, что должно быть некое начальное состояние системы, в базе должны быть какие-то данные.

С неё регулярно снималась копия, из которой удалялись чувствительные данные. Самый наивный подход, что я встречал, — это использование копии реальной базы. Плюс ко всему, писать тесты для копии реальной базы — это мучение. Авторы рассудили, что реальные данные лучше всего подойдут для тестов. Вам нужно сначала найти то, на чём вы собираетесь тестировать. Вы же не знаете, какие там данные. Закончилось тем, что в том проекте решили писать тесты для учетной записи отдела эксплуатации, которая «никогда не поменяется». Если этой информации там нет, то что делать, непонятно. Конечно же, через какое-то время она поменялась.

Тогда можно будет завязываться на конкретный объект, смотреть, что там происходит и писать тесты». За этим обычно следует решение: «давайте сделаем слепок реальной базы, скопируем её и больше не будем синхронизировать. Видимо, придется вручную вносить фейковые данные. Сразу возникает вопрос: что будет, когда в базу добавят новые таблицы?

Этот вариант очень похож на то, что в Django обычно называют fixtures: делают огромный JSON, заливают туда тест-кейсы на все случаи жизни, отправляют их в базу в начале тестирования, и типа всё у нас будет хорошо. Но раз уж мы всё равно будем так делать, давайте сразу подготовим вручную слепок базы. Данные свалены в кучу, непонятно, что к какому тесту относится. У этого подхода тоже куча недостатков. А еще бывают несовместимые состояния базы: например, одному тесту нужно, чтобы пользователей в базе не было, а другому — чтобы были. Никто не может понять, удалили данные или не удалили. В этом случае одному из тестов придется модифицировать базу. Эти два состояния нельзя одновременно хранить в одном слепке. Единственный недостаток этого подхода — сложность создания данных в каждом тесте. А раз уж этим всё равно приходится заниматься, то проще всего начать с пустой базы, чтобы каждый тест клал туда нужные данные, а по окончании тестирования очищал базу. Пока всё это по цепочке не создашь, foreign key не удовлетворишь, ничего не работает. В одном из проектов, где я работал, для создания услуги нужно было сгенерировать 8 сущностей в разных таблицах: услуга на лицевом счете, лицевой счет на клиенте, клиент на юрлице, юрлицо в городе, клиент в городе, и так далее.

Можно написать вспомогательные инструменты, обычно их называют фабриками (не путайте с шаблоном проектирования). Для таких ситуаций есть специальные библиотеки, которые сильно облегчают жизнь. Это клон библиотеки factory_girl, которую в прошлом году переименовали в factory_bot из соображений политкорректности. Например, мы пользовались библиотекой factory_boy, которая подходит для Django. В её основе лежит очень важная идея: вы один раз создаёте фабрику для объектов, которые хотите порождать, устанавливаете для неё связи, а потом говорите пользователю: «когда будешь создаваться, бери себе очередное имя, а группу генерируй сам с помощью фабрики групп». Написать такую библиотеку для вашего собственного фреймворка ничего не стоит. И в фабрике всё точно так же: имя генерируй так-то, связанные сущности такие-то.

Пользователь создался, и с ним можно работать, потому что под капотом он сгенерировал всё, что нужно. В результате в коде остается только одна последняя строчка: user = UserFactory(). При желании можете что-то настроить вручную.

В начале каждого теста делается BEGIN, тест что-то делает с базой, а после теста делается ROLLBACK. Для вычищения данных после тестирования мы используем банальные транзакции. Получается медленно, но поскольку тестов, которым нужны транзакции, обычно очень мало, то всё в порядке. Если транзакции нужны в самом тесте, — например, потому что закоммитил в базу что-то лишнее, — он вызывает метод, который мы назвали break_db, сообщает фреймворку, что сломал базу, и фреймворк её заново накатывает.

Exercise

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

По умолчанию тесты не смогут никуда обратиться, а если для какого-то всё же понадобится открыть доступ, его можно перенаправить. Заглушки легко наделать с помощью патчеров, сложить патчеры в отдельный словарь и дать к нему доступ всем тестам. Jenkins больше не будет слать вашим клиентам SMS по ночам 🙂 Очень удобно.

Verify

На этом этапе мы активно используем самописные assert’ы, даже однострочные. Если вы в тесте проверяете существование какого-то файла, то вместо assert self.assertTrue(file_exists(f)) рекомендую писать assert not file exists. C этим связан холивар: продолжать ли использовать CamelCase в именах, как в unittest, или следовать PEP 8? У меня нет ответа. Если следовать PEP 8, то в коде теста будет каша из CamelCase и snake_case. А если использовать CamelCase, то это не соответствует PEP 8.

Допустим, у вас есть код, который что-то тестирует, и много вариантов данных, на которых этот код надо прогнать. И последнее. Если у вас нет py.test, то можете использовать такой декоратор: <ссылка> В декоратор передаётся таблица, и один тест превращается в несколько других, каждый из которых тестирует один из кейсов. Если вы используете py.test, там можно запустить один и тот же тест с разными входными данными.

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

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

Это очень интересный паттерн. Обратите на внимание на фабрики.

S. P. Приглашаю на мой авторский Telegram-канал по программированию на Python — @pythonetc.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть