Пользователь сохранил документ в приложении, но не может открыть его из результатов системного поиска. На компьютере разработчика повторная проверка проходит, а автоматический тест на облачном Mac иногда завершается с ошибкой: обратный вызов сообщает об успешной записи в индекс, но поиск всё ещё не возвращает результат. Чтобы разобраться в такой ситуации, нельзя сводить «запись завершилась успешно», «запись находится поиском» и «запись исчезла после удаления» к одной проверке. Это три отдельных этапа жизненного цикла индекса, и каждый нужно проверять отдельно.
Сначала определите границы теста
CSSearchableIndex в Core Spotlight отвечает за добавление и удаление доступных для поиска записей, которые предоставляет приложение. Обратный вызов операции записи сообщает о результате этой операции, а не о том, что поисковый запрос уже видит новые данные. Поэтому тест должен как минимум фиксировать идентификатор записи, поисковую фразу, результат записи, время первого обнаружения записи и результат запроса после удаления.
Запускайте тест в цели XCTest с приложением-хостом, а не только в цели логических тестов, которая не загружает окружение приложения. Используйте для тестовых записей отдельный domainIdentifier; при каждом запуске создавайте новые uniqueIdentifier и заголовок, чтобы записи от предыдущего запуска не давали ложноположительный результат. Сначала проверьте весь процесс на одной записи, а затем переходите к массовому добавлению. Не берите в качестве образца постоянный пользовательский документ: если пользователь изменит или удалит его, результат теста тоже изменится.
Успешный обратный вызов записи подтверждает только этап добавления. Для проверки поиска нужен запрос, а после удаления — ещё один запрос. Результаты всех трёх этапов фиксируются отдельно.
Проверьте добавление и удаление с помощью XCTest
Следующий тест показывает, как выполнять асинхронные операции в XCTest. Сначала он удаляет запись с заданным идентификатором, если она осталась от прошлого запуска, затем добавляет запись с уникальным заголовком и в конце удаляет её по идентификатору. Пример проверяет обратные вызовы операций; сам по себе он не доказывает, что запись уже видна в поиске. Проверку поиска нужно добавить, как описано в следующем разделе.
import CoreSpotlight
import XCTest
final class SpotlightLifecycleTests: XCTestCase {
func testWriteAndRemove() {
let index = CSSearchableIndex.default()
let id = "smoke-\(UUID().uuidString)"
let title = "SpotlightSmoke-\(id)"
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = title
let item = CSSearchableItem(
uniqueIdentifier: id,
domainIdentifier: "spotlight-smoke-tests",
attributeSet: attributes
)
func complete(
_ name: String,
_ operation: (@escaping (Error?) -> Void) -> Void
) {
let done = expectation(description: name)
operation { error in
XCTAssertNil(error, "\(name): \(String(describing: error))")
done.fulfill()
}
wait(for: [done], timeout: 10)
}
complete("remove previous item") { finish in
index.deleteSearchableItems(
withIdentifiers: [id],
completionHandler: finish
)
}
complete("index item") { finish in
index.indexSearchableItems(
[item],
completionHandler: finish
)
}
complete("remove indexed item") { finish in
index.deleteSearchableItems(
withIdentifiers: [id],
completionHandler: finish
)
}
}
}
В реальном проекте удаление следует выполнять при очистке после теста: если проверка после записи прервётся из-за ошибки, всё равно нужно попытаться удалить уже добавленную запись. Ошибку очистки нельзя оставлять без внимания — сообщайте о ней вместе с идентификатором записи, иначе оставшиеся данные могут повлиять на следующие запуски.
Ограничьте время ожидания синхронизации поиска
В тестовой цели выполните запрос через CSSearchQuery по уникальному заголовку или поисковой фразе текущего запуска и сверьте uniqueIdentifier возвращённых записей. Проверки «найден хотя бы один результат» недостаточно: совпадающие по названию пользовательские данные могут привести к успешному прохождению теста с неверной записью. Начинайте поиск после возврата обратного вызова записи. Если нужная запись не найдена, немного подождите и создайте новый запрос; считайте тест проваленным, только если запись не появилась к установленному сроку. После обратного вызова удаления опрашивайте индекс тем же способом, пока этот идентификатор не перестанет появляться.
| Этап | Условие успешного прохождения | Что записать в первую очередь при ошибке |
|---|---|---|
| Запись | Обратный вызов без ошибки | Идентификатор, ошибка, состояние симулятора |
| Поиск | Идентификатор текущего запуска найден до истечения срока | Поисковая фраза, ошибка запроса, затраченное время |
| Удаление | Идентификатор перестал возвращаться до истечения срока | Ошибка удаления, результат последнего запроса |
Предельное время ожидания — это бюджет теста, а не гарантия производительности сервиса. Сначала понаблюдайте за несколькими запусками на одной и той же конфигурации симулятора, а затем задайте разумный предел; не увеличивайте ожидание без ограничений из-за одного медленного запроса. И для обратного вызова запроса, и для цикла опроса предусмотрите собственные условия завершения, чтобы зависание не заняло всё время выполнения конвейера.
Зафиксируйте параметры запуска на облачном Mac
Закрепите за этим тестом проект, Scheme и целевой симулятор; убедитесь, что Scheme предоставлена для общего доступа. Перед первым запуском посмотрите доступные цели с помощью xcodebuild -showdestinations, затем укажите идентификатор выбранного устройства в конфигурации запуска. Не полагайтесь на автоматический выбор «первого» симулятора. Например:
xcodebuild test \
-project Example.xcodeproj \
-scheme Example \
-destination 'platform=iOS Simulator,id=SIMULATOR_UDID' \
-only-testing:ExampleTests/SpotlightLifecycleTests \
-resultBundlePath "$PWD/spotlight-result.xcresult"
Замените SIMULATOR_UDID и имя проекта фактическими значениями; перед каждым запуском путь к пакету результатов не должен существовать. Для тестов индекса обычно не требуется сбрасывать весь симулятор. В первую очередь очищайте тестовый домен и идентификатор текущего запуска: это сокращает время выполнения и сохраняет состояние, нужное другим тестам. Если сброс устройства необходим, вынесите его в отдельное задание, чтобы параллельные тесты не использовали тот же набор устройств.
Разберите случайные сбои и завершите проверку
При сбое сначала определите этап, на котором он произошёл, а не запускайте тест повторно сразу. Если обратный вызов записи сообщает об ошибке, проверьте приложение-хост теста, атрибуты записи и объект ошибки. Если запись прошла успешно, но истекло время ожидания поиска, убедитесь, что поисковая фраза действительно записана в доступный для поиска атрибут, запрос использует идентификатор текущего запуска, а другое задание не очищает одновременно тот же тестовый домен. Если запись по-прежнему находится после удаления, сохраните идентификатор последнего найденного результата и проверьте, что вызов удаления был направлен на тот же uniqueIdentifier.
Наконец, проверьте параллельное выполнение: тесты жизненного цикла индекса одного приложения не должны запускаться параллельно на одном симуляторе, если они используют общую очистку домена. Сохраняйте для каждого запуска идентификатор, результаты трёх этапов и пакет результатов теста — так можно отличить ошибку операции от задержки синхронизации поиска и влияния данных, оставленных самим тестом. Путь пользователя через поиск считается проверенным только после успешного добавления, обнаружения и удаления записи.
Часто задаваемые вопросы
Почему запись не находится сразу после успешного добавления?
Завершение операции добавления не гарантирует мгновенное обновление поиска. Повторяйте запрос по уникальному слову до установленного предела времени.
Как исключить влияние предыдущего запуска?
Создавайте отдельные идентификатор и поисковое слово, удаляйте запись до и после теста и сохраняйте идентификатор при ошибке.
Подключите облачный Mac с фиксированной конфигурацией к своему рабочему процессу
Арендуйте выделенные физические узлы на день, неделю, месяц или квартал для сборок, автоматизации и задач инференса MLX на фиксированных аппаратных ресурсах.