After users upgrade an iOS app, their devices may still hold JSON caches written by an older version. A fresh server response decoding successfully does not mean the old cache is readable. A common failure occurs when a field is added to a Codable model but tests cover only new responses; users then encounter a decoding error when they open an offline screen. The answer is not to silently accept every error. Instead, preserve real historical data as fixtures and specify which changes must remain compatible and which must fail.
Define the compatibility boundary
Consider an account summary: id and status are required by the application, while nickname was added later as an optional field. The expected behavior is that old data without nickname remains readable, new data with keys the client does not yet recognize remains readable, and data without id fails. Test these three inputs separately; one “normal response” cannot establish compatibility.
In a synthesized Decodable implementation, String? accepts a missing value and produces nil; a missing non-optional String throws an error. Synthesized decoding also generally ignores unknown keys. But those behaviors apply only at the decoding layer. If the UI later force-unwraps nickname, an old cache can still cause a failure. Once the model tests pass, check that code consuming the field provides a fallback display value.
Put both “read old data” and “reject damaged data” in the tests. Testing only the former can disguise a genuine data error as compatibility handling.
Preserve historical responses as fixtures
Create a Fixtures directory in the test target and save account-v1.json, account-v2.json, and account-missing-id.json there. Name files for the shape of their data, not the date of a CI run. Add all three files to the test target’s Copy Bundle Resources. Creating files in the project directory alone does not make them available at test runtime.
account-v1.json preserves an old response with id and status but no nickname. account-v2.json adds nickname and a key the current model does not declare. The third fixture removes id as a case that must fail. Build fixtures from sanitized versions of actual data: replace personal details and tokens, but retain field types, missing fields, and nesting. Do not overwrite old files whenever the API changes, or the tests will lose their ability to catch regressions.
If the server changes a field from a string to a number, save a new version and first decide whether the app must support both formats. Do not apply permissive string conversion to every field just to make the tests pass. If compatibility is required, implement an explicit custom decoding path and add a matching fixture.
Verify all three outcomes with XCTest
The test below assumes the fixtures have been copied to the root of the test bundle. AccountSnapshot is declared separately for this example; in a real project, replace it with the production model under test so that the tests do not merely validate a temporary duplicate.
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"))
}
}
Give both readable fixtures the same example id so the assertions have a clear meaning. For now, the failure test only requires an error. If the error type is itself part of the data-handling contract, also assert DecodingError.keyNotFound and the field name. That distinguishes an expected rejection of a missing field from a test resource that could not be found.
Do not guess about default date and enum behavior
If a fixture contains dates, check the JSONDecoder.dateDecodingStrategy used by production code; the test must use the same configuration. If a status represented by fixed strings gains a new enum case, decide whether to preserve unknown statuses and show generic text or reject them. Capture that decision in the model and fixtures rather than inventing a separate decoding strategy in the test.
Run the tests in a cloud Mac job
First use xcodebuild -list to check the project and shared scheme, then obtain a device ID from the list of currently available simulators. Your CI job supplies the environment variables in the command below; set TEST_TARGET to the test target’s name. Using a device ID avoids ambiguity when simulators share a name:
: "${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
On a CDNMac cloud Mac, confirm that the selected Xcode can open the project, the scheme is shared, and the test resources are in the bundle before running the job. These are data-contract tests: they need neither signing credentials nor a connected physical device. CI must honor the xcodebuild exit status. A missing fixture, decoding failure, or failed assertion should fail the job; do not infer success from keywords in the log.
What to check when maintaining fixtures
When a model change enters code review, ask: Will old caches still be read? What will the UI display when a newly added field is absent from old data? Should unknown keys be ignored? Where will errors from damaged required fields go? Then verify that the tests use the production model and production decoding configuration, not just a similar-looking example implementation.
When a test fails, identify the source of the error first. If a file cannot be found, check its target membership and the bundle. For keyNotFound, determine whether the field is truly required or a newly added optional field. For typeMismatch, check whether the real data type changed. If an assertion fails, inspect the business meaning of the decoded value. Do not handle all of these cases by switching to try? and returning an empty object. Preserved historical fixtures are the team’s executable record of what the app can still read.
Frequently asked questions
Should I keep old JSON fixtures after adding an optional field?
Yes. Existing caches may lack that field; the fixture verifies that it decodes as nil without rejecting the whole object.
Should an unknown JSON key make Codable decoding fail?
Usually not. Synthesized Decodable ignores undeclared keys. Test a missing required field separately to ensure that input does fail.
Run your next iOS build on a dedicated cloud Mac
Choose a fixed physical environment with M4, 16GB RAM, and a 256GB SSD, and pick the right fit for your task from six nodes and four rental terms.