Автоматическое тестирование приложений
Как 40 строк кода ускорили End to End тесты на iOS более чем на 50%
Наш опыт подчеркивает важный урок: часто самые узкие места могут быть решены с помощью самых простых изменений — если вы просто найдете время, чтобы обратить внимание на проблему.
В Wealthfront (компания финансовых услуг, базирующаяся в Пало-Альто) тестирование является одним из основных инженерных принципов — в виде модульного и сквозного (end-to-end) тестирования на всех платформах. В частности, команда iOS управляет собственной CI-инфраструктурой для запуска тестов на интеграционном сервере, зеркально отражающем производственную среду. С учетом того, что у нас в iOS около 30 тысяч юнит-тестов и почти 1 тысяча сквозных тестов (и это число растет), ускорение нашего E2E-пакета стало первоочередной задачей, поскольку на его выполнение уходит значительное количество времени. В этом посте мы расскажем о том, как мы ускорили наши тесты на 50% с помощью небольшого, целенаправленного изменения.
Проблема: медленные и нестабильные UI тесты
iOS E2E тесты уже давно являются головной болью. XCUITest и xcodebuild печально известны своими сбоями и зависаниями, и когда тесты не имеют защиты от этих проблем, скорость и надежность значительно страдают. Несмотря на такие оптимизации, как отключение анимации, включение параллельного тестирования и распределение тестов по нескольким Mac Mini, наш набор E2E-тестов занимал значительное количество времени — даже при одновременном выполнении на трех машинах.
Мы пришли к выводу, что есть две основные стратегии, когда речь идет о iOS E2E-тестах и XCUITest, но мы всегда отдаем предпочтение надежности:
- Приоритет скорости над надежностью: выполняя немедленные утверждения, например
XCTAssertTrue(self.testApp.textFields[“email”].exists)
, мы значительно увеличиваем скорость, но это не очень хорошо масштабируется в распараллеленной среде, особенно на старых машинах. - Приоритет надежности над скоростью: ожидание истинности утверждений, например
self.testApp.textFields[“email”].waitForExistence()
, существенно снижает скорость, но лучше масштабируется в распараллеленных средах.
чтобы включать все больше и больше тестов E2E, мы остановились на последней стратегии, используя XCTWaiter для опроса условий, которые должны быть выполнены, если только мы не будем абсолютно уверены, что элемент немедленно удовлетворит тому предикату, который мы утверждаем. Влияние этого на время тестирования можно увидеть в журнале тайминга, предоставляемом xcode.
Вот сниппет одного из наших повторяющихся тестов потока вывода:
t = 38.63s Checking existence of `"DateAccessibilityIdentifier" Cell`
t = 39.67s Checking existence of `"DateAccessibilityIdentifier" Cell`
t = 40.69s Find the "DateAccessibilityIdentifier" Cell
t = 41.72s Find the "DateAccessibilityIdentifier" Cell
t = 42.77s Checking existence of `"March 16, 2025" StaticText`
t = 43.79s Checking existence of `"DateAccessibilityIdentifier" Cell`
t = 44.80s Checking existence of `"DateAccessibilityIdentifier" Cell`
t = 45.82s Find the "DateAccessibilityIdentifier" Cell
t = 46.85s Find the "DateAccessibilityIdentifier" Cell
t = 47.87s Checking existence of `"March 16, 2025" StaticText`
t = 48.89s Checking existence of `"AmountInputAccessibilityIdentifier" Other`
t = 49.94s Find the "AmountInputAccessibilityIdentifier" Other
t = 50.00s Find the "AmountInputAccessibilityIdentifier" Other
t = 51.06s Tap "AmountInputAccessibilityIdentifier" Other
Заметили ли вы узкие места?
Кое-что, что привлекло наше внимание, — это 1 секунда между каждым запросом. После некоторого расследования мы обнаружили, что механизм опроса XCTWaiter (который является неизменяемым и неконфигурируемым) ждет 1 секунду между проверками, блокируя цикл выполнения процесса раннера тестирования, пока не будет выполнено условие или не будет достигнут тайм-аут. Этот дизайн явно устарел, он появился в эпоху до появления компьютеров mac серии M.
Быстрое фикс: ускоренный опрос с помощью кастомной замены XCTWaiter
Поскольку Apple закрыла доступ к своей реализации, мы решили воссоздать и улучшить этот механизм с нуля. Наша первая итерация включала опрос каждые 0.1 секунды вместо 1 секунды. Мы также добавили предварительную проверку, чтобы обойти полинг, если условие уже было истинным с самого начала. Вот наша первоначальная реализация:
xxxxxxxxxx
extension XCUIElement {
@discardableResult
class func wait(for condition: @escaping () -> Bool,
timeout: TimeInterval = 8.0,
failureMessage: String = "Condition not met",
hardAssertion: Bool = true,
description: String) -> Bool {
if condition() { return true }
let start = Date()
let timeoutDate = start.addingTimeInterval(timeout)
let expectation = XCTestExpectation(description: description)
let pollInterval = 0.1
while Date() < timeoutDate {
if condition() {
expectation.fulfill()
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
}
if hardAssertion { XCTFail(failureMessage) }
return false
}
}
После обновления наших вспомогательных функций для использования этого нового механизма разница была заметна сразу:
Хотя локальное увеличение скорости на 80% было многообещающим, эти улучшения не нашли отражения в нашем CI. Мы столкнулись с рядом проблем при работе в распараллеленной среде, вероятно, из-за дополнительной нагрузки на процессор, которую вносил новый механизм опроса. Опрос в 10 раз чаще в четырех параллельных процессах перегружал наши Mac Mini M1, что приводило к периодическим сбоям тестового процесса и сводило на нет большинство, если не все улучшения в скорости.
Оптимизация для параллельных сред с экспоненциальным откатом
Чтобы решить эту проблему, мы усовершенствовали нашу реализацию с помощью экспоненциального отката. Благодаря этой настройке наш механизм опроса адаптируется как к отзывчивости условий, так и к загрузке процессора на наших узлах CI. Окончательная реализация выглядела следующим образом:
xxxxxxxxxx
extension XCUIElement {
enum Timeouts: TimeInterval {
case veryShort = 2.0
case short = 4.0
case medium = 8.0
case long = 12.0
case veryLong = 15.0
}
struct PredicatePollerDefaults {
static let minPollInterval: TimeInterval = 0.2
static let pollMultiplier: Double = 1.5
static let maxPollInterval: TimeInterval = 2.0
static let maxIterations: Int = 100
}
@discardableResult
class func wait(for condition: @escaping () -> Bool,
timeout: Timeouts = .medium,
failureMessage: String = "Condition not met",
hardAssertion: Bool = true,
description: String) -> Bool {
if condition() { return true }
let start = Date()
let timeoutDate = start.addingTimeInterval(timeout.rawValue)
let expectation = XCTestExpectation(description: description)
var pollInterval = PredicatePollerDefaults.minPollInterval
var iterationCount = 0
while Date() < timeoutDate {
iterationCount += 1
if iterationCount > PredicatePollerDefaults.maxIterations {
if hardAssertion {
XCTFail("Exceeded maximum allowed iterations (\(PredicatePollerDefaults.maxIterations))")
}
return false
}
if condition() {
expectation.fulfill()
return true
}
let remainingTime = timeoutDate.timeIntervalSinceNow
let effectivePollInterval = min(pollInterval, remainingTime)
if effectivePollInterval <= 0 { break }
RunLoop.current.run(until: Date().addingTimeInterval(effectivePollInterval))
// Exponential backoff so CI doesn't get overwhelmed
pollInterval = min(max(PredicatePollerDefaults.minPollInterval, pollInterval * PredicatePollerDefaults.pollMultiplier), PredicatePollerDefaults.maxPollInterval)
}
if hardAssertion { XCTFail(failureMessage) }
return false
}
}
Благодаря этим изменениям наши конвейеры CI значительно сократили время выполнения, оставаясь при этом стабильными:
Заключительные мысли
Уделив время наблюдению и исследованию узких мест в нашем тестовом E2E наборе, мы добились следующих результатов:
- Более быстрое время выполнения: до 80% прироста скорости локально и 50% на наших CI-конвейерах
- Перспективность: теперь наши тесты будут автоматически использовать преимущества аппаратных улучшений, не требуя изменений в коде
Наш опыт подчеркивает важный урок: часто самые узкие места могут быть решены с помощью самых простых изменений — если вы просто найдете время, чтобы обратить внимание на проблему. Иногда свежий взгляд и небольшие изменения в реализации могут принести огромную пользу.
Приложение: интеграция в ваш тестовый iOS пакет E2E
Если вы хотите реализовать этот механизм опроса в своем собственном тестовом наборе, мы рекомендуем создать несколько вспомогательных функций, расширяющих XCUIElement. Вот несколько примеров:
xxxxxxxxxx
extension XCUIElement {
@discardableResult
func waitForExistence(timeout: Timeouts = .medium, hardAssertion: Bool = true, description: String? = nil) -> Bool {
return XCUIElement.wait(for: { self.exists }, timeout: timeout, hardAssertion: hardAssertion, description: description ?? "Waiting for existence")
}
@discardableResult
func waitForNonExistence(timeout: Timeouts = .medium, hardAssertion: Bool = true, description: String? = nil) -> Bool {
return XCUIElement.wait(for: { !self.exists }, timeout: timeout, hardAssertion: hardAssertion, description: description ?? "Waiting for non-existence")
}
@discardableResult
func waitForHittable(timeout: Timeouts = .medium, hardAssertion: Bool = true, description: String? = nil) -> Bool {
return XCUIElement.wait(for: { self.exists && self.isHittable }, timeout: timeout, hardAssertion: hardAssertion, description: description ?? "Waiting for hittability")
}
@discardableResult
func waitForVisible(timeout: Timeouts = .medium, hardAssertion: Bool = true, description: String? = nil) -> Bool {
return XCUIElement.wait(for: { self.exists && self.isVisible }, timeout: timeout, hardAssertion: hardAssertion, description: description ?? "Waiting for visibility")
}
@discardableResult
func waitForNotVisible(timeout: Timeouts = .medium, hardAssertion: Bool = true, description: String? = nil) -> Bool {
return XCUIElement.wait(for: { !self.isVisible }, timeout: timeout, hardAssertion: hardAssertion, description: description ?? "Waiting for invisibility")
}
@discardableResult
func waitForEnabled(timeout: Timeouts = .medium, hardAssertion: Bool = true, description: String? = nil) -> Bool {
return XCUIElement.wait(for: { self.isEnabled }, timeout: timeout, hardAssertion: hardAssertion, description: description ?? "Waiting for enabled state")
}
func tapWhenHittable(timeout: Timeouts = .medium,
file: StaticString = #file,
line: UInt = #line,
hardAssertion: Bool = true,
description: String? = nil) {
let failMessage = "Element was not hittable within \(timeout.rawValue)s: \(self)"
if self.waitForHittable(timeout: timeout, hardAssertion: hardAssertion, description: description) {
self.tap()
} else if hardAssertion {
XCTFail(failMessage)
}
}
}
// Example usage:
self.testApp.textFields["Email"].waitForExistence()
self.testApp.textFields["Email"].typeText("...")
self.testApp.textFields["Password"].waitForExistence()
self.testApp.textFields["Password"].waitForEnabled()
self.testApp.textFields["Password"].typeText("...")
self.testApp.buttons["Login"].tapWhenHittable()
-
Видео и подкасты для разработчиков3 недели назад
Как устроена мобильная архитектура. Интервью с тех. лидером юнита «Mobile Architecture» из AvitoTech
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.10
-
Новости2 недели назад
Видео и подкасты о мобильной разработке 2025.11
-
Видео и подкасты для разработчиков1 неделя назад
Javascript для бэкенда – отличная идея: Node.js, NPM, Typescript