Инженерные статьи CDNMac

Проверка совместимости Codable на старых JSON в iOS

Проверка совместимости Codable на старых JSON в iOS

После обновления приложения iOS на устройстве может остаться JSON-кеш, записанный прежней версией. То, что последний ответ сервера успешно декодируется, ещё не означает, что удастся прочитать старый кеш. Типичная проблема возникает после добавления поля в модель Codable: тесты проверяют только новый ответ, а с ошибкой декодирования сталкивается пользователь, открывший страницу без подключения к сети. Решение — не подавлять все ошибки, а сохранить реальные исторические данные как тестовые образцы и явно определить, какие изменения допустимы, а какие должны приводить к ошибке.

Определите границы совместимости

Возьмём сводку учётной записи: id и status необходимы для работы приложения, а nickname — необязательное поле, добавленное позднее. Ожидаемое поведение таково: старые данные без nickname читаются; новые данные с ещё неизвестным клиенту ключом тоже читаются; отсутствие id приводит к ошибке. Эти три случая нужно проверять отдельно. Одного «нормального ответа» недостаточно, чтобы подтвердить совместимость.

В автоматически сгенерированной реализации Decodable поле типа String? допускает отсутствие ключа и получает значение nil; отсутствие обязательного String вызывает ошибку. Неизвестные ключи такая реализация обычно игнорирует. Но всё это относится только к декодированию: если интерфейс затем принудительно разворачивает nickname, старый кеш всё равно может привести к сбою. После проверки модели убедитесь, что код, использующий это поле, предусматривает запасной вариант отображения.

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

Сохраните исторические ответы как неизменяемые образцы

Создайте в тестовой цели каталог Fixtures и сохраните в нём account-v1.json, account-v2.json и account-missing-id.json. Имена файлов должны описывать форму данных, а не дату конкретного запуска CI. Добавьте все три файла в Copy Bundle Resources тестовой цели: одного лишь создания файлов в каталоге проекта недостаточно, чтобы тесты нашли их при запуске.

В account-v1.json сохраните старый ответ с id и status, но без nickname. В account-v2.json добавьте nickname и ключ, не объявленный в текущей модели. В третьем образце удалите id: это отрицательный пример, который должен приводить к ошибке. Подготовьте образцы на основе обезличенных реальных данных: замените персональные сведения и токены, но сохраните типы полей, отсутствующие значения и уровни вложенности. Не перезаписывайте старые файлы при каждом изменении API, иначе тесты перестанут выявлять регрессии.

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

Проверьте три результата с помощью XCTest

В тесте ниже предполагается, что образцы скопированы в корень тестового bundle. AccountSnapshot объявлена отдельно для примера; в реальном проекте замените её производственной моделью, которую требуется проверить. Иначе тест может подтвердить работоспособность лишь временной структуры.

import Foundation
import XCTest

private struct AccountSnapshot: Decodable {
    let id: String
    let status: String
    let nickname: String?
}

final class CodableContractTests: XCTestCase {
    private func decode(_ name: String) throws -> AccountSnapshot {
        let url = try XCTUnwrap(
            Bundle(for: Self.self).url(
                forResource: name,
                withExtension: "json"
            )
        )
        let data = try Data(contentsOf: url)
        return try JSONDecoder().decode(AccountSnapshot.self, from: data)
    }

    func testOldResponseRemainsReadable() throws {
        let account = try decode("account-v1")
        XCTAssertEqual(account.id, "example-001")
        XCTAssertNil(account.nickname)
    }

    func testUnknownFieldDoesNotBreakDecoding() throws {
        let account = try decode("account-v2")
        XCTAssertEqual(account.id, "example-001")
        XCTAssertEqual(account.nickname, "Tester")
    }

    func testMissingRequiredFieldFails() {
        XCTAssertThrowsError(try decode("account-missing-id"))
    }
}

