Vedran Burojevic
← WritingSeptember 13, 2026

Testing CloudKit sync without waiting for production

How to reproduce CloudKit sync failures locally with isolated containers, fixture data, and deterministic assertions before users discover them.

On this page

CloudKit sync bugs have an annoying habit of looking like weather.

A user says data disappeared on the iPad. Another says a deleted item came back. A tester signs out of iCloud, signs back in, and now a record exists twice. The developer opens one simulator, taps around for a few minutes, sees nothing obviously broken, and quietly hopes the problem was atmospheric.

That is not testing. It is a manual smoke check with no reproducible evidence.

You cannot make CloudKit completely deterministic. You can make the app's sync behavior testable enough that most failures reproduce before production. The trick is to stop testing "CloudKit works" and start testing the contract your app owns around identity, mutation, freshness, conflict handling, and recovery.

1. Write the sync contract first

Before building sync tests, write down what the app promises.

A useful sync contract is small and specific:

Trips, items, and templates sync through the user's private iCloud database.
Device settings and diagnostics stay local.
stableID is the logical identity for synced records.
Relationships may be temporarily unresolved while sync converges.
The app preserves local edits when the network is unavailable.
Duplicate logical records are surfaced or repaired explicitly.

That document gives tests something to enforce.

Without it, sync testing becomes a tour of vibes:

  • does a record eventually appear somewhere?
  • does the UI seem current?
  • does reinstalling make the weird state vanish?
  • does the engineer feel a vague sense of relief?

Relief is not an assertion.

The contract should answer the questions users will actually care about:

  1. Which data should appear on another device?
  2. Which data is intentionally local?
  3. What identity survives across devices and reinstalls?
  4. What happens when the same object changes in two places?
  5. What does the UI say while sync is delayed?
  6. What recovery path exists when convergence fails?

Once those answers exist, tests can target behavior instead of CloudKit folklore.

2. Use separate CloudKit containers for development, test, and production

Never run destructive sync tests against the production container.

That sounds obvious. It is exactly the kind of obvious rule that gets violated when a release is already late and someone toggles an entitlement with the confidence of a raccoon operating a forklift.

Use separate containers or at least separate environments for:

  • local development
  • automated testing or dogfooding
  • production

For a real app, I want the environment visible in code and diagnostics:

enum SyncEnvironment: String {
    case development
    case dogfood
    case production
}
 
struct SyncConfiguration {
    var environment: SyncEnvironment
    var containerIdentifier: String
    var storeURL: URL
}

The app should not guess this from scattered build settings. The configuration should be explicit enough that logs can tell you which container, store, schema version, and account state were active when a test failed.

A good local test run should be disposable. If deleting the test container or store would make someone nervous, the environment is not isolated enough.

3. Make the local store disposable

CloudKit testing needs clean starts.

Use a store location that can be removed before each scenario:

func makeTestStoreURL(name: String) throws -> URL {
    let root = FileManager.default.temporaryDirectory
        .appending(path: "SyncTests")
        .appending(path: name)
 
    try FileManager.default.removeItemIfExists(at: root)
    try FileManager.default.createDirectory(
        at: root,
        withIntermediateDirectories: true
    )
 
    return root.appending(path: "Store.sqlite")
}

The exact SwiftData container setup depends on the app, but the principle is stable: a sync test should know which store it opened, whether that store was clean, and how to delete it afterward.

Do not reuse a random simulator store and call the result a test. Old local state is how sync bugs hide inside yesterday's data and then reappear during a demo.

Keep fixtures explicit:

struct TripFixture {
    var stableID: UUID
    var title: String
    var items: [PackingItemFixture]
}

Seed data through the same domain operations the app uses in production. If tests insert half-valid models directly into SwiftData, they may prove the persistence layer accepts a shape the product never creates.

That is useful for corruption tests. It is not useful as the default path.

4. Test with two logical devices

Most sync bugs require at least two timelines.

You can represent those timelines with two physical devices, two simulators, or two isolated stores using the same account and CloudKit container. The important point is that the test scenario names the actors.

