Автоматическое тестирование приложений
Хватит тратить время на модульное тестирование: как Tokopedia добилась ускорения тестов в 8 раз
В этой статье мы рассмотрим наш путь оптимизации ежедневного процесса модульного тестирования в iOS-команде Tokopedia.
Юнит-тестирование является неотъемлемой частью разработки программного обеспечения, особенно для крупномасштабных приложений, обслуживающих миллионы пользователей. Для разработчиков крайне важно убедиться, что каждая функция тщательно тестируется и что каждая новая реализованная функция не создает новых ошибок для существующих, поскольку это может привести к значительным потерям как для разработчиков, так и для пользователей.
Хотя наличие модульных тестов очень важно, поддержка и выполнение тестов может отнимать много времени и ресурсов, особенно в крупномасштабном приложении, которое имеет десятки тысяч или более тестовых случаев.
Когда один цикл сборки и модульного тестирования занимает слишком много времени, это снижает производительность труда самих разработчиков. Это особенно актуально для нагруженной CI/CD-системы, в которой всего несколько машин обслуживают десятки разработчиков.
В нашем случае у нас 9 CI/CD машин, на которых ежедневно работают 65 разработчиков. Обычно каждый разработчик работает над несколькими ветками, причем каждая ветка запускает как сборку, так и задания модульного тестирования. Можете ли вы представить, если бы одно задание модульного тестирования занимало 30 минут?
В этой статье мы рассмотрим наш путь оптимизации ежедневного процесса модульного тестирования в iOS-команде Tokopedia. Мы расскажем, как мы проводили модульное тестирование в прежние времена и закончим тем, к чему мы пришли сегодня.
Болевые точки
Оптимизировав производительность наших модульных тестов, мы решили некоторые болевые точки, с которыми сталкивается наша команда:
ОМГ! Этот фикс должен быть смержен как можно скорее, но на машине CI 10 простаивающих очередей, ожидающих выполнения :(.
Я только что изменил владельца кода на GitHub, в PR есть только один измененный файл с двумя новыми строками. Что вы сказали? Мне нужно запустить все модульные тесты снова, правда?.
Хорошо, мой PR готов, давайте запустим CI для прогона всех юнит-тестов.
*30 минут спустя*
Что? Как мой юнит-тест мог провалиться? О, я забыл закоммитить последние изменения. Через 30 минут повторный триггер, давайте займемся домашними делами, пока ждем.
Путь учителя физкультуры: давайте запустим все модульные тесты!
В Tokopedia мы приняли модульную архитектуру приложений, используя Bazel в качестве системы сборки (подробнее об этом можно прочитать здесь). Поэтому все объяснения будут основаны на Bazel, а не на родной системе сборки Xcode.
Наш первый процесс модульного тестирования заключался в запросе всех целей модульного тестирования с помощью запроса bazel, затем запуска тестов для каждой цели из него. Обратите внимание, что благодаря модульной архитектуре приложения, наше приложение состоит из дюжины модулей (целей) для каждой фичи/тестового случая.
Процесс довольно прост, но запуск тестов на каждой цели по отдельности занимает очень много времени. Это связано с тем, что каждый раз, когда Bazel запускает сборку или тест, он работает с собственным процессом сборки. А именно: анализирует зависимостей, проверяет и линкует, что может замедлить общее время тестирования.
Хотя такой подход не обязательно плох, он не подходит для нашего крупномасштабного приложения. У нас сотни тестовых целей с десятками тысяч тестовых случаев, и их выполнение по отдельности занимает очень много времени.
С другой стороны, если у нас простое приложение с несколькими тестовыми случаями, разница во времени будет незначительной, поэтому улучшение процесса модульного тестирования может не стоить затраченных усилий.
Помните, в прошлом абзаце я упоминал процесс сборки Bazel? Да, в следующем разделе мы поговорим о том, как оптимизировать этот процесс сборки, чтобы сделать его быстрее. Давайте перейдем к следующему разделу.
Путь брака: где все модульные тесты объединяются в один
После того как мы некоторое время использовали способ «учителя физкультуры», мы поняли, что процесс сборки Bazel негативно сказывается на производительности конвейера юнит-тестов. Анализ зависимостей и накладные расходы на запуск и завершение работы XCTest runner приводят к тому, что время выполнения модульных тестов варьируется от ~20 минут до ~35 минут, поскольку у нас сотни тестовых целей.
Поэтому мы попытались объединить все цели юнит-тестов в одну единственную цель. Идея заключается в том, чтобы упростить анализ зависимостей и сократить время, затрачиваемое на выполнение каждого теста по отдельности. Примерное представление о процессе показано на следующем рисунке:
Недостатком этого подхода является то, что он не использует кэширование Bazel в полной мере. В то время как старый подход будет кэшировать тестовые случаи, в которых не было изменений кода.
После тестирования в течение ~2 месяцев мы поняли, что этот новый подход работает быстрее по сравнению со старым способом юнит-тестирования, несмотря на недостатки, о которых я говорил ранее. Он экономит время на процесс сборки Bazel (анализ пакетов, зависимостей и т.д.), в то время как старый подход будет повторять это для всех тестовых целей.
Приведенный выше график показывает, что использование единственного модульного теста позволяет нам достичь более стабильного времени модульного тестирования, со средней продолжительностью около 450 секунд. Это указывает на то, что подход с использованием одного модульного теста может привести к более последовательным и надежным результатам тестирования.
Напротив, использование традиционного подхода без использования единичного теста приводит к значительному количеству скачков во времени тестирования, причем наибольшее значение на 50% больше, чем наибольшее значение, полученное при использовании подхода с использованием единичного теста. Это говорит о том, что традиционный подход может привести к менее предсказуемым и менее надежным результатам тестирования.
И наконец, тестирование одного блока также значительно уменьшило медиану времени тестирования до ~563с с ~1772с. Это указывает на то, что подход, основанный на единичном тестировании, может также привести к более быстрым и эффективным процессам тестирования, что в конечном итоге может привести к экономии времени и средств.
Наше путешествие по модульным тестам на этом не закончилось, мы продолжили искать способы улучшить производительность модульных тестов, чтобы сделать наш рабочий процесс более эффективным.
Путь Баффета: когда важны только избранные модульные тесты
Вот наш последний подход, давайте воспользуемся аналогией, чтобы было легче понять.
— Если вы пролили что-то на свой рабочий стол, должны ли вы вытирать все столы ваших коллег на нашем этаже?
— Нет, ты с ума сошел? Я буду вытирать только свой собственный стол и его окружение, на которое попали брызги.
— Ага, именно так вы должны делать и юнит-тесты.
Вы поняли? Наш последний подход — это выборочное тестирование, когда проводятся только связанные между собой модульные тесты. Так, например, допустим, у нас есть следующая структура для нашего модульного теста:
Позвольте мне немного пояснить, что означают стрелки и круги. Каждый кружок означает модуль, а каждая стрелка означает, что что-то зависит от другого. Так, например, на приведенной выше диаграмме модули A и E указывают на модуль C, что означает, что модули A и E зависят от модуля C. Другими словами, A и E — это обратные зависимости от C.
Исходя из предыдущей аналогии, если в модуле C произойдут изменения (прольется молоко), то непосредственно затронутыми модулями будет сам модуль C, а косвенно затронутыми модулями будут A и E. Итак, возвращаясь к обсуждению модульного тестирования, когда у нас есть изменения в модуле C, мы должны протестировать модули C (очевидно), A и E, как показано ниже.
Вы можете спросить, почему А и Е? Потому что модули A и E используют модуль C в качестве зависимости, поэтому каждое изменение в модуле C косвенно повлияет на модули A и E. Мы не будем тестировать остальные модули из-за их транзитивной зависимости. Когда A зависит от B, а B зависит от C, если C корректен, то A тоже должен быть корректен.
После долгой скучной болтовни давайте перейдем к блок-схеме реализации выборочного тестирования!
При выборочном тестировании мы реализуем три основных этапа:
Шаг 1 — Сравнение изменений файлов
Обычно, когда разработчик начинает создавать свои функции, он создает ответвление от основной ветки, где все изменения будут объединены. На этом этапе происходит сравнение изменений файлов из ветки разработки с основной веткой. Результатом этого шага будет список путей для каждого обновленного файла.
Шаг 2 — Извлечение целевого пути
Результатом шага 1 является только путь к файлу, нам нужно найти его имя цели, прежде чем мы перейдем к следующему шагу. На этом шаге мы рекурсивно найдем уникальный путь к файлу Bazel BUILD для каждого каталога.
Например, у нас есть такой путь:
parent/childOne/childTwo/childThree/ChangedFile.swift
Мы рекурсивно найдем BUILD-файл Bazel из parent/childOne/childTwo/childThree. Если BUILD-файл Bazel существует, мы сохраним его путь и разорвем дерево рекурсии.
Шаг 3 — Запрос обратных зависимостей
После того как мы нашли измененную цель, мы найдем ее обратные зависимости с помощью команды Bazel Sky Query allrdeps. Затем мы объединим все обратные зависимости в одну цель, как и в подходе номер два выше, и протестируем ее.
После запуска селективного тестирования мы работали с ним в течение примерно 3 месяцев в реальной среде разработки. Мы обнаружили значительное улучшение скорости модульного тестирования, особенно когда изменения файлов затрагивают только небольшие части кода, которые не многие модули используют в качестве зависимостей.
Теперь вернемся к сравнительному графику. Как вы можете видеть, основное различие в распределении между выборочным тестированием находится на нижнем конце данных. Поскольку выборочное тестирование нацелено на выполнение минимального количества тестов, оставаясь при этом верным, ожидаемо, что мы увидели увеличение количества низкого (быстрого) времени модульного тестирования. Медиана продолжительности тестирования также уменьшилась до ~250 с ~500 с.
Основное преимущество выборочного тестирования заключается в том, что мы также смогли достичь нулевого времени модульного тестирования (ага, тест сразу помечается как пройденный, потому что тестировать нечего!). Это тот случай, когда наши изменения в файле не затрагивают никакой основной базы кода, например, файл владельца кода на GitHub. Доказательством этого преимущества является количество точек с примерно нулевым временем тестирования при выборочном тестировании.
На приведенном выше графике видно, что один выборочный тест может вдвое снизить скорость юнит-тестирования. Благодаря возможности достичь мгновенного юнит-тестирования, когда ничего не нужно тестировать. Обратите внимание, что если изменения в файлах относятся к большому количеству модулей, время модульного тестирования будет более или менее таким же, как и при одиночном модульном тестировании из второго подхода.
Резюме
Юнит-тестирование — один из самых важных шагов в нашем инженерном процессе, позволяющий убедиться в том, что ничего не сломалось, по крайней мере, со стороны логики функций. Поэтому очень важно улучшить время модульного тестирования, поскольку оно может занять очень много времени, особенно в крупномасштабном приложении.
Наш путь совершенствования модульного тестирования не был таким гладким, как указано в этой статье, он был полон взлетов, падений и множества экспериментов, которые привели нас к тому, к чему мы пришли сегодня. Но как мы всегда говорим в Tokopedia — Make it happen, make it better.
Благодарности
И последнее, но не менее важное. Огромная благодарность
Венди Лига, которая начала эту одиссею с единственного модульного теста и вовлекла меня в наше последнее усовершенствование — выборочные тесты, которые в целом уменьшили продолжительность модульного тестирования в 8 раз по сравнению с первой итерацией.
Также спасибо нашим CI/CD машинам за работу с нами, они сейчас в более счастливом состоянии 😆.