Используйте одинаковый пример id в двух читаемых образцах, чтобы проверки имели однозначный смысл. Пока отрицательный тест требует лишь возникновения ошибки. Если её тип тоже входит в контракт обработки данных, дополнительно проверьте DecodingError.keyNotFound и имя поля. Так можно отличить ожидаемый отказ из-за отсутствующего поля от ситуации, когда тестовый ресурс вообще не найден.

Не полагайтесь на поведение по умолчанию для дат и перечислений

Если образцы содержат даты, сначала проверьте, какое значение JSONDecoder.dateDecodingStrategy используется в производственном коде; тест должен применять ту же конфигурацию. Если набор строковых значений статуса пополняется новым элементом перечисления, решите, нужно ли сохранять неизвестный статус и показывать общий текст или отказывать в обработке. Зафиксируйте решение в модели и образцах, а не придумывайте отдельную стратегию декодирования внутри теста.

Добавьте проверку в задачу на облачном Mac

Сначала проверьте проект и общий scheme командой xcodebuild -list, затем возьмите ID устройства из списка доступных симуляторов. Переменные окружения для следующей команды должна предоставить ваша задача CI; в TEST_TARGET укажите имя тестовой цели. Использование ID устройства исключает неоднозначность, если у симуляторов совпадают имена:

: "${PROJECT:?Set PROJECT}"
: "${SCHEME:?Set SCHEME}"
: "${SIM_ID:?Set SIM_ID}"
: "${TEST_TARGET:?Set TEST_TARGET}"

xcodebuild \
  -project "$PROJECT" \
  -scheme "$SCHEME" \
  -destination "platform=iOS Simulator,id=$SIM_ID" \
  "-only-testing:$TEST_TARGET/CodableContractTests" \
  test

На облачном Mac CDNMac сначала убедитесь, что выбранная версия Xcode открывает проект, scheme доступен совместно, а тестовые ресурсы попали в bundle. После этого запускайте задачу. Здесь выполняются тесты контракта данных: им не нужны ни материалы для подписи, ни подключённое физическое устройство. CI должна учитывать код завершения xcodebuild: отсутствие образца, ошибка декодирования или несовпадение результата проверки должны завершать задачу с ошибкой. Не определяйте успешность по ключевым словам в логах.

Что проверять при обновлении образцов

Когда изменение модели поступает на ревью, последовательно спросите: будет ли приложение по-прежнему читать старый кеш? Что показать вместо нового поля, если его нет в старых данных? Следует ли игнорировать неизвестные ключи? Куда попадёт ошибка при повреждении обязательного поля? Затем убедитесь, что тесты используют производственную модель и производственную конфигурацию декодирования, а не только похожую на них реализацию для примера.

Если тест не проходит, сначала выясните источник ошибки. Файл не найден — проверьте его принадлежность к тестовой цели и наличие в bundle. Возникла keyNotFound — уточните, действительно ли поле обязательно или это новое необязательное поле. Возникла typeMismatch — проверьте, не изменился ли тип в реальных данных. Не прошла проверка результата — разберитесь в бизнес-смысле декодированных данных. Не заменяйте все эти случаи на try? с возвратом пустого объекта. Сохранённые исторические образцы — это исполняемая запись того, какие данные команда обязуется продолжать читать.

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

Нужно ли хранить старые JSON после добавления необязательного поля?

Да. В существующем кеше нового поля нет; образец подтверждает, что оно декодируется как nil без ошибки всего объекта.

Должен ли неизвестный ключ JSON вызывать ошибку Codable?

Обычно нет. Синтезированный Decodable игнорирует необъявленные ключи. Отдельно проверьте, что отсутствие обязательного поля вызывает ошибку.

CDNMac M4

Запустите следующую сборку iOS на выделенном облачном Mac

Выберите подходящую для задачи конфигурацию: M4, 16 ГБ ОЗУ, SSD на 256 ГБ, шесть узлов и четыре срока аренды.

Арендовать