For example:

Device A: iPhone store
Device B: iPad store
Account: same iCloud user
Container: dogfood CloudKit container
Object: Trip(stableID: 6E4A...)

Then write scenarios as sequences:

  1. Device A creates a trip while online.
  2. Device B launches from a clean store.
  3. Device B observes the trip or shows a truthful waiting state.
  4. Device B edits the title.
  5. Device A edits the title before observing B's change.
  6. Both devices converge to the app's defined conflict policy.

The test does not need to assert every CloudKit notification. It needs to assert the product outcome.

If your app says "last writer wins," prove the final value and the updated timestamp are consistent. If your app says conflicts are reviewed by the user, prove a conflict review state appears with both versions available. If your app merges fields, prove the merge is deterministic.

The weak version is "open both devices and see if it looks fine." That is how bugs survive QA.

5. Control network and account state deliberately

CloudKit failures are often not pure data bugs. They are state bugs.

Test these separately:

  • offline creation
  • offline edit
  • slow network
  • cancelled fetch or save
  • iCloud unavailable
  • iCloud sign-out while the app is running
  • account switch between launches
  • permission or entitlement mistakes in development builds

Do not bury all of them under one broad "sync failed" branch.

The app should translate environment state into product state:

enum SyncAvailability {
    case available
    case offline
    case iCloudAccountUnavailable
    case restricted
    case temporarilyUnavailable
}

Then the UI can say something truthful:

  • Saved on this device
  • Waiting for iCloud
  • iCloud account unavailable
  • Sync issue detected
  • Recovery available

A lying green checkmark is worse than no status. It tells the user the app knows something it does not know. Users remember that, especially when their data is involved.

6. Put sync operations behind named boundaries

Testing is easier when writes have names.

This is hard to verify:

trip.title = newTitle
try context.save()

This is easier:

try tripOperations.renameTrip(
    id: tripID,
    title: newTitle,
    source: .userEdit
)

The operation can enforce preconditions, update timestamps, log events, and return a result the test can assert.

enum RenameTripResult {
    case saved(stableID: UUID, updatedAt: Date)
    case missing
    case blockedBySyncIssue(SyncIssue)
}

That boundary gives sync tests leverage:

  1. set up fixture state
  2. run the operation on Device A
  3. run another operation on Device B
  4. wait for the observation boundary
  5. assert the final domain state and user-visible state

If views mutate synced models directly from everywhere, testing becomes forensic archaeology. You can still do it, but it is mostly an exercise in learning which screen changed the data.

7. Wait on app-level evidence, not arbitrary sleeps

Sync is asynchronous, but tests should not be a pile of sleep(10) calls.

Use observation helpers that wait for app-level conditions:

func waitForTrip(
    stableID: UUID,
    in store: TripStore,
    timeout: Duration
) async throws -> TripSnapshot {
    try await eventually(timeout: timeout) {
        guard let trip = try await store.tripSnapshot(stableID: stableID) else {
            throw WaitError.notReady
        }
        return trip
    }
}

Then assert what matters:

let syncedTrip = try await waitForTrip(
    stableID: tripID,
    in: deviceB.trips,
    timeout: .seconds(30)
)
 
#expect(syncedTrip.title == "Lisbon")
#expect(syncedTrip.items.count == 3)

The timeout should be generous enough for realistic local sync and strict enough to catch regressions. If a scenario only passes with random long sleeps, the test is not stable.

For CI, separate fast deterministic tests from slower sync integration tests. Not every pull request needs a full CloudKit run, but release candidates should prove the sync contract against real infrastructure before users do the job for free.

8. Test conflict sequences explicitly

Conflicts are not rare edge cases in sync apps. They are normal multi-device behavior.

Start with a small set of hostile sequences:

  1. same object renamed on two devices
  2. parent deleted while child is edited elsewhere
  3. object archived on one device and updated on another
  4. duplicate logical records created offline
  5. migration runs on one device before the other
  6. iCloud account disappears during a save

For each sequence, define the expected product result.

Example:

