Речь пойдет про наш любимый fastlane, если вы являетесь специалистом по Maraphon или Emcee, то, возможно, мои советы для вас окажутся больше вредными / нелепыми / и порой даже забывными — не обессудьте.
Но в первую очередь хочу отметить на Хабре две отменные статьи схожие по содержанию от одного и того же автора, очень рекомендую с ними ознакомиться:
-
Ускоряем прохождение iOS UI-тестов. Часть 1. Запуск тестов без сборки проекта
-
Ускоряем прохождение iOS UI-тестов. Часть 2. Распараллеливание тестов
И вот что я увидел в Fastfile
В Fastfile ничего не обычного, обычный запуск тестов через scan. Но для начала опишу испытуемый небольшой проект, для наглядности в виде списка:
-
iOS проект (Swift + SwiftUI + UIKit + SwiftPM)
-
Отдельная джоба для юнит тестов
-
Отдельная джоба для UI тестов на последней ОС (на момент написания статьи это iOS 26.4.1)
-
Отдельная джоба для UI тестов для iOS 18.6 (UI для iOS 26 и 18 сильно отличается, поэтому тестами покрываются обе версии, чтобы отлавливать регрессии на старых осях)
-
Джобы в пайплайне идут последовательно (для GitLab
concurrent = 1) -
Испытуемый проект для статьи это не оригинальный рабочий проект, так как оригинальный имеет очень много тестов и кода для сборки, тогда бы исследование для статьи вышло бы в очень долгий процесс, но испытуемый проект очень похож на боевой по структуре и самое важное по структуре тестов и их интеграции на CI
Вот так выглядят джобы в пайплайне на CI, просто запуск трех видов тестов:

А fastlane слудующим образом:
private_lane :run_testing do |options| scan( project: project, scheme: options[:scheme], testplan: options[:testplan], destination: "platform=iOS Simulator,id=#{options[:uuid]}", skip_detect_devices: true, code_coverage: false, derived_data_path: derived_data_path, disable_concurrent_testing: true )end lane :run_unit_tests do run_testing(scheme: "AppTests", testplan: "UnitTests", uuid: "123..") end lane :run_ui_tests_26_4 do run_testing(scheme: "AppUITests", testplan: "UITests", uuid: "123..") end lane :run_ui_tests_18_6 do run_testing(scheme: "AppUITests", testplan: "UITests", uuid: "456..") end
Обычный пускатель тестов без излишеств в каждом втором туториале про fastlane, где под капотом вот такая xcodebuild команда:
xcodebuild \ -scheme AppTests \ -project App.xcodeproj \ -derivedDataPath DerivedData/App-biooakmzntobceawoboqmizvekct \ -destination 'platform=iOS Simulator,id=5F53A860-544D-4A6F-9F1A-C414CC89DDA5' \ -disable-concurrent-testing \ -enableCodeCoverage NO \ -testPlan 'AppTests' \ build test
Запустим тесты и посмотрим на время выполнения:

