Unit-тест проверяет один тип в изоляции, с подменёнными зависимостями, и выполняется за миллисекунды; интеграционный тест запускает несколько настоящих частей вместе; UI-тест прогоняет собранное приложение через экраны так, как это делает пользователь.
Unit: view model, парсер, правило расчёта цены. Работает в процессе тестов через @testable import, сотни тестов в секунду, поэтому это основа сьюта.
Интеграционный: настоящие компоненты, соединённые вместе: репозиторий поверх in-memory хранилища SwiftData, API-клиент против стабов на URLProtocol. Ловит несостыковку частей, каждая из которых по отдельности проходит.
UI (XCUITest): запускает приложение в отдельном процессе и проходит его нажатиями. Медленный и более хрупкий, поэтому их держат немного, на критичные сценарии: вход, оформление заказа, покупка.
Обычно используют пирамиду тестов: много unit-тестов, меньше интеграционных, немного UI, потому что стоимость и нестабильность растут к вершине. Что это даёт: рефакторинг без страха, регрессии, пойманные до QA и App Review, и давление на дизайн, ведь код, который трудно тестировать, обычно слишком связан.
iOSMiddle
Тестирование в iOS
XCTest, Swift Testing, XCUITest, тестовые дублёры, async-тесты, снапшоты, покрытие, флаки
Закрыто вопросов
0 / 25
- 0Повторить
- 2Часто спрашивают
Вопросы
Хороший unit-тест проверяет одно поведение, быстрый и детерминированный, и читается тремя блоками: Arrange (готовим входные данные и подмены), Act (вызываем один тестируемый метод), Assert (проверяем наблюдаемый результат).
Одна причина упасть: имя называет поведение: @Test("a coupon below its minimum spend is ignored") или test_applyCoupon_belowMinimum_keepsTotal.
FIRST: Fast (быстрый), Independent (не зависит от других тестов и их порядка), Repeatable (без настоящей сети, часов и случайности), Self-validating (проверяет ассертом, а не печатает), Timely (пишется вместе с кодом).
Проверяйте результат, а не внутренности: возвращённое значение или изменение состояния, а не то, что вызвался какой-то приватный хелпер, иначе каждый рефакторинг ломает тест.
Никакой логики в тесте: ни if, ни циклов, вычисляющих ожидаемое значение; вместо этого параметризованный тест.
@testable import MyApp позволяет тестовому таргету пользоваться internal-объявлениями модуля так, будто они public, поэтому тестировать можно, не расширяя доступ в продакшен-коде. Работает это только если модуль собран с включённой поддержкой тестирования.
Настройка сборки: ENABLE_TESTABILITY = YES (флаг компилятора -enable-testing) по умолчанию включена в Debug и выключена в Release. Попытка тестировать Release-сборку падает с ошибкой «module 'MyApp' was not compiled for testing».
Что открывается: становятся видны internal-члены, а public-классы и методы можно наследовать и переопределять из тестов. private и fileprivate остаются скрытыми: тестируйте через internal API.
Это возможность компилятора, поэтому Swift Testing использует тот же @testable import; swift test в пакете собирает с включённым тестированием автоматически.
XCTest создаёт отдельный экземпляр вашего подкласса XCTestCase для каждого тестового метода и вызывает setUp до и tearDown после каждого из них, поэтому свойства экземпляра всегда начинают с чистого листа.
Порядок вызовов
class func setUp() выполняется один раз, перед первым тестом класса; class func tearDown() выполняется один раз, после последнего.
setUp(), setUpWithError() или setUp() async throws выполняется перед каждым тестом. Если бросить там XCTSkip("no network"), тест аккуратно пропускается.
После каждого теста сначала выполняются блоки, зарегистрированные через addTeardownBlock (последний зарегистрированный первым), затем tearDown().
Что новый экземпляр не сбрасывает
static- и глобальные переменные, синглтоны вроде UserDefaults.standard, Keychain и файлы на диске. Тест, который проходит в одиночку и падает после соседа, читает что-то из этого.
Подписки: наблюдатель NotificationCenter, добавленный в одном тесте, продолжит срабатывать в следующем, если teardown его не снимет.
Память: XCTest создаёт все экземпляры заранее и держит их до конца прогона, поэтому тяжёлые свойства (UIWindow, ModelContainer, большие фикстуры) обнуляйте в tearDown.
load вызывает completion позже, асинхронно, поэтому тестовый метод возвращается раньше, чем выполнится замыкание; в этот момент XCTest считает тест завершённым и успешным, и ассерты внутри замыкания для этого теста так и не проверяются.
Замыкание срабатывает потом на какой-то очереди или вообще никогда, если работу ничто не держит. Если оно сработает во время следующего теста, падение может достаться не тому тесту.
Исправление в XCTest: создать expectation, выполнить fulfill() внутри замыкания и затем wait(for:timeout:) в синхронном тесте или await fulfillment(of:timeout:) в async-тесте.
Исправление лучше: дать load async-версию и просто сделать await, без expectation вовсе.
Полезная привычка: увидеть, как каждый новый тест хотя бы раз падает, например сломав фейк. Тест, который вы ни разу не видели красным, ничего не доказывает.
#expect фиксирует падение и даёт тесту продолжиться; try #require бросает ошибку и останавливает тест: это то, что нужно для предусловия вроде разворачивания опционала. Оба реализованы как макросы, поэтому при падении показывают значение каждого подвыражения.
#expect(cart.total == 42) падает с сообщением вроде (cart.total → 25) == 42. XCTAssertTrue(cart.total == 42) получает только Bool, поэтому может сказать лишь «XCTAssertTrue failed».
let user = try #require(store.currentUser) заменяет XCTUnwrap. Используйте #require везде, где следующие строки без этого бессмысленны или упадут.
Ошибки: #expect(throws: LoadError.notFound) { try sut.load() } проверяет конкретную ошибку, #expect(throws: Never.self) { ... } проверяет, что ничего не брошено, а начиная со Swift 6.1 #expect(throws:) возвращает пойманную ошибку для дальнейших проверок.
Строка в конце служит комментарием, который показывается при падении: #expect(balance > 0, "overdraft is not allowed here").
UI-тест работает в собственном процессе-раннере и управляет установленным приложением снаружи через слой accessibility, как пользователь; он не может вызвать ваш код или прочитать его состояние, а может только найти элементы, взаимодействовать с ними и проверить, что на экране.
XCUIApplication().launch() запускает приложение. Запросы вроде app.buttons["checkout.pay"] возвращают прокси XCUIElement, которые разрешаются по дереву accessibility в момент использования.
@testable import здесь нет: тест видит только то, что выставляет экран. Состояние передаётся через launchArguments и launchEnvironment.
exists проверяет один раз; waitForExistence(timeout:) опрашивает, пока элемент не появится. XCUITest ждёт, пока приложение станет idle, перед каждым действием, но ничего не знает о ваших сетевых запросах.
Каждый тест стоит секунд (запуск, анимации), поэтому покрывайте критичные сценарии, а правила оставьте unit-тестам. Кнопка записи в Xcode генерирует черновик запросов.
Swift Testing заменяет подклассы XCTestCase и семейство XCTAssert на функции @Test, макросы #expect и #require и трейты, а тесты по умолчанию запускает параллельно внутри одного процесса. У UI-тестов и performance-тестов на measure аналога нет, они остаются в XCTest.
Структура: любой тип, обычно struct, с функциями @Test; @Suite добавляет имя или трейты. setUp превращается в init(), который может быть async throws; вместо teardown используют deinit у сьюта-класса или актора либо defer в тесте. Префикс test не нужен.
Параметризованные тесты: @Test(arguments:) превращает цикл по входным данным в отдельные кейсы, каждый из которых виден и перезапускается сам по себе.
Трейты: .disabled("…"), .enabled(if:), .bug(id: "FB12345"), .timeLimit(.minutes(1)), .tags(.checkout), .serialized; их ставят на один тест или на весь сьют.
Параллельность: XCTest раскидывает классы по процессам; Swift Testing выполняет тесты конкурентно в одном процессе, поэтому общее изменяемое состояние, которое раньше «работало», теперь гоняется. .serialized отключает это для сьюта.
Асинхронность: тесты пишутся как обычные async-функции, вместо expectation используется confirmation; wait нет.
Оба фреймворка живут в одном таргете, поэтому unit-тесты можно переносить файл за файлом, но каждый тест держите в одном фреймворке: не вызывайте XCTAssert внутри @Test и #expect внутри XCTestCase.
Первый запускает 9 кейсов, потому что две коллекции аргументов дают все комбинации (3 × 3); второй запускает 3, потому что zip составляет пары. Каждый кейс отображается отдельно, а более короткий список в zip молча отбрасывает лишние кейсы.
Комбинации: @Test принимает не больше двух коллекций для декартова произведения. Если измерений больше, передайте одну коллекцию кортежей или небольших структур.
**Ловушка zip**: добавьте в Plan четвёртый тариф и забудьте его цену, и zip просто остановится на коротком списке: новый тариф не тестируется, и ничего не краснеет. Массив кортежей [(Plan.free, 0), (.pro, 9), (.team, 29)] держит каждый вход рядом с ожиданием.
Независимые кейсы: в отличие от цикла for в одном тесте, упавший кейс не скрывает остальные, а сами кейсы выполняются параллельно, поэтому не должны делить изменяемое состояние.
Перезапуск одного кейса: Xcode может перезапустить отдельный аргумент, если умеет его закодировать, например когда у Plan есть String raw value или он Codable.
Все четыре относятся к тестовым дублёрам (test doubles) настоящей зависимости: stub возвращает заготовленные ответы, spy ещё и записывает вызовы, mock проверяет заранее заданные ожидания, а fake представляет собой работающую облегчённую реализацию. В Swift их пишут руками или генерируют.
Все четыре на практике
Stub: StubProfileLoader(result: .success(.stub)). Нужен, когда тест о том, что код делает с ответом.
Spy: stub, который записывает: private(set) var trackedEvents: [Event]. Нужен, когда сам вызов и есть поведение: аналитика, «никогда не списывать деньги дважды».
Mock: заранее знает, что ожидать, и падает на verify(). В Swift обычно пишут spy; моки в основном получаются из генераторов.
Fake: настоящее поведение, но дёшево: in-memory репозиторий, стаб-сервер на URLProtocol, in-memory ModelContainer. Dummy заполняет параметр и никогда не используется.
Почему нет Mockito и что делать вместо него
Mockito строит прокси во время выполнения; Swift вызывает методы протоколов через статические witness-таблицы и не умеет наследоваться от структур и final-классов, так что перехватывать нечего.
Руками: тип, реализующий протокол. Правильный выбор по умолчанию для немногих зависимостей одного экрана.
Генерация: AutoMockable из Sourcery, Mockolo или библиотека на макросах вроде Mockable, когда протоколы большие или дублёры переиспользуются широко.
Структура из замыканий: зависимость описывается как struct с замыканиями и экземпляром live; тест подменяет только нужное замыкание.
Не оборачивайте каждый тип в протокол: выделяйте его на настоящих границах (сеть, часы, хранилище, аналитика) и предпочитайте проверку состояния проверке вызовов.
AnalyticsSpy является структурой, поэтому при передаче в CheckoutViewModel она копируется: confirm() записывает "purchase" в копию внутри view model, а у spy в тесте массив events остаётся пустым. Тест падает, хотя продакшен-код верный.
Семантика значений: init(analytics:) сохраняет собственную копию в var, и mutating func track меняет именно её. У теста и SUT нет общего хранилища, поэтому ассерт не видит ничего из того, что записал SUT.
Исправление: сделать спай final class: обе стороны держат ссылку на один объект, и events общий. Если объявить протокол как AnyObject и убрать mutating, дубль-структура перестанет компилироваться, а view model сможет хранить зависимость в let.
Зеркальная ловушка: та же копия ломает стаб-структуру: stub.result = .failure(.offline) после внедрения ничего не меняет внутри SUT, поэтому тест незаметно проверяет путь по умолчанию и может пройти не по той причине.
Как поймать заранее: дубли, которые записывают вызовы или настраиваются после внедрения, по соглашению делают ссылочными типами; mutating-требование в протоколе сервиса само по себе повод придраться на ревью, ведь настоящие клиенты аналитики или сети являются классами или акторами. Прежде чем доверять новому тесту, убедитесь, что он падает по правильной причине.
wait(for:timeout:) блокирует поток, на котором вызван, а в async-тесте этот поток принадлежит кооперативному пулу, который нужен ожидаемой работе, поэтому тест может попасть в deadlock; в async-тесте используйте await fulfillment(of:timeout:): он приостанавливается, а не блокирует.
Именно поэтому Xcode помечает wait(for:timeout:) как недоступный из асинхронного контекста: предупреждение в режиме Swift 5, ошибка в режиме Swift 6.
Если API уже async, expectation не нужен: let items = try await sut.load() и проверка.
Expectation нужен для работы, которая сообщает о результате позже: через колбэк, делегат, нотификацию или Combine-паблишер. Один раз обернуть одноразовый колбэк через withCheckedContinuation часто избавляет от них совсем.
expectedFulfillmentCount = 3 ждёт трёх вызовов, а assertForOverFulfill = true падает на четвёртом; enforceOrder: true проверяет порядок нескольких expectation.
В Swift Testing аналогом служит await confirmation(expectedCount:) { confirm in ... }: счётчик проверяется, когда тело вернулось, таймаут подбирать не нужно.
Таймаут служит механизмом падения, а не синхронизации: таймаут в секунду, который никогда не срабатывает, нормален, а Task.sleep в теле теста означает будущий flaky-тест.
load() почти наверняка запускает Task и сразу возвращается, поэтому проверка выполняется раньше, чем тело задачи успело выполниться хотя бы раз. Сделайте работу ожидаемой и дождитесь её через await, а не через сон или yield.
Пометка теста @MainActor (@MainActor final class VMTests: XCTestCase или @Test @MainActor func) позволяет создавать и читать view model синхронно, чего требует Swift 6. Ничего ждать она не умеет.
Лучшее исправление: вынести работу в async-метод func run() async и запускать его из view через .task { await model.run() }. Тогда тест просто делает await sut.run().
Если fire-and-forget Task должен остаться, сохраните его хэндл, чтобы тест мог сделать await sut.loadTask?.value.
Иначе наблюдайте эффект: confirmation в Swift Testing или expectation, выполняемый при смене состояния.
await Task.yield() работает как лотерея: он один раз уступает актор, и этого хватает задаче без точек приостановки, но не задаче, которая ждёт фейковый сетевой вызов.
Правило: никогда не проверяйте состояние через границу, которую вы не дождались через await.
Обычно ждать вообще не нужно: внедрите spy вместо приёмника аналитики, доведите код до точки, которую можно дождаться через await, и проверьте, что spy ничего не записал. Это точно и мгновенно.
Spy только записывает: final class AnalyticsSpy: AnalyticsTracking с private(set) var events: [Event]. Когда await sut.submit() вернулся, #expect(spy.events.isEmpty) служит полным доказательством.
Если отсутствие события действительно асинхронное (debounce, отложенный повтор), используйте инвертированный expectation: exp.isInverted = true делает выполнение провалом, а таймаут успехом. Он всегда стоит полный таймаут, поэтому ставьте его чуть больше собственной задержки кода (0,4 с для debounce в 0,3 с, а не 5 с).
Swift Testing: await confirmation(expectedCount: 0) { confirm in ... } падает, если confirm() вызван, пока выполняется тело.
XCTWaiter().wait(for:timeout:) возвращает .completed или .timedOut вместо падения, что подходит тесту, которому нужно ветвиться по результату.
Ловушка: негативный тест на событие, которое так и не подключили, проходит вечно. Дополните его позитивным тестом, доказывающим, что тот же spy записывает событие, когда должен.
Он проверяет утечку памяти: после возврата из тестового метода на sut не должно указывать ничего, кроме слабого захвата, значит, он обязан быть nil. Но это не так: view model владеет service, а service.onUpdate захватывает self сильно, и получается цикл сильных ссылок (retain cycle).
[weak sut] не удерживает объект, а teardown-блоки выполняются после тела теста, когда локальной sut уже нет. Если что-то его ещё держит, это утечка.
Исправлять нужно продакшен-код: service.onUpdate = { [weak self] posts in self?.items = posts }.
Оберните проверку в хелпер вроде trackForMemoryLeaks(_ instance: AnyObject) и вызывайте его из каждой тестовой фабрики view model, презентеров и координаторов. Он ловит циклы через замыкания, делегаты, объявленные сильными, и замыкания Combine sink, захватывающие self.
В Swift Testing нет addTeardownBlock: держите weak var снаружи области do { }, которая владеет объектом, и после её окончания проверяйте #expect(weakSUT == nil).
Видны только циклы, достижимые от этого объекта во время этого теста; остальное найдут Memory Graph в отладчике и Leaks в Instruments.
Вынесите решения из view в модель и покройте её unit-тестами; пиксели проверяйте snapshot-тестами, а поведение через XCUITest. Apple не даёт API, который отдал бы unit-тесту отрисованное дерево SwiftUI.
Модель: var canBuy: Bool { !items.isEmpty && !isSubmitting } проверяется unit-тестом в две строки, а view превращается в .disabled(!model.canBuy). Во view, которая только связывает состояние с модификаторами, почти нечего проверять.
Snapshot-тесты: разместите view в UIHostingController и сравните отрисовку с записанным изображением через swift-snapshot-testing. Единственный способ внутри процесса увидеть настоящую раскладку, цвета и Dynamic Type; цена складывается из картинок на ревью и расхождений между версиями ОС.
ViewInspector: через рефлексию по приватному хранилищу SwiftUI обходит body, например try sut.inspect().find(button: "Buy").isDisabled(). Популярен и всегда в одном релизе Xcode от поломки.
XCUITest: поддерживаемый способ спросить запущенное приложение: XCTAssertFalse(app.buttons["cart.buy"].isEnabled). Это запуск на каждый тест, поэтому тратьте его на сценарии.
Превью не тесты: если превью выглядит неправильно, ничего не падает.
Snapshot-тест отрисовывает view и сравнивает результат с эталонным изображением или текстовым дампом, закоммиченным в репозиторий. Так ловятся визуальные регрессии, которые не поймает ни один ассерт. На CI он падает потому, что отрисовка зависит от симулятора, ОС и машины, на которых сделан эталон.
Окупаются для компонентов дизайн-системы и экранов с множеством состояний: пустое, ошибка, тёмная тема, самый крупный Dynamic Type, письмо справа налево. Не окупаются для экранов, которые переделывают каждый спринт, потому что эталоны начинают перезаписывать не глядя.
Что сдвигает пиксели: модель симулятора и версия ОС (масштаб, safe area, метрики шрифтов), сам Mac (сглаживание различается между версиями macOS и архитектурами процессора) и входные данные, которые тест забыл зафиксировать: локаль, тема, Date(), картинки из сети.
Зафиксируйте окружение: один симулятор и одна ОС для записи и проверки, в идеале запись прямо на образе CI.
Допускайте сглаживание: .image(precision: 0.99, perceptualPrecision: 0.98) в swift-snapshot-testing прощает невидимый шум и всё ещё ловит сдвинутую кнопку.
Заморозьте входные данные: внедрите фиксированную дату, застабьте загрузку картинок, задайте трейты явно.
Никогда не записывайте эталоны автоматически на CI: запускайте с withSnapshotTesting(record: .never), чтобы CI не записывал и не перезаписывал эталоны, а отсутствующий эталон был обычным падением.
Они матчились по label, то есть переведённому тексту, который видит пользователь. Запросы должны матчиться по accessibilityIdentifier: он не локализуется и не озвучивается VoiceOver.
app.buttons["Continue"] сравнивает строку с identifier, label и похожими атрибутами элемента. Если identifier не задан, срабатывает label, и сьют проходит по неправильной причине (до смены языка).
Задавайте identifier там, где создаётся элемент: .accessibilityIdentifier("checkout.pay") в SwiftUI, button.accessibilityIdentifier = "checkout.pay" в UIKit.
Находите по identifier, проверяйте label: XCTAssertEqual(app.staticTexts["cart.total"].label, "12,00 €") осознанно проверяет текст.
Что всё равно прячет identifier: .accessibilityElement(children: .combine) сливает дочерние элементы в один, identifier, поставленный на внутренний Text вместо Button, и .accessibilityHidden(true).
Если запрос ничего не находит, выведите app.debugDescription в отладчике: это дамп дерева accessibility, которое тест реально видит.
Передайте сценарий при запуске, и пусть приложение соберёт себя из стабов: UI-тест работает в другом процессе, поэтому launchArguments и launchEnvironment служат основным каналом в приложение.
Они приходят как ProcessInfo.processInfo.arguments и .environment. Аргументы, начинающиеся с -, ещё и попадают в домен аргументов UserDefaults: так работает -AppleLanguages "(de)".
Ветвитесь один раз, в composition root: при -uiTesting собирайте граф объектов с in-memory хранилищем, уже залогиненной сессией и застабленным сетевым клиентом. Никогда не разбрасывайте if isUITesting по коду фич.
Стабьте бэкенд внутри процесса приложения, например подклассом URLProtocol в protocolClasses сессии. Стаб, установленный из тестового бандла, трафика приложения не видит.
Стирайте то, что переживает перезапуск (UserDefaults, файлы в Application Support, записи Keychain), и сбрасывайте разрешения через app.resetAuthorizationStatus(for: .location).
Оберните всю тестовую ветку в #if DEBUG: релизная сборка, которую можно залогинить аргументом запуска, оказывается дырой в безопасности.
Оно измеряет, какие участки кода хотя бы раз выполнились во время тестов, и ничего не говорит о том, проверялось ли что-то ассертами и какие комбинации входных данных прогонялись. 87% означают, что 13% точно не протестированы, а не то, что остальное протестировано.
Xcode использует source-based coverage из LLVM: компилятор добавляет счётчики на регионы кода, а Xcode показывает долю строк, которые выполнились хотя бы раз.
Ассерты не нужны: тест, который вызывает метод и ничего не проверяет, поднимает покрытие так же, как хороший. Один запуск UI-теста затягивает за черту большую часть приложения.
Комбинации не видны: то, что обе ветки if выполнились, не значит, что пара условий, вызывающая баг, хоть раз встретилась.
Значения не видны: пустой массив, nil, ошибка на единицу живут внутри регионов, которые уже считаются покрытыми.
Отсутствующий код не виден: у необработанного случая ошибки нет региона, поэтому он не может понизить цифру.
Как сделать её полезной: включайте покрытие в тест-плане только для нужных таргетов, читайте красные полосы в редакторе, а не процент, ставьте порог на непокрытые строки в диффе pull request, а не глобальные 80%, и извлекайте цифры на CI через xcrun xccov view --report --json. Для критичных веток спросите себя: если инвертировать это условие, упадёт ли хоть один тест?
Разработка через тестирование (TDD) означает сначала падающий тест, затем минимальный код, чтобы он прошёл, затем рефакторинг при зелёных тестах: red, green, refactor. Больше всего окупается на логике с понятными входами и выходами и меньше всего на вёрстке и экспериментальном UI.
Три правила: не писать продакшен-код, кроме как чтобы прошёл падающий тест; не писать теста больше, чем нужно, чтобы он упал (некомпилирующийся тест тоже считается); не писать кода больше, чем нужно, чтобы тест прошёл.
Где хорош: парсеры, правила цен и валидации, машины состояний во view model и каждое исправление бага: сначала воспроизведите баг падающим тестом, и тихо вернуться он уже не сможет.
Где буксует: вёрстка SwiftUI, анимации, прототипы, форма которых ещё не ясна. Snapshot- или UI-тесты пишите после того, как дизайн устоится.
Что даёт: давление на дизайн в сторону внедрённых зависимостей и маленьких типов и отсутствие кода, который ни разу не видели падающим.
Цена: медленный старт и переделки тестов, если они фиксируют детали реализации, а не поведение.
Честный ответ на интервью называет, где вы его применяете, а где пишете тесты после, а не заявляет про 100% TDD.
На каждом pull request запускайте быстрые детерминированные наборы (unit- и интеграционные тесты по тест-плану Smoke), а медленные (полный UI, snapshot-тесты, производительность) переносите на мерж или на ночь.
На каждом pull request: xcodebuild test -testPlan Smoke на зафиксированной версии Xcode и зафиксированном симуляторе, чтобы красная сборка означала изменение кода, а не машины.
Сохранённые результаты: -resultBundlePath сохраняет .xcresult как артефакт, покрытие изменённых строк публикуется в pull request, скриншоты падений прикладываются.
На мерж или по ночам: тест-план Full: UI-, snapshot- и performance-наборы, которые медленнее и сильнее зависят от машины.
Нестабильные тесты: отправляйте в карантин с тикетом, вместо того чтобы перезапускать до зелёного, чтобы сигнал pull request оставался надёжным.
Тесты перестали владеть машиной в одиночку: XCTest распределяет тестовые классы по клонированным процессам и симуляторам, а Swift Testing ещё и выполняет тесты конкурентно внутри одного процесса. Тесты, полагавшиеся на общее состояние или фиксированный порядок, теперь падают в зависимости от тайминга.
Как параллелит каждый раннер
XCTest: классы раскладываются по нескольким процессам-раннерам, каждый на своём клоне симулятора. Синглтон больше не общий для тестов, которые раньше видели записи друг друга, поэтому первыми ломаются тесты, зависящие от порядка. -maximum-parallel-testing-workers N ограничивает число клонов, каждый из которых ест реальную память.
Swift Testing: сьюты и функции @Test по умолчанию выполняются как конкурентные задачи в одном процессе. static var действительно общий и действительно гоняется. .serialized делает последовательным один сьют или тест.
Что ломается и как чинить
Изменяемые глобальные переменные: static var current, URLProtocol-стаб, зарегистрированный глобально через URLProtocol.registerClass, закэшированный синглтон. Внедряйте зависимости в экземпляр. Это единственное исправление, которое переживает оба раннера.
Один фиксированный путь на диске или один suite в defaults: дайте каждому тесту свою директорию в FileManager.default.temporaryDirectory с именем из UUID и свой UserDefaults(suiteName:).
Общий аккаунт на бэкенде или порт: два воркера логинятся одним пользователем.
Где это настраивается
.xctestplan хранит настройки по конфигурациям: окружение, язык, санитайзеры, покрытие, случайный порядок выполнения, повторы и то, запускается ли таргет параллельно. Держите план Smoke для pull request и Full для ночных прогонов (xcodebuild -testPlan Smoke) и включите там случайный порядок, чтобы скрытые зависимости проявились раньше, чем их вскроет параллельность.
Тест, который падает только в параллели, читал состояние, которое не создавал.
Сначала добейтесь падения по требованию, затем меняйте одну вещь: если помогло более долгое ожидание, это была синхронизация; если тест падает и с таймаутом в 30 секунд, приложение пришло в неверное состояние, и это настоящая гонка (race condition).
Воспроизведите, прежде чем строить теории
xcodebuild test -only-testing:UITests/CheckoutTests/testPay -test-iterations 100 -run-tests-until-failure или та же политика повторов в тест-плане. Запускайте на машине CI или на нагруженном симуляторе: flaky-падение живёт в медленном хвосте распределения таймингов.
Сохраняйте улики автоматически: бандл .xcresult как артефакт сборки, скриншот, прикреплённый в tearDown с lifetime = .keepAlways, и xcrun simctl io booted recordVideo, когда одного кадра мало.
Недостающее ожидание или настоящая гонка
XCUITest ждёт, пока приложение станет idle, перед каждым действием, а waitForExistence(timeout:) поверх этого опрашивает. Ни то ни другое не знает о вашем сетевом запросе, запущенном Task, asyncAfter или картинке, декодирующейся в ячейку. Такие дыры и есть недостающие ожидания; закрывайте их явным условием: waitForExistence, expectation с NSPredicate на isHittable или XCTWaiter для нескольких событий.
Настоящая гонка на скриншоте выглядит иначе: устаревшая строка, второй алерт, спиннер, который так и не остановился. Это состояние, утёкшее из предыдущего теста, стаб, отвечающий не в том порядке, или анимация по таймеру; чините приложение или фикстуру, а не тест.
Чего не делать
sleep(2) противопоставляет константу переменной: мало на загруженном раннере, в остальных случаях это потерянное время, а против настоящей гонки лишь сужает окно.
Перезапуск до зелёного прячет баг. Вместо этого отправьте тест в карантин с тикетом и ответственным.
Сделайте шов (seam) на каждом из этих четырёх вызовов и больше ничего не меняйте: целью должен стать объект, который можно создать в тесте без глобальных зависимостей, а не переписанный экран.
Шаги
Сначала зафиксируйте поведение: snapshot или грубый тест через публичные точки входа, чтобы извлечение доказуемо сохраняло поведение. Рефакторинг непротестированного кода без характеризационного теста остаётся просто правкой.
Протокол поверх существующего типа: protocol Analytics { func track(_ event: Event) } плюс extension AnalyticsManager: Analytics {}. Сам синглтон не меняется.
Внедрение через аргументы по умолчанию: init(analytics: Analytics = AnalyticsManager.shared). Все места вызова продолжают компилироваться, дифф остаётся маленьким, а тест передаёт spy.
Используйте швы, которые уже есть во фреймворках: протокол для URLSession не нужен: соберите сессию со стабом URLProtocol в protocolClasses. UserDefaults(suiteName:) даёт одноразовое хранилище. Date() превращается во внедрённое () -> Date или Clock.
Ловушка
Сделать shared переменной var, чтобы тесты могли его подменять. Выглядит дешевле и работает, пока тесты не делят процесс (Swift Testing по умолчанию выполняет их конкурентно) и стаб одного теста не протекает в проверку другого. У внедрения в экземпляр такого окна нет.
Где остановиться
Остановитесь, когда решения живут в простом объекте без импорта UIKit, который создаётся одной строкой, а у контроллера остались только аутлеты, вёрстка и проброс вызовов. Не стройте на этой неделе DI-контейнер на всё приложение и не трогайте экраны, которые никто не меняет; следующая задача в этом файле сделает следующий шов.