Изоляция параллельных тестов Swift Testing на облачном Mac

Изоляция параллельных тестов Swift Testing на облачном Mac

Если один и тот же набор тестов Swift безошибочно проходит отдельно, но время от времени падает в параллельных задачах на облачном Mac, причина обычно не в нестабильности машины. Чаще всего тесты совместно используют каталоги, настройки, базы данных, порты или состояние генератора случайных чисел. Дополнительные попытки лишь скрывают загрязнение состояния. Надёжнее задать для каждого теста отдельные границы выполнения, которые можно идентифицировать, очистить и воспроизвести.

Сначала убедитесь, что сбой вызван общим состоянием

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

set -o pipefail

xcodebuild test \
  -scheme AppTests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -parallel-testing-enabled NO \
  -resultBundlePath Artifacts/serial.xcresult

xcodebuild test \
  -scheme AppTests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -parallel-testing-enabled YES \
  -maximum-parallel-testing-workers 4 \
  -resultBundlePath Artifacts/parallel.xcresult

Если запуск в одном процессе стабилен, а параллельный завершается сбоем, сначала проверьте следующие ресурсы, а не сами утверждения:

Общий ресурс Типичные симптомы Способ изоляции
Временный каталог Файлы перезаписываются или уже отсутствуют во время очистки Создавать уникальный подкаталог для каждого теста
UserDefaults Читаются значения, записанные другим тестом Использовать отдельный suiteName
Файл SQLite Конфликты блокировок, непостоянное количество записей Использовать отдельную базу данных для каждого теста
Фиксированный порт Address already in use Привязываться к порту 0
Глобальный генератор случайных чисел Результат меняется при изменении порядка Явно сохранять случайное начальное значение

Успешный последовательный запуск доказывает лишь то, что к общему состоянию не обращались одновременно. Он не доказывает правильность изоляции тестов.

Выделите каждому тесту отдельную песочницу

Не разрешайте всем тестам записывать данные в /tmp/app-tests. При запуске теста создавайте уникальный идентификатор, сохраняйте все файлы, базы данных и экспортированные результаты в соответствующем каталоге, а после завершения удаляйте его.

import Foundation
import Testing

struct TestSandbox {
    let id: String
    let root: URL

    init(name: String) throws {
        id = "\(name)-\(UUID().uuidString)"
        root = FileManager.default.temporaryDirectory
            .appending(path: "ZoneMiniTests")
            .appending(path: id)
        try FileManager.default.createDirectory(
            at: root,
            withIntermediateDirectories: true
        )
    }

    func preferences() throws -> UserDefaults {
        guard let defaults = UserDefaults(suiteName: "tests.\(id)") else {
            throw CocoaError(.fileWriteUnknown)
        }
        return defaults
    }

    func remove() throws {
        try FileManager.default.removeItem(at: root)
        UserDefaults.standard.removePersistentDomain(
            forName: "tests.\(id)"
        )
    }
}

Передавайте root тестируемому коду через внедрение зависимостей, чтобы рабочий код не обращался к глобальному временному пути самостоятельно. Очистку следует выполнять в defer, чтобы она происходила даже после сбоя утверждения. Если данные неудачного запуска нужно сохранить для анализа, удаление можно пропустить в зависимости от переменной окружения, а путь к песочнице добавить во вложения теста.

Не используйте имя теста как уникальный ключ

Параметризованный тест может параллельно выполнять несколько наборов входных данных под одним именем функции. Одного имени функции недостаточно: ключ должен включать имя теста, сводку параметров и UUID. Если параметры содержат токены или пользовательские данные, не добавляйте их в имя каталога напрямую — сначала сформируйте необратимый хеш.

Изолируйте настройки, базы данных и порты прослушивания

Стандартный домен UserDefaults относится к общему состоянию процесса. В тесты следует внедрять отдельный экземпляр, а после завершения удалять соответствующий постоянный домен. Для баз данных действует тот же принцип: каждый тест должен создавать собственный файл, а тесты миграции не должны использовать ту же копию, что и обычные тесты чтения и записи.

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

Тесты, которые действительно зависят от глобального синглтона и пока не могут быть переработаны, можно сериализовать локально:

import Testing

@Suite(.serialized)
struct LegacyDatabaseTests {
    @Test
    func migratesExistingStore() async throws {
        // Реализация теста
    }
}

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

Фиксируйте случайное начальное значение вместо многократных попыток

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

let environment = ProcessInfo.processInfo.environment
let seed = UInt64(environment["TEST_SEED"] ?? "") ?? 20260806

Дочернее начальное значение можно стабильно вычислять из базового значения и уникального ключа теста. В отчёте о сбое как минимум должны сохраняться базовое значение, количество рабочих процессов, тестовая цель и команда запуска. Это позволяет воспроизвести сочетание конкретных входных данных с конкретным уровнем параллельности вместо десяти запусков наугад.

На узлах ZoneMini с непрерывным выполнением рекомендуется сохранять пакеты результатов в каталогах, соответствующих идентификаторам задач, чтобы следующий запуск не перезаписал предыдущий:

RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
mkdir -p "Artifacts/$RUN_ID"

TEST_SEED=20260806 xcodebuild test \
  -scheme AppTests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -parallel-testing-enabled YES \
  -maximum-parallel-testing-workers 4 \
  -resultBundlePath "Artifacts/$RUN_ID/tests.xcresult"

Проверьте исправление повторными запусками под нагрузкой

После исправления не ограничивайтесь одним запуском. Сначала выполните серию запусков с одинаковым начальным значением и уровнем параллельности, затем меняйте начальное значение, чтобы проверить другие входные данные. Для пакета результатов каждого запуска нужен отдельный путь, иначе xcodebuild завершит работу из-за того, что целевой файл уже существует.

Для приёмочной проверки можно использовать четыре чётких критерия:

  1. Запуски с одним и четырьмя рабочими процессами дают одинаковые результаты утверждений.
  2. Каждый тест записывает данные только в собственный каталог, домен настроек и базу данных.
  3. Сервисы прослушивания не используют жёстко заданные порты.
  4. По журналу сбоя можно определить начальное значение, песочницу и пакет результатов.

В завершение проверьте стратегию очистки. После успешных задач временные песочницы можно удалять. Для неудачных задач следует сохранять необходимые журналы и пакеты результатов, но не хранить длительное время учётные данные, токены репозитория или содержимое запросов без обезличивания. Стабильность параллельных тестов обеспечивается не дополнительными попытками, а отдельным состоянием каждого теста и достаточным набором данных для воспроизведения каждого сбоя.

Часто задаваемые вопросы

Нужно ли сразу отключать параллельный запуск Swift Testing?

Нет. Сначала изолируйте каталоги, UserDefaults, базы данных и порты для каждого теста. Признак .serialized используйте локально только для устаревших сценариев, которые пока нельзя безопасно распараллелить.

Как воспроизвести сбой, который появляется только в CI?

Сохраняйте seed, число параллельных работников, назначение симулятора и путь к xcresult. Повторяйте запуск с тем же seed и той же степенью параллелизма.

Почему тестовому серверу не стоит назначать постоянный порт?

Несколько процессов могут одновременно занять один порт или подключиться к серверу другого теста. Привязывайтесь к порту 0 и передавайте клиенту номер, назначенный системой.

ВЫДЕЛЕННЫЙ СЛОТ ДЛЯ СБОРКИ

Выберите выделенный облачный Mac для непрерывных задач сборки

Проверьте M4, объём памяти, хранилище, узел и расчётный период, а затем подключите стабильное окружение сборки к существующему конвейеру.

Выбрать конфигурацию и заказать