CDNMac 工程文章

用历史 JSON 样本验证 iOS Codable 兼容性

用历史 JSON 样本验证 iOS Codable 兼容性

用户升级 iOS App 后,设备上可能仍留着旧版写入的 JSON 缓存。此时服务端返回的最新响应能正常解码,不代表旧缓存也能读。一个常见事故是给 Codable 模型增加字段后,测试只覆盖新响应,直到用户打开离线页面才遇到解码错误。解决办法不是让所有错误静默通过,而是把真实的历史数据固定成样本,明确哪些变化应兼容,哪些必须报错。

先划定兼容边界

以账户摘要为例:id 和 status 是业务必需字段,nickname 是后来增加的选填字段。预期是:旧数据没有 nickname 也能读取;新数据多出客户端尚未认识的键仍能读取;缺少 id 必须失败。这三种输入要分开测试,不能只用一份“正常响应”证明兼容。

String? 在合成的 Decodable 实现中可接收缺失值,结果为 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,作为预期失败的反例。样本应从已脱敏的数据形态整理:替换个人资料与令牌,但保留字段类型、缺失情况和嵌套层级。不要每次接口变动就覆盖旧文件,否则测试会失去发现回归的能力。

如果服务端将字段从字符串改为数字,另存新版本,并先确定 App 是否必须同时支持两种格式;不要为了让测试变绿,直接给所有字段套上宽松的字符串转换。需要兼容时,写明确的自定义解码分支和对应样本。

用 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 的测试任务

先用 xcodebuild -list 核对项目与共享 scheme,再从当前可用模拟器列表取得设备 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

在 CDNMac 云端 Mac 上,先确认所选 Xcode 能打开工程、scheme 已共享、测试资源进入 bundle,再执行任务。这里跑的是数据契约测试,不需要签名资料,也不需要连接真机。CI 应保留 xcodebuild 的退出状态:样本丢失、解码失败或断言不符都应让任务失败,不能用日志关键词判断成功。

维护样本时检查什么

模型改动进入代码评审时,逐项问:旧缓存是否还会被读取?新增字段在旧数据中缺失时如何显示?未知键是否应被忽略?必填字段损坏时错误会去哪里?再核对测试是否调用生产模型及生产解码配置,而不是只运行一份看起来相同的示例实现。

测试失败先看错误来源。找不到文件,检查目标成员资格与 bundle;keyNotFound,核对字段究竟是必要数据还是新增选填项;typeMismatch,检查真实数据类型是否变化;断言失败,则检查解码后的业务含义。不要把这些情况统统改成 try? 并返回空对象。保留下来的历史样本,是团队对“还能读取什么”的可执行记录。

常见问题

新增一个可选字段后,旧 JSON 样本还需要保留吗?

需要。旧缓存和延迟到达的响应仍可能没有该字段;保留样本可以持续验证缺失字段被解码为 nil,而不是意外导致整个对象解码失败。

未知字段应当让 Codable 解码失败吗?

通常不应当。合成的 Decodable 解码会忽略未声明的 JSON 键;应另设样本验证真正必填的字段缺失时仍会报错。

CDNMac M4

让下一次 iOS 构建运行在独享云端 Mac 上

使用 M4、16GB RAM 与 256GB SSD 的固定物理环境,从六个节点和四种租期中选择适合当前任务的组合。

开始租用