В ночном задании CI иногда возникает сбой XCTest, который не удаётся воспроизвести локально: входные данные создаются случайной фабрикой, тесты запускаются в случайном порядке, а после ошибки конвейер автоматически повторяет попытку. Второй запуск завершается успешно, но уже невозможно определить, какие данные использовались при первом сбое и какие тесты выполнялись перед ним. Решение заключается не в увеличении числа повторных попыток, а в превращении каждого запуска в полностью воспроизводимый эксперимент.
Сначала определите контракт воспроизведения
На случайные тесты влияют как минимум три группы переменных: случайные значения бизнес-данных, порядок выполнения тестов, а также состояние, оставшееся в симуляторе и процессе. Одной записи SEED=42 недостаточно: после изменения коммита, набора тестов или региональных настроек то же начальное значение может привести к выполнению другого пути.
В начале каждого задания создавайте манифест запуска, а на последующих этапах только считывайте его. Рекомендуется сохранять как минимум следующие поля:
| Поле | Пример | Назначение |
|---|---|---|
| commit | Хеш коммита Git | Фиксирует тестируемый код |
| seed | Беззнаковое целое число | Воспроизводит случайные данные |
| tests | Упорядоченный список идентификаторов тестов | Фиксирует порядок выполнения |
| destination | UDID симулятора | Фиксирует целевое устройство |
| runtime | Версии системы и Xcode | Выявляет различия в инструментарии |
| locale | zh_CN или en_US |
Исключает различия форматирования |
| result bundle | Путь к .xcresult |
Сохраняет журналы и вложения |
«Тот же тест снова прошёл» — это не воспроизведение. Определить, сохранился ли дефект, можно только при одинаковых входных данных, порядке и окружении.
Манифест запуска необходимо архивировать вместе с результатами тестирования, а не только выводить в консольный журнал, который может быть обрезан.
Используйте стабильное начальное значение для случайных данных
Системный источник случайных чисел Swift подходит для рабочего кода, но не позволяет воспроизводить последовательность по заданному начальному значению. Генератор случайных чисел можно передавать в фабрику данных как зависимость: в тестах использовать реализацию с начальным значением, а в рабочем коде — системную реализацию.
struct SplitMix64: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) {
state = seed
}
mutating func next() -> UInt64 {
state &+= 0x9E3779B97F4A7C15
var value = state
value = (value ^ (value >> 30)) &* 0xBF58476D1CE4E5B9
value = (value ^ (value >> 27)) &* 0x94D049BB133111EB
return value ^ (value >> 31)
}
}
func makeQuantity<R: RandomNumberGenerator>(using rng: inout R) -> Int {
Int.random(in: 1...20, using: &rng)
}
Точка входа тестов должна считывать начальное значение из переменной окружения и сразу завершаться ошибкой, если она отсутствует, а не незаметно переключаться на системный источник случайных чисел:
let rawSeed = ProcessInfo.processInfo.environment["TEST_SEED"]
let seed = rawSeed.flatMap(UInt64.init)
XCTAssertNotNil(seed, "TEST_SEED must be provided")
Все фабрики случайных данных в рамках одного задания должны использовать явно заданное правило получения производных значений. Например, дочернее начальное значение можно вычислять из основного начального значения и стабильного идентификатора теста. Тогда дополнительное обращение к генератору в одном тесте не изменит последовательность данных для всего набора.
Зафиксируйте набор тестов и порядок выполнения
Порядок тестов не должен зависеть от результата обхода файловой системы. Сначала создайте текстовый список, где в каждой строке указан полный идентификатор, например AppTests/LoginTests/testExpiredSession. Затем стабильно отсортируйте тесты по хешу начального значения и идентификатора:
import hashlib
import os
seed = os.environ["TEST_SEED"]
with open("ci/tests.txt", encoding="utf-8") as source:
tests = [line.strip() for line in source if line.strip()]
def key(name):
value = f"{seed}:{name}".encode()
return hashlib.sha256(value).hexdigest()
for test in sorted(tests, key=key):
print(test)
Так можно проверять разные варианты порядка и при этом восстанавливать один и тот же список на любом узле. Перед запуском сохраните результат сортировки в ordered-tests.txt, а затем последовательно сформируйте параметры xcodebuild:
set -euo pipefail
mkdir -p artifacts
python3 ci/order_tests.py > artifacts/ordered-tests.txt
args=()
while IFS= read -r test_id; do
args+=("-only-testing:${test_id}")
done < artifacts/ordered-tests.txt
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-destination "id=${SIMULATOR_UDID}" \
-resultBundlePath "artifacts/test-${TEST_SEED}.xcresult" \
"${args[@]}"
Не включайте одновременно другой механизм случайной сортировки, результат которого невозможно записать. Иначе порядок в списке снова разойдётся с фактическим порядком выполнения.
Изолируйте состояние симулятора, не уничтожая доказательства
Для воспроизведения используйте выделенный симулятор, чтобы ручная отладка и CI не работали с одним устройством. Перед запуском тестов выбранное устройство можно выключить и стереть, но сначала необходимо убедиться, что его UDID относится к текущему заданию. Нельзя без разбора очищать общие устройства.
xcrun simctl shutdown "$SIMULATOR_UDID" 2>/dev/null || true
xcrun simctl erase "$SIMULATOR_UDID"
xcrun simctl boot "$SIMULATOR_UDID"
xcrun simctl bootstatus "$SIMULATOR_UDID" -b
Регион, язык, часовой пояс и параметры запуска также следует явно задавать в точке входа тестов. Если на проверки влияют сетевые ответы, текущее время или UUID, передавайте фиксированные реализации через протоколы, а не рассчитывайте на то, что симулятор случайно окажется в том же состоянии.
Если предполагается, что проблему вызывает состояние после предыдущих тестов, не начинайте с запуска каждого теста в новом симуляторе. Сначала воспроизведите исходный порядок и подтвердите сбой. Затем методом двоичного поиска сокращайте список предшествующих тестов, пока не найдёте минимальную цепочку, загрязняющую состояние. Это позволяет отличить сбой, вызванный случайными данными, от утечки состояния из предыдущих тестов.
Сохраняйте полное состояние при первом сбое
Для задания, завершившегося ошибкой, необходимо сохранить как минимум xcresult, упорядоченный список тестов, манифест запуска, стандартный вывод и коммит кода. Также следует записать результаты xcodebuild -version, sw_vers и версию среды выполнения симулятора. Для конфиденциальных переменных окружения сохраняйте только имя переменной и признак её наличия, но не фактическое значение.
Автоматический повторный запуск можно использовать для классификации, однако он должен выполняться только после архивации данных первого сбоя. Результаты рекомендуется разделить на три категории:
Стабильный сбой при том же контракте
Если сбой стабильно повторяется при том же коммите, начальном значении, порядке и состоянии симулятора, в первую очередь проверяйте детерминированную бизнес-логику и утверждения.
Сбой после изменения порядка
Если данные остаются неизменными, но результат зависит от набора предыдущих тестов, проверьте синглтоны, обработчики уведомлений, базу данных, кеши и невосстановленные глобальные настройки.
Результат меняется даже при том же контракте
Убедитесь, что асинхронные задачи действительно завершаются, системное время не считывается напрямую, сетевые ответы зафиксированы, а тесты не обращаются к источнику случайных чисел, который не был передан как зависимость.
Конечная цель — не заставить красный индикатор исчезнуть после повторной попытки, а снабдить каждый сбой исполняемой командой воспроизведения. Тогда нестабильная ошибка перестаёт быть «вероятностным событием» и превращается в набор входных данных, который можно сокращать, сравнивать и исправлять.
Часто задаваемые вопросы
Достаточно ли сохранить начальное значение генератора?
Обычно нет. Необходимо также зафиксировать коммит, список и порядок тестов, версию среды симулятора, локаль и внешние входные данные.
Нужно ли сразу перезапускать тест после сбоя?
Сначала сохраните исходный xcresult, начальное значение и точный порядок. Затем повторите запуск с тем же сценарием, не считая успешный перезапуск доказательством исправления.
Зачем внедрять генератор случайных чисел в код Swift?
В тесте его можно заменить генератором с заданным начальным значением и получить ту же последовательность, сохранив системный генератор для рабочей версии.
Подготовьте выделенный физический Mac для следующей сборки
Выбирайте модель, дата-центр и срок аренды под задачу. Узлы стабильно работают 365 дней в году; актуальный статус доступности отображается в консоли в реальном времени.