Your browser is not supported anymore. Please update to a more recent one.


Download Chrome

Download Firefox

Download
Internet Explorer

Download Safari

Оптимальная параллелизация юнит-тестов или 17000 тестов за 4 минуты

30 мая 2013 | Илья Кудинов
Сегодня мы поговорим про разработанную нами утилиту, которая оптимизирует тестирование PHP-кода с помощью PHPUnit и TeamCity. При этом нужно понимать, что наш проект — это не только веб-сайт, но и мобильные приложения, wap-сайт, Facebook-приложение и много чего ещё, а разработка ведется не только на PHP, но и на C, C++, HTML5 и т.д.

Методы, которые мы описываем, прекрасно адаптируются под любой язык, любую систему тестирования и любое окружение. Поэтому наш опыт может оказаться полезным не только разработчикам веб-сайтов на PHP, но и представителям других областей разработки. Кроме того, в ближайшем будущем мы планируем перевести нашу систему в Open Source ― без обязательной привязки к TeamCity и PHPUnit ― наверняка она кому-нибудь пригодится.

Зачем это нужно?


Юнит-тестирование ― обязательная (мы на самом деле в это верим!) составляющая любого серьёзного проекта, над которым трудятся десятки людей. И чем больше проект, тем больше в нём юнит-тестов. Чем больше юнит-тестов, тем больше время их выполнения. Чем больше время их выполнения, тем больше разработчиков и тестировщиков принимают решение «мягко игнорировать» их запуск.

Естественно, это не может положительно сказаться на качестве и скорости тестирования задач. Тесты полноценно прогоняются только перед релизом, в последнюю минуту начинаются выяснения в духе «А почему упал вот этот тест?», и зачастую всё сводится к «А давайте разложимся так, а потом будем чинить!». И хорошо ещё, если проблема в тесте. Гораздо хуже, если она в тестируемом коде.

Лучший выход из такой ситуации ― сократить время выполнения тестов. Но каким образом? Можно при тестировании каждой задачи запускать только тесты, покрывающие затрагиваемый ею код. Но и это вряд ли даст 100% гарантии, что всё в порядке: ведь в проекте, состоящем из многих тысяч классов, иногда трудно проследить тонкую связь между ними (юнит-тесты, если верить книжкам, НИКОГДА не должны выходить за пределы тестируемого класса, а ещё лучше ― тестируемого метода. Но много ли вы видели таких тестов?). Следовательно, обязательно нужно запускать ВСЕ тесты параллельно, в несколько потоков.

Самое простое решение для неленивых ― это, например, руками создать несколько конфигурационных XML-файлов PHPUnit и запускать их отдельными процессами. Но хватит его ненадолго: нужна будет постоянная поддержка этих конфигов и повысится вероятность упустить какой-нибудь тест или целый пакет, да и время выполнения будет далеко не оптимальным.

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

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

Поиск


Поскольку PHPUnit ― очень распространённая система, мы подумали, что наверняка кто-то уже что-то подобное сделал, и начали искать.

Первые результаты не разочаровали: нашлось множество решений разной степени готовности и уровня функциональности. Это были и bash-скрипты, и PHP-скрипты, и обёртки вокруг PHPUnit, и даже достаточно сложные и размашистые патчи для самой системы. Мы стали пробовать некоторые из них, копаться в коде, пытаться адаптировать под наш проект и столкнулись с огромным количеством проблем, неоднозначностей и логических ошибок. Это было удивительно: неужели до сих пор нет ни одного стопроцентно проверенного решения?

Большая часть вариантов была построена по одной из двух схем:

  • Ищем все необходимые файлы и делим их поровну между потоками;
  • Запускаем некоторое количество потоков, которые последовательно обрабатывают общую очередь тестов и «скармливают» их PHPUnit.
Недостаток первого способа очевиден: тесты распределяются не оптимальным образом, что, конечно, повышает скорость их работы, но зачастую какой-нибудь поток работает в 3-4 раза дольше всех остальных.

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

Ещё мы нашли информацию, что разработчики PHPUnit хотят реализовать поддержку многопоточности «из коробки». Но проблема в том, что этим обещаниям уже не один год, комментарии по поводу развития проекта скупы и никаких даже приблизительных прогнозов нет, так что рассчитывать на это решение пока что очень рано.

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

Муки творчества


Самая первая идея лежала на поверхности. Мы определили, что большая часть тест-классов работает приблизительно равное время, но есть и такие, которые выполняются в десятки и сотни раз дольше. Поэтому мы решили разделить эти два вида тестов (именно самостоятельно определяя эти «медленные» тесты) и прогонять их отдельно друг от друга. Например, все «быстрые» тесты провести в первых пяти потоках, а все «медленные» ― в трёх других. Результат оказался достаточно неплохим, при том же количестве потоков тесты проходили приблизительно в два раза быстрее, чем с двухпоточным вариантом, но разброс по времени работы разных потоков всё же был достаточно ощутим. Следовательно, нужно собирать информацию по времени работы тестов, чтобы распределять их максимально равномерно.

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

Из этого следовало, что нужно собирать статистику в определённом изолированном месте, куда бы все обращались только за чтением, а запись производилась бы чем-то отдельным. Именно в этот момент мы вспомнили о TeamCity, но об этом несколько позже.

Существует ещё одна проблема: время выполнения тестов всегда может колебаться как локально ― например, при резком падении производительности серверов, так и глобально ― когда на сервера повышается или понижается нагрузка (тесты у нас проводятся на тех же машинах, на которых ведётся разработка, и запуск разработчиком любого ресурсоёмкого скрипта заметно влияет на время работы тестов). Значит, для максимальной актуализации статистики её нужно накапливать и использовать усреднённое значение.

