Mock Wearable Data That Is Ugly Enough to Find Bugs
Last verified July 27, 2026 · 13 min read
Covered here:Health ConnectHealthKit
Saturday morning, a user walks for an hour with a watch on one wrist and a phone in their pocket. The watch records 09:02:00 to 10:03:00 by its own clock. The phone records 09:00:00 to 10:00:00 by a clock that is roughly two minutes off. Your reconciliation code adds them together, that user's Saturday reads about double, and nobody files a bug — they just stop believing the number. Your suite is green, because every fixture in it has one source per day, whole-minute boundaries, no gaps, and a 24-hour Tuesday.
The fix is not more tests. It is worse fixtures. Three assertions carry most of the weight, and all three are trivial to write once the input is nasty enough to move them:
- Idempotence. Feeding the same day twice produces the same rollup as feeding it once.
- Coverage, not sum. A day's total may never claim more covered wall-clock time than the union of the intervals that produced it.
- Absence is preserved. Two inputs that contain identical samples but differ in permission state must produce different rollup statuses.
Clean synthetic data cannot fail any of those. That is the entire problem.
The taxonomy#
Nine cases. Each one exists because it breaks a specific piece of code, and the third column is the point of the table.
| Fixture | Shape of the input | The production bug it catches |
|---|---|---|
| Overlapping sources | Two sources covering the same hour, boundaries not aligned, arrival order shuffled | A daily total computed by summing per-source subtotals instead of resolving coverage interval by interval |
| Device clock skew | The second source's timestamps shifted by one to three minutes | Dedupe keyed on exact timestamp equality; interval logic that assumes boundaries line up; a sample that lands on the wrong side of local midnight |
| Retro-edited session | The same logical sleep session re-issued hours later with different bounds and a newer modified time | An upsert keyed on start time that creates a second night; a rollup computed at 06:00 and never recomputed; last-write-wins resolved by arrival order rather than by the provider's edit time |
| Mid-day gap | Samples until 11:00, nothing until 15:00, samples again after | Zero-fill; a daily mean divided by the buckets you happen to have; a coverage percentage derived from the samples present, so it is always 100 |
| Long local day | A local date containing a fall-back DST transition, so the day exceeds 24 hours and one wall-clock time occurs twice | Day boundaries computed as start-of-day plus 86400 seconds; a 24-slot hourly chart that stacks two hours into one; a local-time-to-instant conversion that silently picks one of two valid instants |
| Short local day | A local date containing a spring-forward transition, so the day is under 24 hours and one wall-clock time does not exist | The same boundary arithmetic in the other direction; a conversion that invents an instant for a time that never happened rather than rejecting it |
| Manual beside automatic | A user-typed whole-day value on a date that a device also recorded | Treating a manual entry as just another source and letting priority order or summation decide; interval-wise resolution with nothing to cut on, because the manual entry has no real interval |
| Denied read | An empty payload, twice: once with permission granted, once denied | A zero written for a day nobody measured, a broken streak, and a push notification telling a user their activity dropped |
| Day arrives twice | The whole day re-delivered, once as a webhook replay and once as a backfill window overlapping the incremental cursor | Doubled totals, doubled calories, a personal record set by an artifact |
Four of those deserve a paragraph, because the reason they break things is not obvious from the table.
Clock skew is the one that quietly disables your dedupe. Two devices on the same wrist-and-pocket walk do not agree to the second, and the disagreement is not constant — it drifts, and it resets whenever a device syncs its clock. Any matcher that treats "same start instant and same value" as "same sample" fails on skew, and any interval resolver that requires clean boundaries leaves slivers of double-counted time at every transition. The fixture that catches this is deliberately unaligned. The design that survives it lives in the interval-wise deduplication design; the fixture is what proves you actually implemented it.
The DST pair has to be built from real timezone data, not by hand. Use a real IANA zone identifier and take the transition instant from the tzdb build your application actually ships. Do not write the offsets into the fixture yourself: zone rules change between tzdb releases, and a hand-written offset asserts your own assumption back to you, which is the definition of a test that cannot fail. Build both directions, because they break different code — the long night breaks aggregation and charting, and the short night breaks conversion. Whose midnight defines the day is the design decision; these two fixtures are how you find out whether the decision made it into the rollup.
The denied-read pair is the highest-value fixture on this list and the cheapest to build. Apple documents that a denied read permission is indistinguishable from an empty store, so on iOS your code receives byte-identical input in the two cases that matter most. Google's Health Connect testing documentation carries a matching caution: "You should also have tests to verify correct behavior when the client throws a SecurityException. Users can revoke permissions at any time." The library gives you FakePermissionController(grantAll = false) for exactly this. Two fixtures, identical sample lists, different permission state, and an assertion that the two rollups differ. If they do not, you are writing zeroes for days you never observed, which is the failure missing data and gaps exists to prevent.
The manual entry is a shape, not a value. A typed entry usually has no device, no meaningful interval — an instant, or a span covering the entire day — and a magnitude that cannot be decomposed into sub-intervals. Interval-wise resolution has nothing to cut on, so a manual entry will either be dropped or double-counted depending on which branch your code falls into, and both are wrong in ways that a well-formed fixture will never surface. Carry the recording method as a first-class column, as the canonical record layout does, and assert that the flag survives ingest rather than assuming it.
What this page deliberately does not contain#
Numbers.
We are not publishing typical HRV ranges, resting heart rates, sleep durations, step distributions, or "realistic" variance figures, because we have not measured them. This matters more than it sounds. A distribution invented for a documentation page does not stay on the documentation page — it ends up as the tolerance in somebody's assertion, and then that assertion is measuring a plausible-sounding sentence rather than a population.
A fixture's value lives entirely in its structure: how many sources, arriving in what order, with what overlaps, gaps, offsets, edits and permission states. The magnitudes should come from your own production data. Take a window of real payloads, strip identifiers, and read the shape of your own users, who are the only population your thresholds are meant to describe. If a threshold in your test suite came from a blog post, the test is asserting the blog post. That is our recommendation, and it is judgement rather than a sourced practice — but the alternative is fabricating the one part of a fixture that is genuinely measurable.
One caution on that: a scrubbed payload is still health data. Fixtures get committed to repositories, copied into CI logs, and pasted into issue trackers. Sample structure freely, sample values statistically, and keep raw captures under the same handling rules as production — see storing health data securely.
A generator sketch#
Deterministic, seeded, and small enough to read in one sitting. This is a sketch to adapt, not a suite to adopt.
// fixtures/ugly.ts — one seed in, the same bytes out, forever.
// Every case is named for the bug it catches, not for the data it holds.
export type Sample = {
externalId: string | null; // the provider's id, or null when it gives none
metric: "steps";
startInstant: string; // ISO-8601, always carrying an offset
endInstant: string;
offsetSeconds: number; // as reported by the source, not as recomputed
value: number;
source: string; // provider + device + app, opaque to the test
recordingMethod: "automatic" | "manual";
modifiedAt: string; // the provider's edit time, NOT your ingest time
};
export type Feed = { samples: Sample[]; permission: "granted" | "denied" };
export type Case = {
id: string;
catches: string;
build(seed: number): Feed;
};
// xorshift32: no dependency, byte-identical across runtimes and language ports.
// Math.random() in a fixture generator is a flaky test with a long fuse.
function rng(seed: number): () => number {
let s = seed >>> 0 || 0x9e3779b9;
return () => {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return s / 4294967296;
};
}
The case builders share three helpers — walk(startIso, minutes, source) emits per-minute samples, shift(samples, seconds) moves a source's clock, and shuffle(samples, r) scrambles arrival order, because arrival order is never chronological.
const overlapTwoSources: Case = {
id: "overlap-two-sources",
catches: "summing per-source subtotals instead of resolving per interval",
build(seed) {
const r = rng(seed);
const skewSec = 60 + Math.floor(r() * 120); // 1-3 minutes of drift
const watch = walk("2026-03-14T09:02:00+01:00", 61, "watch:auto");
const phone = shift(walk("2026-03-14T09:00:00+01:00", 60, "phone:auto"), skewSec);
return { samples: shuffle(watch.concat(phone), r), permission: "granted" };
},
};
const deniedRead: Case = {
id: "denied-read-looks-like-no-data",
catches: "writing a zero for a day the user never let you observe",
build() {
return { samples: [], permission: "denied" }; // byte-identical to the
}, // granted-but-empty case
};
export const CASES: Case[] = [
overlapTwoSources, clockSkew, retroEditedSession, midDayGap,
longLocalDay, shortLocalDay, manualBesideAutomatic, deniedRead, dayArrivesTwice,
];
And the harness, where the assertions live:
const SEEDS = [1, 7, 1337]; // pinned. Add a seed; never rotate one.
describe.each(CASES)("$id", (c) => {
test.each(SEEDS)("seed %i", (seed) => {
const feed = c.build(seed);
const day = rollup(feed);
// 1. idempotence: the same feed twice is the same day
expect(rollup({ ...feed, samples: feed.samples.concat(feed.samples) }))
.toEqual(day);
// 2. coverage, not sum: no total may claim more seconds than the union
// of the intervals that produced it
expect(day.coveredSeconds).toBeLessThanOrEqual(unionSeconds(feed.samples));
// 3. absence survives. Build BOTH feeds here and compare them: a one-sided
// check on the denied branch still passes if a bug collapses every
// rollup to "unknown", which is the exact failure it exists to catch.
const granted = rollup({ ...feed, permission: "granted" });
const denied = rollup({ ...feed, permission: "denied" });
expect(denied.status).not.toEqual(granted.status);
});
});
Three properties, no golden files, and every failure reproduces from a case id and a seed. Pin the seeds in source. A generator that draws a fresh seed each run is not thorough, it is a suite that fails one morning on a shape nobody can reproduce, gets marked flaky, and then gets deleted.
Three ways a fixture lies to you#
Three traps specific to this kind of work, and all three read as coverage in a pull request.
Asserting against a fake you programmed. Google documents that in the Health Connect testing library, "aggregation calls don't have fake implementations. Instead, aggregation calls use stubs that you can program to behave in a certain way." A daily-total test run against that stub compares your expectation to your own hand-written AggregationResult. It cannot catch a mistake in what the platform would actually have computed, because the platform is not in the loop. Assert your own resolution logic against your own fixtures instead, and see generating Health Connect test data for how far the platform tooling goes.
A generator written from the normalizer's assumptions. If the same person writes the ingest adapter in the morning and the fixture builder in the afternoon, the fixture will encode the adapter's model of the world and the pair will agree forever. The cheapest countermeasure we know is to derive the case list from incidents and raw captured payloads rather than from the schema.
A tolerance wide enough to swallow the bug. "Within five percent" on a daily total will absorb a four-minute overlap on a one-hour walk without blinking. Prefer exact structural assertions — one session for that night, this many covered seconds, this status — over numeric tolerances, everywhere the quantity is actually discrete.
The honest limits#
Neither platform will generate this data for you, and one of them has stopped trying. Google's Health Connect testing library states plainly that "the library doesn't include APIs to generate fake data yet," and points instead at the library's own internal test source in Android Code Search — a copy-paste workaround, not a public API. How stalled that artifact is, and what its version number and release date mean for anything you plan around it, is the subject of the Health Connect page linked above. Apple ships no HealthKit test double at all. What Apple does give you is constructible data objects: HKQuantitySample has public initializers including init(type:quantity:start:end:metadata:), which is enough to build realistic samples and hand them to a fake behind your own protocol — and what that protocol seam should look like, and the one thing it cannot cover is a page of its own. In both cases the generator is yours to write, and that is the durable part of this page.
You cannot derive the taxonomy from first principles. Every case in the table above exists because something like it happened to somebody. The tenth case is in your production data right now and not in this list. So wire the capture path: when reconciliation produces an implausible day, persist the raw payload window that produced it, scrub it, and promote it to a fixture with its own seed. That loop is the only mechanism we know that keeps a fixture set honest over years, and it is manual by nature — a human decides that a captured day is interesting enough to keep.
Some shapes only a real device produces. A phone that has genuinely been in another timezone for a week, a watch worn intermittently, a device whose clock was wrong and then corrected mid-day. You can approximate all of these in a generator once you have seen them; you will not invent them accurately beforehand. Budget a periodic device pass for discovery, and use the generator for regression.
Write the nine cases, pin three seeds, and assert idempotence, coverage and preserved absence. That is a morning of work and it is the difference between a suite that proves your reconciliation design and a suite that proves your fixtures are tidy.
Frequently asked questions
- Should a fixture generator draw a new random seed on every CI run?
- No. Pin the seeds in source and add new ones rather than rotating them. A generator that reseeds itself each run will eventually produce a shape that breaks your rollup on a Tuesday morning, and nobody will be able to reproduce it. The failure gets labelled flaky, the case gets skipped, and within a quarter the whole suite is deleted. A pinned seed makes every failure reproduce from two values you can paste into a bug report: the case identifier and the seed. Use a small deterministic generator such as xorshift32 rather than the platform random function, so the same seed yields the same bytes across runtimes, language ports and CI images.
- Why won't you publish typical ranges or distributions for synthetic wearable data?
- Because we have not measured them, and an invented range does not stay on the page it was invented on. It becomes the tolerance in somebody's assertion, and from then on the test is measuring a plausible-sounding sentence instead of a population. What a fixture actually needs from us is structure: how many sources overlap, in what arrival order, with what gaps, offsets, edits and permission states. Those are transferable. Magnitudes are not, because they describe your users and not ours. Take a window of real payloads from your own production system, strip identifiers, and read the distribution off that.
- What does a two-minute device clock skew break that a clean fixture never will?
- Three things. Any duplicate matcher that treats an identical start instant and value as the same sample stops matching, so the same walk is stored twice under two sources. Any interval resolver that assumes source boundaries align leaves slivers of double-counted time at every transition between sources. And a sample recorded near midnight can land on the wrong calendar day, which moves it into a different daily total and can break a streak. Skew also drifts and resets when a device syncs its clock, so a fixture with one fixed offset is not enough; vary it per seed.
- Is it safe to keep scrubbed real user payloads as test fixtures?
- Treat them as production data, because that is what they are. Fixtures get committed to repositories, echoed into CI logs, and pasted into issue trackers, and a scrubbed health payload is still a health payload. Our recommendation is to split the two things you want from real data: sample the structure freely, since field presence, arrival order, overlaps and null patterns carry no personal information once identifiers are gone, and sample the magnitudes statistically rather than copying rows. Where you do keep raw captures, keep them under the same handling and retention rules as your production store rather than in the test directory.
- Where do new fixture cases come from once the obvious ones are written?
- From incidents, not from imagination. Every case worth having exists because something like it happened to someone, which means you cannot derive the list from your schema, and a generator written by the same person who wrote the ingest adapter will encode that adapter's assumptions and agree with it forever. The loop that works is a capture path: when reconciliation produces an implausible day, persist the raw payload window that produced it, scrub it, and promote it to a named case with its own seed. It is deliberately manual, because a human has to decide that a captured day is interesting enough to keep.
Keep reading
Elsewhere on the site
Pages that share this one’s concepts and sources, from other sections.
Next steps
Was this page useful?
Independent comparison, last reviewed July 27, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All testing · by AIFitnessAPI