Суммарное время вышло 11 минут и 4 секунды (запускал пайплайн несколько раз и взял среднее), зафиксируем это время как время отсчета до наших изменений.
Как можно видеть, все три джобы каждый раз собирают проект, иногда сборка попадает в кеш и быстро собирается, иногда на ровном месте SwiftPM глючит и начинает пересобирать свои зависимости. Предлагаю выделить отдельную джобу, которая будет один раз собрать исходники в текущем пайплайне для всех тестов и потом раздаст результат сборки тестовым джобам. Посмотрим, будет ли от этого толк.
Собираем приложение один раз и раздаем тестовым задачам
Для этого будем использовать новый флаг -testProductsPath из Xcode 13.3, который описан следующим образом:
xcodebuild now supports an .xctestproducts bundle format for the build-for-testing and test-without-building actions. Using a bundle makes it easier to run tests, particularly when transporting tests between systems. Use the new -testProductsPath argument to set the path to the bundle.
Простым языком, для поиска приложения для запуска тестов когда используется флаг build-for-testing теперь не нужно исследовать DerivedData, xcodebuild просто положит финальную сборку в специальную .xctestproducts директорию, которую потом нужно передать снова через -testProductsPath + test-without-building для запуска тестов.
Lane для сборки продукта для тестов будет выглядеть следующим образом:
private_lane :build_for_testing do |options| # создаем `.xctestproducts` прям в DerivedData директории product_path = "#{File.join(derived_data_path, "#{options[:scheme]}")}.xctestproducts" scan( project: project, scheme: options[:scheme], sdk: "iphonesimulator", destination: "generic/platform=iOS Simulator", # Тут важно это выставить `true` build_for_testing: true, code_coverage: false, derived_data_path: derived_data_path, # Так как fastlane не поддерживает `-testProductsPath` передаем через `xcargs` xcargs: "-testProductsPath #{product_path}" ) # Так как после сборки `.xctestproducts` также содержит артефакты сборки, # то мы удаляем лишнее, так как нам нужно только само приложение и пускатель UI тестов tests_files = [ "App.app", "AppUITests-Runner.app" ] derived_data = File.join(product_path, "Binaries/0/Debug-iphonesimulator") Dir.children(derived_data).each do |name| if tests_files.any?(name) next else path = "#{derived_data}/#{name}" if File.directory?(path) FileUtils.rm_rf(path) else File.delete(path) end end end # Директория артефактов, это директория сборок, которую мы будем передавать # тестовым джобам, которые будут ее передавать в `-testProductsPath` при запуске тестов artifacts_path = File.join(ENV["HOME"], "builds/artifacts/#{options[:uname]}") if !File.exist?(artifacts_path) Dir.mkdir(artifacts_path) end # Перемещаем `.xctestproducts` из Xcode DerivedData в артефакты FileUtils.mv(product_path, artifacts_path) # Возвращаем путь до `.xctestproducts` из артефактов File.join(artifacts_path, File.basename(product_path)) end
Мы описали lane который:
-
Соберет приложение в виде
${SCHEME}.xctestproductsдля тестов -
Поместит
${SCHEME}.xctestproductsв артефакты (специальную папку на CI) -
Вернет из функции путь до
${SCHEME}.xctestproductsв артефактах
В моем конкретно примере, этот lane вернет такого вида путь $HOME/builds/artifacts/2026.04.28_01-44-11-883074-+0700/${SCHEME}.xctestproducts. Под капотом xcodebuild комнада для сборки продукта (.xctestproducts) выглядит так:
xcodebuild \ -scheme AppTests \ -project App.xcodeproj \ -derivedDataPath DerivedData/App-biooakmzntobceawoboqmizvekct \ -sdk 'iphonesimulator' \ -destination 'generic/platform=iOS Simulator' \ -enableCodeCoverage NO \ -testProductsPath DerivedData/App-biooakmzntobceawoboqmizvekct/AppTests.xctestproducts \ build-for-testing
Теперь напишем lane, который собирает продукты для юнит тестов и UI тестов:
lane :build_tests_products do # Выставляем одинаковое имя, чтобы все продукты этой джобы лежали в одной директории uname = "#{Time.now.strftime("%Y.%m.%d_%H-%M-%S-%6N-%z")}" build_for_testing( scheme: "AppTests", uname: uname ) products = build_for_testing( scheme: "AppUITests", uname: uname ) File.write(File.join(project_dir, "products.txt"), File.dirname(products))end
Обратите внимание на последнюю строку:
File.write(File.join(project_dir, "products.txt"), File.dirname(products))
Так как у нас будет отдельная джоба для сборки приложения, то нам затем нужно передать эти данные дочерним тестовым процессам. Идеально это всё делать “по-взрослому”, заархивировать данные и загрузить в artifactory на aws например, откуда дочерние джобы себе скачают, что им нужно. Но для тестового примера или когда у вас CI это просто одна физическая машина, то проще результаты сборки сохранить тут же локально и просто передать путь до них через простой текстовый файл который загрузить в тот же artifactory, при этом нужно загрузить только этот небольшой файл, а не огромный архив, и потом ничего долго скачивать не нужно, так как файлы тестовых сборок уже лежат локально на машине.
Поэтому в нашем примере, пишем путь до .xctestproducts в файл products.txt, который положим в корень проекта, что будет автоматически скачено дочерней джобой и автоматически станет доступно в джобе тестирования. В .gitlab-ci.yml это будет выглядеть примерно так:
iOS Build Tests Products: extends: .testing_template script: - bundle exec fastlane ios build_tests_products artifacts: when: on_success paths: - products.txt expire_in: 1 dayiOS 26.4.1 iPhone | UI Tests: extends: .testing_template needs: [ "iOS Build Tests Products" ] script: - bundle exec fastlane ios run_ui_tests_26_4
Директория со сборками для тестов на CI будет похожа на пример:

