---
title: "Testing Background Sync: Three Tests, and Only One Is Real"
canonical: "https://aifitnessapi.com/test/background-sync"
cluster: "Testing"
primary_query: "test healthkit background delivery"
last_reviewed: "2026-07-27"
description: "Apple documents that background server queries are not supported on the Simulator. Split it: a pure wake handler, a rare device run, a freshness alert."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"Testing Background Sync: Three Tests, and Only One Is Real\", AIFitnessAPI, https://aifitnessapi.com/test/background-sync"
---

# Testing Background Sync: Three Tests, and Only One Is Real

> The assertion worth writing is that your wake handler never advances its cursor on a failed read and produces the same result when the same wake arrives twice, expressed as a total function over samples, an error and a stored cursor. Apple documents that background server queries are not supported on the Simulator, so no hosted CI run can prove the delivery itself ever happens. Test that handler exhaustively and alert on silence, rather than writing an integration test that pretends CI woke your app up.

- Canonical: https://aifitnessapi.com/test/background-sync
- Last reviewed: 2026-07-27
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "Testing Background Sync: Three Tests, and Only One Is Real", AIFitnessAPI, https://aifitnessapi.com/test/background-sync

---

A user finishes a run at 21:40 with the phone locked in a jacket pocket. HealthKit wakes your app, the observer handler runs, and the read comes back as an error rather than data — on a locked device that is `errorDatabaseInaccessible`, which our own background sync architecture page documents. Your handler logs the error, treats "no samples returned" as "nothing new," writes the freshly fetched anchor to disk anyway, and calls the completion handler. The run is now behind the cursor. It will never be read again. Nothing crashed, nothing was logged as a failure, and the user's Saturday is permanently short by one workout.

That is not a background-delivery bug. It is a bug in a function that takes an error and a stored cursor and returns the wrong cursor, and it is four lines of test. Almost everything people want to test about background sync is like that — and the small remainder that genuinely depends on the operating system deciding to wake you cannot be tested in CI at all. So split the problem in three, and be honest about which of the three is a test:

1. **The handler.** A total function over the wake's inputs. Deterministic, exhaustive, runs on every commit. This is the only real test.
2. **The delivery.** A scripted manual run on a physical device, done rarely and deliberately. Evidence, not coverage.
3. **The freshness alert.** A server-side assertion that fires when a user goes quiet. The only one of the three that is still running when it matters.

## 1. Make the wake a function, then test it exhaustively

A HealthKit wake hands your code at most four things: a batch of samples or an error, the cursor you persisted last time, whatever you already hold locally, and the clock. Everything else in the closure — the store, the upload, the disk — is plumbing. Push the decisions into a total, non-throwing reducer over those four inputs and the closure shrinks to something with no branches worth testing:

```swift
enum WakeInput {
    case delivered(samples: [HKQuantitySample], anchor: Data)
    case failed(Error)
}

struct WakeOutcome: Equatable {
    var toPersist: [StepRecord]   // keyed on the sample's uuid
    var newCursor: Data?          // nil means: do not advance
    var dirtyDays: Set<CivilDate>
    var retryWhenUnlocked: Bool
}

func reduceWake(_ input: WakeInput,
                held: Set<UUID>,
                zone: TimeZone) -> WakeOutcome
```

`WakeOutcome` being `Equatable` is the point of the exercise: a test can assert on the whole decision in one comparison, rather than reaching into a spy for three separate side effects. And because `reduceWake` cannot throw, the closure can acknowledge on every path with a single `defer` — the acknowledgement rule is enforced by the type, not by a test that a reviewer has to remember to ask for.

The assertions worth writing are the ones that encode failures a health pipeline actually produces:

- **Failure never advances the cursor.** Given `.failed`, `newCursor` is `nil` and `retryWhenUnlocked` is true. This is the opening bug, and it is one line.
- **The same wake twice is a no-op the second time.** Feed the identical `.delivered` input with those `uuid`s already in `held`; `toPersist` must be empty and `dirtyDays` unchanged. A doubled day is the classic symptom of a handler that adds rather than recomputes, and duplicate deliveries are cheap to produce in a test and impossible to prevent in production.
- **A sample spanning local midnight marks two days, and the right two.** Run the same fixture through a DST-transition zone. This is where a wake handler quietly disagrees with the rest of your system; the rules it has to agree with are in [timezones and day boundaries](/architecture/timezones-and-day-boundaries).
- **An empty delivery produces no dirty days.** Not a zero. Not a recompute. Nothing. A wake that legitimately carries no samples must be indistinguishable, downstream, from no wake at all.
- **A partial batch advances the cursor only as far as what was persisted.** Truncate the batch mid-list and assert the cursor matches the last durably written record.

Apple ships no test double for `HKHealthStore` and no way to seed ordinary samples into a Simulator, but you do not need either here. `HKQuantitySample` has public initializers — `init(type:quantity:start:end:)` and `init(type:quantity:start:end:metadata:)` — so the fixtures you feed the reducer are real HealthKit objects rather than a parallel model that drifts. Where the store itself has to be mocked, that seam belongs to [testing a HealthKit integration](/test/healthkit-integration), and the fixture shapes that make these tests adversarial rather than decorative are in [mocking wearable data](/test/mock-wearable-data).

The Android side is the same reducer with a different input: a page of changes plus a token, or a `changesTokenExpired` response. Assert that an expired token produces a re-read plan rather than an empty result — on Health Connect an unnoticed expiry is not latency, it is permanent loss. The fake client that feeds it is covered in [Health Connect test data](/test/health-connect-test-data).

## 2. What CI cannot cover, in Apple's own words

Apple puts the same sentence on two pages — `enableBackgroundDelivery(for:frequency:withCompletion:)` and `HKObserverQuery` (both checked 2026-07-30):

> Background server queries aren't supported on the Simulator. Be sure to test your background queries on a device.

A hosted macOS CI runner gives you simulators. That sentence ends the conversation: no amount of XCUITest scaffolding makes a green CI run evidence that background delivery works, and a test that passes on a platform where the feature is documented as unsupported is worse than no test, because it will be read as coverage. Attach a real device to a self-hosted runner and you still have no documented lever that makes iOS decide to wake your app; you can register, you can write a sample, and then you wait on someone else's scheduler.

Android is better and still not good. Google documents the constraint plainly in the Doze restrictions (page fetched 2026-07-30):

> Doesn't let `JobScheduler` run. `WorkManager` uses `JobScheduler` internally, so `WorkManager` tasks don't run.

Calling that "best-effort" is our characterisation, not Google's wording — but unlike Apple, Google gives you the lever. Doze and App Standby are forceable from `adb`, so an instrumented run can put the device in the state your job actually fails in:

```bash
adb shell dumpsys deviceidle force-idle
# ... enqueue work, observe that it does not run ...
adb shell dumpsys deviceidle unforce
adb shell dumpsys battery reset
```

