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

Цели


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

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

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

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

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

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

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

Принципы


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

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

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

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

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

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

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

Реализация на Python


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

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

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



Setup


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

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

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

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

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

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

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

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

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

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

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

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

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

В результате в коде остается только одна последняя строчка: 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, потому что без него ваш код нормально не функционирует, ничего страшного. Но лучше обойтись без этого, если есть возможность.

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

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

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


  1. SergeyEgorov
    19.12.2018 21:32

    Если вы используете dependency injection, то вполне уже можете изолироваться от базы данных каким-нибудь интерфейсом. При этом необходимость писать "собственный PostgreSQL для тестов" немедленно отпадает. Для тестов нужно будет всего лишь сделать реализацию этого интерфейса, которая будет оперировать данными в памяти.


  1. SergeyEgorov
    19.12.2018 21:37

    Модульные тесты на Python-е я писал году примерно в 2001-ом. Никаких особых сложностей вроде не возникало. Наоборот, если сравнивать скажем с тогдашним ASP.NET-ом и C#-ом, то все получалось легко и ненавязчиво. Любую реализацию для тестов можно подменить своей собственной, в силу динамической типизации Python-а.


  1. zloy_stas
    20.12.2018 22:33

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


  1. yttrium
    20.12.2018 20:35

    потому что SQLite работает с файловой системой
    Справедливости ради можно отметить www.sqlite.org/inmemorydb.html
    Для sqlite3.connect исполузуется псевдо-путь :memory: достаточно быстрая штука, иногда используется для организации внутреннего кэша.
    В sqlalchemy(«sqlite://») и django(":memory:") для не сложных проектов, не использующих специфичные типы данных и операции, вполне применимо для тестов. Для специфичных тестов можно использовать skipIf или маркировку, например pytest.mark.pg, и запускать такие тесты в отдельном окружении.


  1. LighteR
    20.12.2018 23:59

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

    Почему нельзя просто замокать обращение к memcached? Если это в каком-то месте сложно сделать, то, кажется, это проблема в качестве тестируемого кода