Given Device A and Device B both have Trip 123
When Device A renames it to "Paris"
And Device B renames it to "Lisbon" before observing A
Then the app records a conflict for Trip 123
And the UI presents a conflict review state
And neither version is silently discarded

Maybe your app chooses last-writer-wins instead. Fine. Say so and test that. The problem is not choosing a simple policy. The problem is having no policy and letting timing choose for you.

Timing should not be the product manager.

9. Keep diagnostics testable

Diagnostics should not be an afterthought you add once support is already drowning.

Log structured sync events:

logger.info(
    "sync.trip.rename.saved stableID=\(tripID) source=userEdit updatedAt=\(updatedAt)"
)

Use categories the app can assert in tests:

  • container opened
  • account available
  • account unavailable
  • local save succeeded
  • local save failed
  • remote change observed
  • conflict detected
  • orphan detected
  • duplicate repair completed
  • recovery action performed

The goal is not to create a beautiful log museum. The goal is to have enough evidence when a test fails or a user reports stale data.

A good failure report should answer:

  1. Which sync environment was active?
  2. Which app and schema version opened the store?
  3. Which operation was running?
  4. Which stable IDs were involved?
  5. What did the app believe about account and network state?
  6. What recovery path was offered?

Never log personal content unless the user explicitly exports diagnostics with consent. IDs, counts, timestamps, operation names, and outcome categories are usually enough.

10. Separate unit tests, integration tests, and release drills

One test suite should not do every job.

Use three layers.

Unit tests prove domain behavior without CloudKit:

  • identity generation
  • merge policy
  • duplicate detection
  • orphan resolution
  • mutation preconditions
  • recovery decisions

Integration tests run against real persistence and a non-production CloudKit environment:

  • data appears on a second logical device
  • offline changes upload later
  • account unavailable state is handled
  • delayed sync shows truthful UI state
  • conflicts produce the expected product outcome

Release drills exercise the messy sequences before shipping:

  • upgrade old stores
  • install on two real devices
  • test sign-out and sign-in
  • test App Store or TestFlight entitlements
  • test migration with sync already enabled
  • verify diagnostics export contains useful evidence

This keeps the fast suite fast and the serious suite serious. A single giant "sync test" that runs unpredictably and tells you nothing precise is not a reliable release gate.

11. Make local reproduction part of the bug template

Every sync bug report should include a reproduction target.

For example:

Environment: dogfood CloudKit container
Devices: iPhone 15 simulator + iPad simulator
Account state: signed into iCloud
Initial fixture: Trip with 3 items
Sequence:
1. Create item offline on iPhone
2. Delete trip online on iPad
3. Bring iPhone online
Expected: orphan item enters recovery state
Actual: item reappears under deleted trip
Diagnostics: sync.orphan.detected missing

That format forces the report to name the timeline. It also gives the engineer a starting point for a regression test.

If a bug cannot be reproduced locally yet, say that. Do not turn uncertainty into a fake root cause. Sync systems already contain enough ambiguity without the team contributing decorative fiction.

12. The baseline I would ship

For a production CloudKit-backed app, I want this testing baseline:

  1. a written sync contract in the repo
  2. non-production CloudKit environment for destructive tests
  3. disposable local stores for clean scenarios
  4. fixture data created through domain operations
  5. two-device scenarios for convergence and conflict behavior
  6. explicit tests for offline, account, and delayed-sync states
  7. named mutation boundaries instead of view-scattered writes
  8. app-level wait helpers instead of arbitrary sleeps
  9. structured diagnostics with stable IDs and outcome categories
  10. old-store upgrade fixtures when SwiftData migrations are involved
  11. release drills on real devices before shipping risky sync changes

The point is not to make CloudKit pretend to be a local deterministic database. It is not one, and pretending harder will not improve the weather.

The point is to make your app's side of the contract explicit and testable: what it writes, what it observes, what it tells the user, and how it recovers when distributed state gets messy.

Do that, and sync bugs stop being vague production anecdotes. They become named scenarios with fixtures, assertions, and logs. Less glamorous. Much more useful.

Command menu

Navigate the site or run an action