Test iOS JSON Merge Patch on a cloud Mac
When a team switches user settings to partial updates, the common failure is not a rejected request but a successful one that leaves stale fields behind: the client treats null as a value to ignore, while the server interprets it as a deletion. Adding a set of Swift contract tests to build jobs on a cloud Mac checks more than whether the code compiles—it verifies that both sides agree on what an update means. This guide covers only JSON Merge Patch document transformations, with no calls to production APIs and no simulator dependency.
Establish the four rules of the update contract
RFC 7396 defines the rules for JSON Merge Patch. Object patches are processed field by field: fields absent from the patch are preserved, fields set to null are deleted, and other values are applied recursively. If the patch is not an object, it replaces the entire target value. Arrays are therefore not merged by index.
First, specify that the API uses application/merge-patch+json; do not infer the protocol from the request method alone. An ordinary JSON request can also use PATCH while following completely different business rules.
Within an object member,
nullmeans deletion. At the root of a patch,nullturns the entire document into a null value. If the application must store explicit null values,nullat the same location cannot serve as both an assignment and a deletion instruction.
Also distinguish an empty object from an empty array: applying {} to an existing object preserves its contents, while [] replaces the entire target with an empty array. If the target is not already an object, an empty object patch produces an empty object.
Write a standalone Swift merge core
Create ci/merge-patch-check.swift in the repository and add the following code. It uses Foundation to parse JSON, with fragment support enabled so that numbers, strings, and root-level null values can also be tested, rather than accepting only objects and arrays.
import Foundation
enum PatchError: Error {
case depthExceeded
case mismatch(String)
}
func parse(_ text: String) throws -> Any {
try JSONSerialization.jsonObject(
with: Data(text.utf8),
options: [.fragmentsAllowed]
)
}
func merge(
_ target: Any,
_ patch: Any,
depth: Int = 0
) throws -> Any {
guard depth <= 64 else {
throw PatchError.depthExceeded
}
guard let fields = patch as? [String: Any] else {
return patch
}
var result = target as? [String: Any] ?? [:]
for (key, value) in fields {
if value is NSNull {
result.removeValue(forKey: key)
} else {
result[key] = try merge(
result[key] ?? NSNull(),
value,
depth: depth + 1
)
}
}
return result
}
func canonical(_ value: Any) throws -> Data {
try JSONSerialization.data(
withJSONObject: value,
options: [.sortedKeys, .fragmentsAllowed]
)
}
func check(
_ name: String,
_ target: String,
_ patch: String,
_ expected: String
) throws {
let actual = try merge(try parse(target), try parse(patch))
let expectedValue = try parse(expected)
guard try canonical(actual) == canonical(expectedValue) else {
throw PatchError.mismatch(name)
}
}
The depth limit is this implementation’s resource policy, not a rule of the protocol. The root call starts at depth zero, and recursive calls beyond 64 fail. A production request entry point should also limit the payload size in bytes and the overall nesting depth: arrays are replaced directly, so this object-recursion counter does not fully inspect them.
This core accepts only values produced by JSON parsing, not arbitrary application objects. Do not place dates or binary data directly into a dictionary and expect serialization to correct the types automatically.
Verify deletion and replacement with fixed test vectors
Append the following checks to the same file. Each fixture tests one primary semantic rule, so a failing test name maps directly to a contract clause during code review.
try check(
"delete-and-preserve",
#"{"theme":"dark","badge":3}"#,
#"{"badge":null}"#,
#"{"theme":"dark"}"#
)
try check(
"nested-object",
#"{"ui":{"theme":"dark","scale":2}}"#,
#"{"ui":{"scale":3}}"#,
#"{"ui":{"theme":"dark","scale":3}}"#
)
try check(
"replace-array",
#"{"roles":["reader","writer"]}"#,
#"{"roles":["reader"]}"#,
#"{"roles":["reader"]}"#
)
try check("root-null", #"{"enabled":true}"#, "null", "null")
try check("empty-object", "[1,2]", "{}", "{}")
try check("empty-array", #"{"items":[1]}"#, #"{"items":[]}"#, #"{"items":[]}"#)
try check("missing-delete", "{}", #"{"absent":null}"#, "{}")
try check("scalar-root", #"{"count":1}"#, "7", "7")
Only object keys are sorted before comparison; array order is left unchanged. This serialization-based comparison is suitable for these fixed fixtures, but it is not a general-purpose number normalization scheme and should not be used to compute cross-language signatures.
If the client models fields as ordinary optional values, encoding can easily turn both “delete this field” and “leave this field unchanged” into an omitted field. The request-building layer should explicitly distinguish three states: unchanged, delete, and assign. Check the generated JSON separately; testing the merge core alone does not prove that requests are encoded correctly.
Add resource-boundary and repeated-application checks
Applying the same patch repeatedly should leave the result unchanged. This helps catch implementations that mistakenly append to arrays or increment numbers, but it does not prove that the entire API is free of side effects such as sending duplicate notifications.
Add the following checks to confirm that excessively deep objects produce an explicit error rather than being silently truncated:
let target = try parse(#"{"ui":{"scale":1}}"#)
let patch = try parse(#"{"ui":{"scale":2},"old":null}"#)
let once = try merge(target, patch)
let twice = try merge(once, patch)
guard try canonical(once) == canonical(twice) else {
throw PatchError.mismatch("idempotence")
}
var deep: Any = 1
for _ in 0..<66 {
deep = ["child": deep]
}
do {
_ = try merge(NSNull(), deep)
throw PatchError.mismatch("depth-limit")
} catch PatchError.depthExceeded {
}
Invalid JSON is another commonly overlooked case. A parsing failure must stop request processing; do not catch the error and continue merging with an empty dictionary, or malformed input may be treated as a valid update. Define the policy for accepting duplicate keys separately at the entry point as well, rather than relying on different parsers happening to retain the same value.
Add the tests to commit checks on a cloud Mac
First confirm that the current toolchain is available, then run the following from the repository root:
set -eu
xcrun --find swift
xcrun swift ci/merge-patch-check.swift
The script does not use assertions that optimization might disable. Any failed comparison throws an error and produces a nonzero exit status. Run it before the time-consuming application build, and retain the fixture names and exit status. There is no need to log real users’ settings.
In practice, validate at three layers: run the fixed vectors against the core, inspect the actual encoding in the request-building layer, and verify the document after the server applies the patch in the API integration layer. The mobile cache should use the same fixtures to prevent the UI from first applying an optimistic update under incorrect rules, only to be overwritten later by the server’s result. The final review should focus on what each field update means, not merely whether a request succeeds.
Frequently asked questions
Can JSON Merge Patch set an object field to null?
A null value in a patch object deletes that field. Keeping the field with an explicit null value requires another protocol or an explicit operation wrapper. A null root patch does replace the entire document with null.
Are array elements merged by index?
No. An array in the patch replaces the entire existing array, including when the new array is empty. Define a separate business operation if updates must address individual element identifiers.
Run your team's builds on a dedicated Mac
Choose a dedicated Apple Silicon M4 Mac mini and keep your team's own toolchain and runner configuration. Choose daily, weekly, monthly, or quarterly billing; availability is shown in real time in the console.
Rent a build node