Каким же образом конфигурировать эту утилиту? При первой реализации всё управление осуществлялось параметрами командной строки. То есть мы запускали утилиту с параметрами для PHPUnit, а она передавала их в запускаемые процессы и добавляла свои ― например, полный список запускаемых тестов (и восхитительные строки запуска PHPUnit были длиной в тысячи символов). Понятно, что это неудобно, заставляет лишний раз задумываться о передаваемых параметрах и способствует появлению новых ошибок. После множества экспериментов наконец-то пришло самое простое решение: можно использовать стандартные конфигурационные XML-файлы PHPUnit, меняя настройки в них и добавив несколько собственных XML-тегов в структуру. Таким образом, с помощью этих конфигурационных файлов можно будет запускать как чистый PHPUnit, так и нашу утилиту с одним и тем же результатом (не учитывая, конечно, прироста скорости в нашем случае).

Решение


Итак, спустя многие часы планирования, разработки, поиска серьёзных багов и вылавливания незначительных, мы пришли к той системе, которая работает и сейчас.

Наш Multithread Launcher (или, как мы его ласково называем, «пускалка») состоит из трёх изолированных классов:

  • Класс для сбора и сохранения статистики по времени выполнения тестов;
  • Класс для получения этой статистики и равномерного распределения тестов;
  • Класс для генерации конфигурационных файлов PHPUnit и запуска его процессов.

Первый класс работает с TeamCity. Она регулярно запускает билд, который среди всего прочего прогоняет абсолютно все тесты, а затем включает наш сборщик статистики. У этого сотрудничества есть и преимущества, и недостатки. С одной стороны, TeamCity сама собирает статистику по всем запущенным тестам, с другой ― API TeamCity не предоставляет никаких средств для работы с этой информацией (ни родной, ни даже более развитый REST API), поэтому нам приходится обращаться напрямую к базе данных, с которой она работает.

Здесь мы столкнулись с небольшой проблемой. Наша изначальная архитектура предусматривала файл с тест-классом как единицу информации для равномерного распределения тестов по потокам (так как это наиболее естественный для конфигурационных файлов PHPUnit способ). А TeamCity хранит статистику по отдельный тестам, никак не связывая их с файлами. Поэтому наш сборщик читает из базы статистику по тестам, соотносит тесты с классами, классы ― с файлами, и сохраняет статистику уже в таком виде.



Это может показаться достаточно ресурсоёмким занятием, но мы написали простую систему кеширования соответствий «класс=>файл», и тяжёлая работа производится сборщиком только в случае появления новых классов, а их количество при каждом запуске не так велико.

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

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

Третий класс занимается самым интересным. В основном режиме он принимает на вход стандартный конфиг PHPUnit, в котором указываются директории с тестами и конкретные файлы. В нём есть возможность пометить тест как «медленный» (время работы некоторых тестов сильно зависит от внешних факторов и их следует проводить отдельно от остальных) и «изолированный» (такие тесты запускаются в отдельных потоках после завершения всех остальных). Затем пускалка формирует собственный конфиг PHPUnit со всеми необходимыми параметрами вроде необходимого TestListener'а, а test suite'ы являются списками файлов для запуска в каждом потоке. Соответственно, после этого достаточно только запустить несколько процессов PHPUnit с этим конфигом и указанием необходимого test suite'а. Помимо основного режима существуют возможности для отладки тестов: запуск с самым последним или любым другим сгенерированным конфигом, со стандартным TestListener'ом PHPUnit вместо нашего, запуск тестов в разном порядке и т.д.

К слову о наших TestListener'ах (это стандартный интерфейс PHPUnit, используемый для вывода информации о проведённых тестах). Изначально пускалка выводила информацию в таком же виде, в каком и PHPUnit по умолчанию. Но для большего удобства мы написали собственный Listener, который позволил сделать информацию более компактной и удобочитаемой, а также добавил новые возможности вроде перехвата STDOUT и STDERR.

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



Итоги


Теперь немного цифр.
Мы проводим больше 17 тысяч тестов в 8 потоков (плюс три дополнительных ― для «медленных» тестов, которые в нормальной ситуации проходят все вместе за минуту).
В лучшем случае (при стандартной нагрузке тестовых серверов) тесты с помощью пускалки проходят за 3.5-4 минуты против 40-50 минут в один поток и 8-15 минут при распределении поровну.
При сильно нагруженных серверах пускалка быстро приспосабливается к обстановке и отрабатывает за 8-10 минут. При распределении тестов поровну они работали не меньше 20-25 минут… И мы так ни разу и не дождались завершения работы тестов в один поток.



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

Что дало нам такое ускорение выполнения тестов?
  • Во-первых, мы повысили скорость тестирования задач.
  • Во-вторых, из-за частого прогона тестов существенно понизилась вероятность поймать фатальные ошибки при массовом мерже веток задач в релизную ветку.
  • В-третьих, значительно разгрузили агентов TeamCity: тесты запускаются далеко не в одном билде, и теперь у них реально бывает свободное время на особые задачи от QA-инженеров.
  • В-четвёртых, стало возможным реализовать автоматический запуск тестов при отправке задачи на тестирование, так что тестировщик имеет некоторую информацию о работоспособности кода задачи уже при её получении.

What's next?


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

В ближайшее время (очень рассчитываем на июнь) будет произведён тотальный рефакторинг и пересмотр системы, чтобы её можно было превратить в проект Open Source.

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

К тому же, архитектура системы изменится, чтобы у разработчиков была возможность без особых проблем отвязать пускалку от TeamCity и привязать её, например, к Jenkins, или работать исключительно локально.

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