А файл products.txt имеет следующее содержание:
cat products.txt /Users/user/builds/artifacts/2026.04.28_13-54-47-168741-+0700
После коммита этих изменений в репозиторий, наш пайплайн стал выглядеть следующим образом:

И наконец обновим наш последний lane с именем run_testing который запускает тесты, раньше он и собирал приложение и сразу же на нем запускал тесты. Теперь за сборку тестов у нас отвечает отдельный lane с именем build_tests_products , поэтому немного поправим lane запуска тестов чтобы он не собирал приложение, а передавал готовую сборку через -testProductPath.
private_lane :run_testing do |options| products_path = File.read(File.join(project_dir, "products.txt")) product_path = "#{products_path}/#{options[:scheme]}.xctestproducts" scan( testplan: options[:testplan], # Важно передать пустую строку из-за бага https://github.com/fastlane/fastlane/issues/20540 package_path: "", # Важно выключить `build` фазу. Поведение флага: # - когда не skip, то добавляется 'build test' # - когда skip, то добавляется 'test' skip_build: true, # Выставляем в `true`, чтобы выключить 'test' флаг (смотри выше) test_without_building: true, destination: "platform=iOS Simulator,id=#{options[:destination]}", skip_detect_devices: true, disable_concurrent_testing: true, derived_data_path: product_path, xcargs: "-testProductsPath #{product_path}" )end
Что транслируется в
xcodebuild \ -destination 'platform=iOS Simulator,id=4E015A22-1A11-4C64-8A4C-46D2285B0262' \ -derivedDataPath /Users/user/builds/artifacts/2026.04.28_22-34-01-946967-+0700/AppTests.xctestproducts \ -disable-concurrent-testing \ -enableCodeCoverage NO \ -testPlan 'AppTests' \ -testProductsPath /Users/user/builds/artifacts/2026.04.28_22-34-01-946967-+0700/AppTests.xctestproducts \ test-without-building
Запускаем снова весь пайплайн, чтобы проверить, стоило ли это всех наших усилий:

Ожидаемо получаем прирост в 21.7% (8 минут и 40 секунд вместо 11 минут, запускал пайплайн несколько раз и взял среднее), то есть на 1/5 стало быстрее, а это почти две с половиной минуты. В день этот пайплайн разработчиками может запускаться десятки раз, потому что иногда пайплайны запускались не только на создание MR/PR, но и просто на push в ветку. Идем дальше.
Переиспользуем симуляторы для запуска тестов
Переходим к следующей оптимизации скорости пайплайна. Приведу часть кода из Fastlane, что меня заинтересовал:
private_lane :run_testing do |options| uuid = (sh "xcrun simctl create #{options[:SimName]} #{options[:SimDeviceType]} #{options[:SimRuntime]}").chop sh "xcrun simctl boot #{uuid}" sh "xcrun simctl bootstatus #{uuid}" begin scan( destination: "platform=iOS Simulator,id=#{uuid}", ... ) ensure sh "xcrun simctl shutdown #{uuid}" sh "xcrun simctl delete #{uuid}" endend
Это тот самый run_testing который запускает тесты. Обратите внимание, что на каждый тест этот lane создает симулятор и после теста удаляет его. Нет никакого намека на использование уже созданных симуляторов. Сейчас объясню, в чем разница. Но сначала выясним что делает каждая команда из кода выше для simctl. Как вы знаете simctl позволяет управлять симуляторами. Выше команды делают следующее.
-
Команда
createсоздает симулятор:
xcrun simctl create ${SimName} ${SimDeviceType} ${SimRuntime}
-
Команда
bootподнимает симулятор:
xcrun simctl boot 4E015A22-1A11-4C64-8A4C-46D2285B0262
-
Команда
bootstatusлочит вызывателя до тех пор, пока симулятор не будет готов к работе:
xcrun simctl bootstatus 4E015A22-1A11-4C64-8A4C-46D2285B0262
Теперь устроим замеры скорости запуска симулятора уже созданного и только что созданного. Командой ниже через time замеряем общее время трех simctl команд create + boot + bootstatus, то есть только что созданного симулятора, который сразу же запускаем и ждем, когда он будет готов к использованию:
time (SIM_ID=$(xcrun simctl create "iPhone-17-Pro-Max-Unit-Tests" "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max" "com.apple.CoreSimulator.SimRuntime.iOS-26-4") \ && xcrun simctl boot ${SIM_ID} \ && xcrun simctl bootstatus ${SIM_ID})
Результат:
[2026-04-28 17:43:24 +0000] Status=4, isTerminal=NO, Elapsed=00:29.Waiting on System App[2026-04-28 17:43:25 +0000] Status=4, isTerminal=NO, Elapsed=00:30.Waiting on System App[2026-04-28 17:43:26 +0000] Status=4294967295, isTerminal=YES, Elapsed=00:31.Finished0.08s user 0.09s system 0% cpu 32.036 total
То есть процесс подготовки симулятора к тестам занимает 32 секунды. Теперь то же самое, только пропускаем шаг создания симулятора:
time (xcrun simctl boot ${SIM_ID} && xcrun simctl bootstatus ${SIM_ID})
Результат в 3 раза быстрее:
Monitoring boot status for iPhone-17-Pro-Max-Unit-Tests (BC8ABA4E-2FCD-4D79-AEE3-5F9B728496B4).[2026-04-28 17:48:44 +0000] Status=1, isTerminal=NO, Elapsed=00:01.Waiting on BackBoard[2026-04-28 17:48:50 +0000] Status=4, isTerminal=NO, Elapsed=00:07.Waiting on System App[2026-04-28 17:48:52 +0000] Status=4294967295, isTerminal=YES, Elapsed=00:09.Finished( xcrun simctl boot ${SIM_ID} && xcrun simctl bootstatus ${SIM_ID}; ) 0.07s user 0.08s system 1% cpu 9.855 total
У нас в пайплайне идет 3 теста и 3 раза мы создаем симулятор. Если отталкиваться от тестов выше, то переиспользование симуляторов дает нам +20 секунд дополнительного времени, а для трех целая минута. Пока это теория, поэтому обновляем тестовые lane чтобы проверить. Для простоты эксперимента просто захардкодим ID симуляторов:
lane :run_unit_tests do run_testing( scheme: "AppTests", testplan: "AppTests", destination: "8A23906D-84CA-42FA-9703-1CA8986B11D9" )endlane :run_ui_tests do run_testing( scheme: "AppUITests", testplan: "AppUITests", destination: "8A23906D-84CA-42FA-9703-1CA8986B11D9" )end
А из lane пускателя тестов убираем создание и удаление симулятора
private_lane :run_testing do |options| uuid = options[:destination] sh "xcrun simctl boot #{uuid}" sh "xcrun simctl bootstatus #{uuid}" begin scan( destination: "platform=iOS Simulator,id=#{uuid}", ... ) ensure sh "xcrun simctl shutdown #{uuid}" endend
Пушим в репо и перезапускам весь пайплайн и средний результат у нас получается 7 минут и 10 секунд, что на 1 минуту и 30 секунд быстрее предыдущего результата (теоретические расчеты примерно эту цифру и показывали).

На данный момент мы ускорили пайплайн на 35.24%. Идем дальше.
Выжимаем максимум из симуляторов
Следующий шагом предлагаю снова улучшить работу с симуляторами. На этот раз не будем закрывать (shutdown) симулятор после теста. Более того, мы на проекте сделали следующий хинт, по cron у нас перед началом работы каждое утро рабочего дня запускается задача, которая:
-
Закрывает все симуляторы
xcrun simctl shutdown all
-
Чистит все симуляторы
xcrun simctl erase all
-
Создает новые симуляторы если необходимо
-
Запускает необходимые симуляторы, чтобы, когда запускались тесты, они уже были “прогреты”
Правим наш run_testing чтобы он:
-
Если симулятор не запущен, то запускал перед тестом
-
Не закрывал симулятор после теста, чтобы он был переиспользуемым
private_lane :run_testing do |options| uuid = options[:destination] is_booted = (sh "xcrun simctl list devices booted").include?(uuid) unless is_booted sh "xcrun simctl boot #{uuid}" sh "xcrun simctl bootstatus #{uuid}" end scan( destination: "platform=iOS Simulator,id=#{uuid}", ... )end
Пушим изменения и прогоняем пайплайн очередной раз. Получаем картину:

Где средний результат 6 минут и 8 секунд, что на 1 минуту лучше предыдущего. На данный момент мы ускорили пайплайн на 44.58%. Идем дальше.
Разбиваем джобы по раннерам
Сделаем анализ джоб в пайплайне.
-
Сборка проекта (долгая и тяжелая для процессора работа)
-
Юнит тесты (легкая работа, слабо нагружает процессор)
-
UI тесты (тяжелые продолжительные тесты)
Из анализа видно, что юнит тесты это легкая работа и если юнит тесты параллельно запускать с тяжелой джобой, то существенного эффекта на тяжелую задачу не будет. Если параллельно запускать тяжелые джобы, то они будут мешать другу, и, например, почти точно UI тесты начнут флаковать чаще обычного.
Наша цель, запускать юнит тесты вместе с UI тестами параллельно. Для этого создадим второй раннер на CI. У нас есть один дефолтный раннер ios, который теперь будет отвечать за выполнение тяжелых задач. И новый раннер, на котором мы будем запускать только легкие задачи. Разрешим новому раннеру запускать до 2 легких задач параллельно, а старому раннеру разрешим только 1 тяжелую джобу, а самому CI выставить возможность запускать только 2 джобы в параллели. То есть у нас теперь могут запускаться такие комбинации:
-
1 легкая задача + 1 тяжелая задача
-
2 легких задачи
-
Если нет легких задач, то запуститься только одна тяжелая задача
Создаем второй раннер с тегом ios_low_load и прописываем в ~/.gitlab-runner/config.toml (если вы используете GitLab):
concurrent = 2[[runners]] name = "ios" limit = 1 [[runners]] name = "ios_low_load" limit = 2
Затем раставляем эти теги в .gitlab-ci.yml, тут всё просто, только для юнит тестов выставляем новый раннер ios_low_load, для остальных оставляем старый.
iOS iPhone | Unit Tests: tags: - ios_low_load
Пушим и запускаем. Так как у нас юнит тесты занимают почти 30-40 секунд, в теории мы должны получить прирост примерно такой же.

Если сложить время всех задач, то получим 5 минут 49 секунд. Но время самого пайплайна 5 минут и 23 секунды, потому что часть задач стали выполняться параллельно. Что дало нам прирост в скорости, чем дольше бы у нас были юнит тесты, тем больше бы прирост мы получили. На данном этапе мы улучшили скорость пайплайна на 51.36%, то есть в 2 раза, как вы помните, без всех изменений выше время пайплайна было больше 11 минут. Переходим на финальный этап.
Распараллеливаем UI тесты
Наверное, самая холиварная часть этой статьи. Сначала определимся, что значит параллельное UI тестирование. Xcode (xcodebuild если точнее) имеет механизм параллельного тестирования на нескольких симуляторах в рамках одного тестового запуска. Вот так выглядит симулятор, когда параллельное тестирование выключено:

А вывод команды запущенных симуляторов выглядит так:
xcrun simctl list devices booted== Devices ==-- iOS 26.4 -- iPhone-17-Pro-Max-Unit-Tests (8A23906D-84CA-42FA-9703-1CA8986B11D9) (Booted)
А так выглядят симуляторы при параллельном тестировании:

Если во время теста мы запустим команду получения списка запущенных симуляторов:
xcrun simctl list devices booted== Devices ==-- iOS 26.4 --
То получим пустой список. Из скриншота видно, что это не обычные симуляторы, а клоны симулятора, который был передан через -destination. Так как эти симуялторы не трекаются simctl, то во время теста мы не можем ими управлять, например если захотим во время теста средствами simctl получиться скриншот экрана, мы этого не сможем сделать, потому что нам не известен UDID симулятора.
Внимательный читатель, возможно, заметил, что в fastlane командах выше мы использовали disable_concurrent_testing: true которые для xcodebuild транслируется в -disable-concurrent-testing чтобы отключить параллельное тестирование, но на самом деле этот флаг ничего не делает так как мы запускаем тесты через testPlan, а testPlan имеет свою настройку для параллельного запуска и она берет приоритет над -disable-concurrent-testing. Мы этот флаг в fastlane использовали, чтобы подчеркнуть, что запускаем тест не в параллельном режим, хотя на самом деле это было выключено на уровне testPlan вот тут:

Теперь опишем как Xcode запускает тесты в параллельном режиме (включено в testPlan). Когда вы запускаете команду:
xcodebuild \-destination 'platform=iOS Simulator,id=8A23906D-84CA-42FA-9703-1CA8986B11D9' \-testPlan 'UITests-iPhone' \-testProductsPath AppUITests.xctestproducts \test-without-building
-
xcodebuildзавершает работу симулятора (8A23906D-84CA-42FA-9703-1CA8986B11D9) если он запущен -
Создает клонов этого симулятора сколько считает нужным если явно не задано через команду (
-parallel-testing-worker-count N) (где N — количество клонов):
xcrun simctl clone 8A23906D-84CA-42FA-9703-1CA8986B11D9 "Clone ${N} ${DEVICE_NAME}"
-
Запускает параллельно тесты на клонах
-
После завершения тестов клоны уничтожаются (их нельзя переиспользовать, потому что у них нет UDID)
На базе этого мы делаем выводы, где у такого подхода могут быть проблемы:
-
Клоны не имеют UDID и мы не можем ими управлять
-
Клоны всегда перезапускаются, невозможно создать pool клонов и переиспользовать, чтобы не тратить время на запуск, так как запуск симуляторов это тяжелая операция для CPU (откройте
htopи сделайтеbootсимулятору). Вот скриншот с мощнойMмашины, тутxcodebuildзапускает одного клона для тестов:

А ведь для параллельного тестирования мы будем создавать 4-5-6 клонов (среднее устоявшееся кол-во при котором тесты на флакуют), и запуск этих 4-6 клонов с легкостью съедают от 3 до 5 минут прежде, чем все тесты в параллели запустятся на симуляторах. Если у вас немного тестов, то ситуация бывает такой, что на первом клоне тесты уже прошли, а четвертый или пятый еще даже не прогрузились.
Но опять же это всё теория. Давайте делать замеры.
-
Тесты без параллельного тестирования для нашего тест плана мы можем взять из джоб, что мы описывали выше, последний результат был 2 минуты и 10 секунд
-
Тесты с параллельным режимом. Наш тест план имеет всего 2 теста (2 тестовых XCTest класса), но все равно проверим. В
fastlaneвыставляемconcurrent_workers: 2чтобы точно запустилось нужное нам кол-во клонов-симуляторов. Два перезапуска и вот результат:
private_lane :run_testing do |options| scan( concurrent_workers: 2, ... )end
xcodebuild \ -destination 'platform=iOS Simulator,id=8A23906D-84CA-42FA-9703-1CA8986B11D9' \ -derivedDataPath AppUITests.xctestproducts \ -parallel-testing-worker-count 2 \ -testPlan 'AppUITests' \ -testProductsPath AppUITests.xctestproducts \ test-without-building

2 минуты и 30 секунд, что даже медленнее, чем при последовательном запуске на одном симуляторе. Этому есть простое объяснение — время перезапуска клонов очень долгое, что нивелирует весь профит от параллельного запуска (но тут важно сказать, что это только для малого кол-ва тестов, в больших масштабах время запуска практически погрешность и там уже другие метрики)
-
Делаем замер параллельного запуска, но ручного:
-
Выключаем в тест плане параллельный запуск
-
На каждый тест назначаем уже прогретый симулятор сами
Так как мы выключили автоматический параллельный запуск, нам нужно самим написать код, который раскидывает тесты по симуляторам. В теории рассортировать тесты по симуляторам несложно, например сходу у меня было решение:
lane :run_ui_tests do |options| thread1 = Thread.new do scan( destination: "platform=iOS Simulator,id=#{SIMULATOR_1}", only_testing: [ "UITest-1", "UITest-2", ] ) end thread2 = Thread.new do scan( destination: "platform=iOS Simulator,id=#{SIMULATOR_2}", only_testing: [ "UITest-3", "UITest-4", ] ) end thread1.join thread2.joinend
К сожалению, fastlane не поддерживает многопоточность. Поэтому это пришлось параллелизацию вынести из fastlane на bash:
lane :run_ui_tests do |options| scan( destination: "platform=iOS Simulator,id=#{options[:destination]}", only_testing: options[:only_testing].split(","), disable_concurrent_testing: true )end
fastlane ios run_ui_tests destination:${SIM_1} only_testing:'UITest-1,UITest-2' & \ fastlane ios run_ui_tests destination:${SIM_2} only_testing:'UITest-3,UITest-4' & \ wait
Очевидно получаем прирост скорости, потому что параллелизация и прогретые симуляторы, итоговое время 1 минута и 38 секунд, что на 24.62% быстрее непараллельного режима и на 34.66% быстрее параллельного режима с холодным стартом клонов.

Из чего мы делаем вывод, если у вас есть время и вы хотите выжать максимум из симуляторов для UI тестов, то вам нужно присмотреться к ручному управлению параллелизации на уже прогретых симуляторах. Ускорение пайплайна на 3-5 минут кажется не существенным, но, если пайплайн запускается десятки раз за день, это время уже идет на часы.
Если вы только настраиваете запуск тестов на CI, я рекомендую посмотреть на Maraphon. По их документации, они как раз решают проблему с холодным стартом клонов, просто вместо клонов создавая и кешируя нормальные симуляторы.
Основано на реальном проекте:
Была в одном проекте как раз такая проблемная джоба, которая запускала UI тесты. Время ее прогона было около 30 минут (4 симулятора в параллели через клоны, больше CI не тянул). Тестов было и правда много — сотня-две. Джоба запускалась довольно часто (до 10 раз в день легко, а если релиз и того больше). Постоянно в чате было, что QA ждёт, когда пройдет пайплайн, чтобы забрать билд на проверку, да и сами разработчики ждали, потому что UI тесты грузили сильно CI, второй пайплайн не запустишь.
В итоге после применения всех пунктов, что описаны выше в статье, удалось снизить время выполнения UI тестов с 30 минут до 12-15 минут насколько помню. После данные оптимизации были добавлены и в другие UI тесты, и даже юнит тесты нам удалось ускорить в несколько раз, потом что мы написали тулу, которая на лету разбивала тест план на несколько тест планов и запускала их на свободных прогретых симуляторах. Я потом делал расчет времени каждого теста и их суммарное время на CI. И у меня выходило, что тесты выполнялись за теоретический максимум, так как в тестах не было потерь времени на что-то другое кроме тестов (запуск симулятор и прочее). То есть пришли в итоге к такому состоянию, что просто уже нечего было улучшать. Теоретический предел посчитать легко это сумма времени каждого теста, деленное на кол-во симуляторов, время тестов можно вытащить из .xcresult. Если у вас это время сильно отличается от времени прохождения джобы/пайплайна, то нужно искать, где это время теряется, и попытаться убрать этот bottle neck.
Бонус, кто дочитал до конца, что такое .xctestproducts
Это директория очень напоминает DerivedData проекта, но структурирована немного иначе. Директрия содержит не только результат сборки приложения, но и, логично, скомпилированне тест планы, что есть ${TESTPLAN_NAME}.xctestrun, что из себя представляет обычный plist файл. Причем самое интересное, что этот файл мы можем изменять, если нужно изменить поведение теста, и не нужно после изменений снова перекомпилировать testplan, можно сразу же использовать измененный .xctestrun. Покажу пару примеров, которые я сам использовал:
В .xctestrun есть такая секция:
<key>CommandLineArguments</key><array> <string>-AppleLanguages</string> <string>(en)</string> <string>-AppleTextDirection</string> <string>NO</string> <string>-AppleLocale</string> <string>en_US</string></array>
Если мы хотим запустить тесты (например снапшот тесты) для разных языков, мы можем один раз собрать приложение, и меняя .xctestrun без пересборки приложения прогонять тесты на разных языках очень быстро. Например, для французского языка выставляем:
<key>CommandLineArguments</key><array> <string>-AppleLanguages</string> <string>(fr)</string> <string>-AppleTextDirection</string> <string>NO</string> <string>-AppleLocale</string> <string>fr_FR</string></array>
Следующая интересная секция:
<key>OnlyTestIdentifiers</key><array> <string>UITest1/test()</string> <string>UITest2/test()</string></array>
Это список тестов, которые будут запущены. Мы как раз писали мини тулу, которая берет отсюда список тестов, группирует их по времени выполнения (чтобы каждая группа тестов выполнялась за одно время), создает новый тест план под каждый симулятор и запускает в параллельном режиме.
И последняя довольно интересная секция:
<key>EnvironmentVariables</key><dict> <key>LOCAL_PORT</key> <string>8000</string></dict>
Через EnvironmentVariables можно пробрасывать разлиные данные в тестовое приложение, например чтобы не хардкодить port для локального сервера, который предоставляет приложению тестовые данные, его можно передать как через testplan, так и напрямую через .xctestrun. Например, если у вас несколько CI и вы передали на другой CI .xctestproducts, то сборщик, возможно, не имеет знаний, какой port нужно было выставить в оригинальном testplan, поэтому уже на месте исполнения тестов это можно пробросить именно тут.
Цель статьи:
-
Не растерять накопленные знания
-
Скомпоновать знания в читаемый вид (до этого это лежало кусками в разных местах)
-
Поделиться с сообществом
-
Себе шпаргалка для будущих проектов
Спасибо всем, кто дочитал. Надеюсь было полезно.
Для написания статьи ИИ не использовался, только спелчеккер для русского языка в IntelliJ Idea.
ссылка на оригинал статьи https://habr.com/ru/articles/1030150/