Проверка настроек iOS на облачном Mac

Проверка настроек iOS на облачном Mac

Один и тот же тест настроек iOS проходит при первом запуске, а при втором считывает тему, оставшуюся с прошлого раза. После переноса на облачный Mac результат снова меняется. Обычно дело не в производительности машины, а в том, что тест смешивает значения по умолчанию в текущем процессе, сохранённые настройки и аргументы запуска. Выполняя такие задачи на VpsKeep, сначала изолируйте настройки, а затем проверяйте поведение приложения. Полная очистка симулятора — не замена изоляции.

Сначала определите контракт чтения

UserDefaults — это не просто одна таблица ключей и значений. При чтении поиск идёт по нескольким доменам: аргументы запуска могут переопределять значения домена приложения, а домен регистрации предоставляет резервные значения. Поэтому наличие значения, полученного через object(forKey:), ещё не означает, что оно сохранено.

Для каждой настройки сначала определите три вещи: имя ключа, допустимый тип и результат при отсутствии значения. Например, ключ темы принимает только system, light и dark. Если ключ отсутствует, тип неверен или строка неизвестна, результатом всегда будет system. Так некорректные значения, оставшиеся от старых версий, тоже обрабатываются предсказуемо.

register(defaults:) регистрирует резервные значения только для текущего процесса и не записывает их как пользовательские настройки. Если после удаления сохранённого значения снова читается значение по умолчанию, это нормальное резервное поведение, а не неудачное удаление.

Не используйте dictionaryRepresentation() как доказательство того, что ключ сохранён. Этот метод объединяет видимые настройки и может включать значения из домена регистрации. Чтобы проверить сохранение, читайте конкретный постоянный домен.

Внедряйте отдельный suite и не затрагивайте реальные настройки

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

Следующий код можно добавить в таргет модульных тестов iOS. Каждый тест использует случайное имя suite и удаляет только созданный им постоянный домен. В примере обёртка для чтения тоже находится в тестовом файле. При подключении к проекту перенесите её в таргет приложения и импортируйте из тестов.

import Foundation
import XCTest

struct ThemePreferences {
    let defaults: UserDefaults

    init(defaults: UserDefaults = .standard) {
        self.defaults = defaults
        defaults.register(defaults: ["theme": "system"])
    }

    var theme: String {
        guard let value = defaults.object(forKey: "theme") as? String,
              ["system", "light", "dark"].contains(value) else {
            return "system"
        }
        return value
    }
}

final class ThemePreferencesTests: XCTestCase {
    private func withSuite(
        _ body: (UserDefaults, String) throws -> Void
    ) throws {
        let name = "preferences-tests." + UUID().uuidString
        let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
        defaults.removePersistentDomain(forName: name)
        defer {
            defaults.removePersistentDomain(forName: name)
        }
        try body(defaults, name)
    }

    func testOverrideAndRemoval() throws {
        try withSuite { defaults, name in
            let preferences = ThemePreferences(defaults: defaults)

            XCTAssertEqual(preferences.theme, "system")
            XCTAssertNil(
                defaults.persistentDomain(forName: name)?["theme"]
            )

            defaults.set("dark", forKey: "theme")
            XCTAssertEqual(preferences.theme, "dark")
            XCTAssertEqual(
                defaults.persistentDomain(forName: name)?["theme"]
                    as? String,
                "dark"
            )

            defaults.removeObject(forKey: "theme")
            XCTAssertNil(
                defaults.persistentDomain(forName: name)?["theme"]
            )
            XCTAssertEqual(preferences.theme, "system")
        }
    }

    func testInvalidValuesFallBack() throws {
        try withSuite { defaults, _ in
            let preferences = ThemePreferences(defaults: defaults)

            defaults.set(["dark"], forKey: "theme")
            XCTAssertEqual(preferences.theme, "system")

            defaults.set("unknown", forKey: "theme")
            XCTAssertEqual(preferences.theme, "system")
        }
    }
}

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

Дополните проверки переопределения и удаления

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

Если приложение использует и общий suite, и домен приложения, не вызывайте addSuite(named:) в модульном тесте без необходимости. Этот вызов меняет порядок поиска, и результат теста начинает зависеть от других доменов. Сначала оформите общие настройки как ещё одну внедряемую зависимость, проверьте их отдельно, а затем переходите к интеграционным тестам приложения.

Для булевых настроек тоже нужно различать «не задано» и «пользователь отключил». Один лишь вызов bool(forKey:) возвращает false при отсутствии значения и не позволяет понять, делал ли пользователь выбор. Явно проверяйте наличие ключа либо заведите отдельное версионируемое поле состояния.

Проверяйте реальное сохранение в двух процессах

Создание нового объекта UserDefaults в том же процессе подтверждает только поведение при чтении, но не сохранение после перезапуска. Интеграционный тест нужно разделить на этап записи и этап чтения, используя один и тот же suite с фиксированным тестовым именем.

На первом этапе запишите тему, подтвердите её чтение через экран приложения или тестовый интерфейс, затем штатно завершите приложение. На втором этапе запустите приложение и, не записывая значение повторно, сразу проверьте, восстановилась ли тема. В конце выполните отдельный этап очистки и удалите этот тестовый домен.

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

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

Сохраняйте данные для разбора сбоев

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

Выполните два прогона подряд, затем включите параллельное выполнение и проверьте, совпадают ли результаты. Если сбой возникает только во втором прогоне, сначала ищите suite с фиксированным именем и неочищенные домены. Если он возникает только при параллельном выполнении, проверьте общие синглтоны. Если только после перезапуска — проверьте, не перезаписывает ли инициализация пользовательские значения.

Итоговый критерий — не «однажды прочитали правильную строку», а отдельные доказательства для значений по умолчанию, пользовательского переопределения, возврата после удаления и перезапуска процесса. Зафиксировав эти границы, вы получите одинаковые результаты тестирования настроек и на новой машине, и на постоянно работающем узле.

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

Сохраняет ли register(defaults:) значения на диск?

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

Достаточно ли создать новый объект UserDefaults?

Нет. Объекты внутри одного процесса могут использовать общее состояние. Для проверки сохранения завершите процесс записи штатно и прочитайте данные в новом процессе.

Запускайте задачи на выделенном облачном Mac

Арендуйте целый физический Mac mini, а не виртуальную машину. Выберите срок аренды: день, неделя, месяц или квартал. Сначала проверьте модель и расположение узла, затем подтвердите заказ. Актуальная доступность отображается в консоли.