惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cisco Talos Blog
Cisco Talos Blog
V
V2EX
C
Check Point Blog
GbyAI
GbyAI
D
Docker
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
N
Netflix TechBlog - Medium
T
Troy Hunt's Blog
博客园 - Franky
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Microsoft Security Blog
Microsoft Security Blog
P
Privacy & Cybersecurity Law Blog
WordPress大学
WordPress大学
The Cloudflare Blog
S
SegmentFault 最新的问题
Latest news
Latest news
Microsoft Azure Blog
Microsoft Azure Blog
P
Proofpoint News Feed
I
InfoQ
博客园 - 【当耐特】
NISL@THU
NISL@THU
A
About on SuperTechFans
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
The Hacker News
The Hacker News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Scott Helme
Scott Helme
雷峰网
雷峰网
C
CXSECURITY Database RSS Feed - CXSecurity.com
Security Latest
Security Latest
V
Vulnerabilities – Threatpost
Security Archives - TechRepublic
Security Archives - TechRepublic
A
Arctic Wolf
Hacker News: Ask HN
Hacker News: Ask HN
N
News and Events Feed by Topic
IT之家
IT之家
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
aimingoo的专栏
aimingoo的专栏
T
Threat Research - Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
阮一峰的网络日志
阮一峰的网络日志
SecWiki News
SecWiki News
大猫的无限游戏
大猫的无限游戏
S
Security Affairs
The Register - Security
The Register - Security
www.infosecurity-magazine.com
www.infosecurity-magazine.com
L
LINUX DO - 热门话题
T
Tor Project blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Xcode Simulator — Ускоряем прогон тестов на CI + Fastlane
house2008 · 2026-04-30 · via Все публикации подряд на Хабре

Уровень сложностиСложный

Время на прочтение17 мин

Охват и читатели0

Туториал

Речь пойдет про наш любимый fastlane, если вы являетесь специалистом по Maraphon или Emcee, то, возможно, мои советы для вас окажутся больше вредными / нелепыми / и порой даже забывными - не обессудьте.

Но в первую очередь хочу отметить на Хабре две отменные статьи схожие по содержанию от одного и того же автора, очень рекомендую с ними ознакомиться:


И вот что я увидел в Fastfile

В Fastfile ничего не обычного, обычный запуск тестов через scan. Но для начала опишу испытуемый небольшой проект, для наглядности в виде списка:

  1. iOS проект (Swift + SwiftUI + UIKit + SwiftPM)

  2. Отдельная джоба для юнит тестов

  3. Отдельная джоба для UI тестов на последней ОС (на момент написания статьи это iOS 26.4.1)

  4. Отдельная джоба для UI тестов для iOS 18.6 (UI для iOS 26 и 18 сильно отличается, поэтому тестами покрываются обе версии, чтобы отлавливать регрессии на старых осях)

  5. Джобы в пайплайне идут последовательно (для GitLab concurrent = 1)

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

  1. Соберет приложение в виде ${SCHEME}.xctestproducts для тестов

  2. Поместит ${SCHEME}.xctestproducts в артефакты (специальную папку на CI)

  3. Вернет из функции путь до ${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 day

iOS 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}"
    end
end

Это тот самый run_testing который запускает тесты. Обратите внимание, что на каждый тест этот lane создает симулятор и после теста удаляет его. Нет никакого намека на использование уже созданных симуляторов. Сейчас объясню, в чем разница. Но сначала выясним что делает каждая команда из кода выше для simctl. Как вы знаете simctl позволяет управлять симуляторами. Выше команды делают следующее.

  1. Команда create создает симулятор:

xcrun simctl create ${SimName} ${SimDeviceType} ${SimRuntime}
  1. Команда boot поднимает симулятор:

xcrun simctl boot 4E015A22-1A11-4C64-8A4C-46D2285B0262
  1. Команда 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.
	Finished

0.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"
    )
end

lane :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}"
    end
end

Пушим в репо и перезапускам весь пайплайн и средний результат у нас получается 7 минут и 10 секунд, что на 1 минуту и 30 секунд быстрее предыдущего результата (теоретические расчеты примерно эту цифру и показывали).

На данный момент мы ускорили пайплайн на 35.24%. Идем дальше.


Выжимаем максимум из симуляторов