For the job itself, `androidx.work:work-testing` (added as an `androidTestImplementation`; pin the current version, the version literal in Google's own snippet is stale) gives you `WorkManagerTestInitHelper` and its `initializeTestWorkManager(context, config)` call, `TestDriver` for simulating initial delays, constraints and periodic intervals, `TestWorkerBuilder` and `TestListenableWorkerBuilder` for exercising a `Worker` or `CoroutineWorker` without initializing WorkManager at all, and `SynchronousExecutor` to make the whole thing run in order.

Read that list again and notice what it is for. `SynchronousExecutor` and `TestDriver` exist to *remove* the nondeterminism that is the production failure. A green WorkManager integration test proves your worker's logic and your constraint wiring; it cannot fail for the reason production fails, because you have hand-fired the trigger the OS was going to withhold. Use `TestWorkerBuilder` for the reducer from section 1 and `WorkManagerTestInitHelper` for enqueue-and-constraint wiring, and record in the test's own name that neither says anything about scheduling.

## 3. The manual device run: rare, scripted, and without a stopwatch

Our recommendation is a written script that one engineer runs on a physical device, and that gets re-run on a short list of triggers rather than on a cadence: a new iOS major version, any change to the entitlement or to where observer queries are registered, and any rewrite of the wake handler itself. Everything else is covered by section 1.

The script:

1. Physical device, unplugged, on a build that is **not** attached to the debugger. A developer report in Apple Developer Forums thread 690974 describes delivery that works while connected to Xcode and fails when the app runs independently — an unconfirmed report rather than an Apple statement, but a cheap precaution.
2. Confirm the `com.apple.developer.healthkit.background-delivery` entitlement is in the shipped build, not just the local one.
3. Confirm observer queries are registered at launch, before anything user-driven — a wake into a process that has not yet created the query finds nothing to deliver to.
4. Background the app. Write a sample from a different source: the Health app, a paired watch, a second test app.
5. Assert on the **server**, not on the device: the record arrived, the payload is right, the day recomputed to the value you expect.

Note what is missing from that list: a deadline. Apple documents `frequency` as a maximum — at most one wake per period — and documents no minimum rate and no latency figure for iOS observer wakes. A pass criterion of "within N minutes" would be a number we invented, so the run's assertion is "it arrived and it was correct," and the elapsed time is recorded as an observation rather than compared against anything.

The reason to keep re-running it is that the failure reports do not stop. Apple Developer Forums thread 690974 has been running since 2021 with developers reporting background delivery going quiet across successive iOS versions, and thread 814914, opened in February 2026, reports one build with identical observer setup delivering at very different rates across different Apple Watch hardware. Both are developer reports; Apple has published no general acknowledgement that background delivery is unreliable, and 814914 was still open and unresolved when we checked on 2026-07-30. Treat them as evidence that a device pass has a shelf life, not as a documented defect you can code around.

## 4. The freshness alert is the only assertion still running in production

Sections 1 and 2 test code. Section 3 tests a build, once. Neither survives contact with a user whose phone has been in a drawer since Thursday. The assertion that runs continuously is server-side, and it asserts on **silence**, not on samples: a user whose device has not checked in within their expected interval is stale, and their yesterday is unknown rather than zero. The sync-state schema, the per-provider thresholds, the reason a global threshold cannot work and the case for paging on a fraction of a provider's users rather than on individuals all belong to the [background sync design page](/architecture/background-sync). What belongs here is the part nobody writes down: the alert is a test, and therefore it has to be able to fail.

An alert nobody has ever seen fire is not coverage. It is a query that has never been executed against data that would trip it. Two cheap things fix that:

```sql
-- Test: the detector must catch a user who has gone quiet.
insert into sync_state (user_id, provider, metric,
                        last_client_checkin_at, expected_checkin_interval)
values (:synthetic_user, 'healthkit', 'steps',
        now() - interval '30 hours', interval '6 hours');

-- The staleness detector must return exactly this user.
-- Then flip last_client_checkin_at to now() and assert it returns nothing.
```

First, a test that seeds a deliberately stale row and asserts the detector returns it, plus its negative twin — move the check-in forward and assert the detector goes quiet. That is a real pass/fail, and it catches the interval-units bug and the `null`-handling bug that otherwise make the alert permanently silent.

Second, in production, a canary: one synthetic user per provider that never checks in, so the whole path — detector, threshold, pager — is exercised every day whether or not any real user is broken. In our experience this is the single highest-value test on the page, because it is the only one that keeps running after the engineer who wrote it has moved on.

The distinction the alert depends on is that "no new samples" and "no check-in" are different facts. A user who genuinely rested and a user whose phone never woke both produce an empty read; only the second timestamp separates them, and conflating them is how a pipeline confidently emails somebody a zero.

## The honest limits

**Your reducer tests assert against your beliefs.** Every fixture in section 1 is an input you invented. If your model of what a wake delivers is wrong, the suite is green and the product is broken; the tests catch handler bugs, not wrong assumptions about HealthKit. Only the device run and production traffic can correct the model, which is why section 3 exists at all.

**There is no number to tune to.** Apple documents no delivery-latency figure for iOS observer wakes and Google publishes no scheduling guarantee, so any timing threshold in your suite is a number you made up. Keep timing out of assertions entirely and put it in the freshness alert, where the threshold is explicitly a product decision about how stale is too stale rather than a claim about the platform.

**The device run is not a regression gate.** It is one engineer's signed-off observation on one device on one OS build, and it does not re-run itself. Record it in the pull request that triggered it, with the device, the OS version and the date, and expect it to be stale within a release.

**Tooling versions rot faster here than the APIs.** The `androidx.work:work-testing` version in Google's documentation snippet is behind current releases, and third-party device-cloud features change without notice. Pin versions, date the claim, and re-check before trusting a page like this one — including this one, which reflects Apple and Google documentation as read on 2026-07-30.

## Where to go next

If a user is complaining right now, that is a symptom, and [wearable data delayed or missing](/fix/wearable-data-delayed) triages it. If the question is what the pipeline should look like so that a missed wake costs latency instead of correctness, that is the background sync design page linked above. This page is the layer in between: the assertions that prove the design is actually implemented, and an honest boundary marking where the assertions stop and a human with a phone begins.

## FAQ

### Why does HealthKit background delivery never fire during a CI run?

Because hosted CI runners give you simulators, and Apple states on both the enableBackgroundDelivery method page and the HKObserverQuery page that background server queries are not supported on the Simulator and that you should test background queries on a device. There is no workaround. Any CI test that appears to cover background delivery is testing something else, and it will be read by reviewers as coverage that does not exist.

[Permalink](https://aifitnessapi.com/test/background-sync#faq-1)

### What should a unit test for an HKObserverQuery handler actually assert?

Five things, all of which are decisions rather than side effects. A failed read advances no cursor and marks a retry. The same delivery replayed a second time persists nothing new. A sample spanning local midnight marks the correct two days, including through a daylight-saving transition. An empty delivery produces no dirty days and no zero. A batch that is only partly persisted advances the cursor no further than the last durable record. Model the handler as a total function returning one comparable outcome value and each of these is a one-line assertion.

[Permalink](https://aifitnessapi.com/test/background-sync#faq-2)

### Does WorkManagerTestInitHelper prove my background job will run on a real phone?

No, and it is important to say why. Google documents that Doze does not let JobScheduler run and that WorkManager uses JobScheduler internally, so WorkManager tasks do not run. The work-testing library exists to remove exactly that nondeterminism: SynchronousExecutor and TestDriver let you fire the trigger the operating system was going to withhold. The resulting test proves your worker logic and constraint wiring and cannot fail for the reason production fails. Forcing Doze from adb with dumpsys deviceidle force-idle is the closer approximation.

[Permalink](https://aifitnessapi.com/test/background-sync#faq-3)

### How often should we do the manual on-device background delivery run?

Our recommendation is to trigger it on events rather than on a calendar: a new iOS major version, any change to the background-delivery entitlement or to where observer queries are registered, and any rewrite of the wake handler. Run it on an unplugged physical device with the debugger detached, write a sample from another source, and assert on your server rather than on the device. Record the device, OS version and date in the pull request, because the result is one engineer's observation and not a regression gate.

[Permalink](https://aifitnessapi.com/test/background-sync#faq-4)

### Why can I not assert that a background wake arrived within a time limit?

Because there is no documented number to assert against. Apple documents the frequency parameter as a maximum, at most one wake per period, and publishes no minimum rate or latency figure for iOS observer wakes; Google publishes no scheduling guarantee for deferred work. Any deadline in your test suite is a number you invented, and it will flake. Keep timing out of assertions and move it to the server-side freshness threshold, where the number is an explicit product decision about how stale is too stale.

[Permalink](https://aifitnessapi.com/test/background-sync#faq-5)