Следующий шагом предлагаю снова улучшить работу с симуляторами. На этот раз не будем закрывать (shutdown) симулятор после теста. Более того, мы на проекте сделали следующий хинт, по cron у нас перед началом работы каждое утро рабочего дня запускается задача, которая:

  1. Закрывает все симуляторы

xcrun simctl shutdown all
  1. Чистит все симуляторы

xcrun simctl erase all
  1. Создает новые симуляторы если необходимо

  2. Запускает необходимые симуляторы, чтобы, когда запускались тесты, они уже были “прогреты”

Правим наш run_testing чтобы он:

  1. Если симулятор не запущен, то запускал перед тестом

  2. Не закрывал симулятор после теста, чтобы он был переиспользуемым

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%. Идем дальше.


Разбиваем джобы по раннерам

Сделаем анализ джоб в пайплайне.

  1. Сборка проекта (долгая и тяжелая для процессора работа)

  2. Юнит тесты (легкая работа, слабо нагружает процессор)

  3. UI тесты (тяжелые продолжительные тесты)

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

Наша цель, запускать юнит тесты вместе с UI тестами параллельно. Для этого создадим второй раннер на CI. У нас есть один дефолтный раннер ios, который теперь будет отвечать за выполнение тяжелых задач. И новый раннер, на котором мы будем запускать только легкие задачи. Разрешим новому раннеру запускать до 2 легких задач параллельно, а старому раннеру разрешим только 1 тяжелую джобу, а самому CI выставить возможность запускать только 2 джобы в параллели. То есть у нас теперь могут запускаться такие комбинации:

  1. 1 легкая задача + 1 тяжелая задача

  2. 2 легких задачи

  3. Если нет легких задач, то запуститься только одна тяжелая задача

Создаем второй раннер с тегом 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
  1. xcodebuild завершает работу симулятора (8A23906D-84CA-42FA-9703-1CA8986B11D9) если он запущен

  2. Создает клонов этого симулятора сколько считает нужным если явно не задано через команду (-parallel-testing-worker-count N) (где N - количество клонов):

xcrun simctl clone 8A23906D-84CA-42FA-9703-1CA8986B11D9 "Clone ${N} ${DEVICE_NAME}"
  1. Запускает параллельно тесты на клонах

  2. После завершения тестов клоны уничтожаются (их нельзя переиспользовать, потому что у них нет UDID)

На базе этого мы делаем выводы, где у такого подхода могут быть проблемы:

  1. Клоны не имеют UDID и мы не можем ими управлять

  2. Клоны всегда перезапускаются, невозможно создать pool клонов и переиспользовать, чтобы не тратить время на запуск, так как запуск симуляторов это тяжелая операция для CPU (откройте htop и сделайте boot симулятору). Вот скриншот с мощной M машины, тут xcodebuild запускает одного клона для тестов:

А ведь для параллельного тестирования мы будем создавать 4-5-6 клонов (среднее устоявшееся кол-во при котором тесты на флакуют), и запуск этих 4-6 клонов с легкостью съедают от 3 до 5 минут прежде, чем все тесты в параллели запустятся на симуляторах. Если у вас немного тестов, то ситуация бывает такой, что на первом клоне тесты уже прошли, а четвертый или пятый еще даже не прогрузились.

Но опять же это всё теория. Давайте делать замеры.

  1. Тесты без параллельного тестирования для нашего тест плана мы можем взять из джоб, что мы описывали выше, последний результат был 2 минуты и 10 секунд

  2. Тесты с параллельным режимом. Наш тест план имеет всего 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 секунд, что даже медленнее, чем при последовательном запуске на одном симуляторе. Этому есть простое объяснение - время перезапуска клонов очень долгое, что нивелирует весь профит от параллельного запуска (но тут важно сказать, что это только для малого кол-ва тестов, в больших масштабах время запуска практически погрешность и там уже другие метрики)

  1. Делаем замер параллельного запуска, но ручного:

  • Выключаем в тест плане параллельный запуск

  • На каждый тест назначаем уже прогретый симулятор сами

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

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.join
end

К сожалению, 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, поэтому уже на месте исполнения тестов это можно пробросить именно тут.


Цель статьи:

  1. Не растерять накопленные знания

  2. Скомпоновать знания в читаемый вид (до этого это лежало кусками в разных местах)

  3. Поделиться с сообществом

  4. Себе шпаргалка для будущих проектов

Спасибо всем, кто дочитал. Надеюсь было полезно.

Для написания статьи ИИ не использовался, только спелчеккер для русского языка в IntelliJ Idea.