Provider Sandboxes: Build the Fake, Keep One Real Account
Last verified July 27, 2026 · 16 min read
Covered here:Health ConnectHealthKitFitbitGarminWHOOPTerra
Your integration test asks the provider for last Tuesday and asserts that your normalizer produced one row: 8,412 steps, one sleep block from 23:58 to 06:12 local. It passed for three weeks. This morning it is red, and nobody touched the code. Overnight the watch on the test account's wrist re-synced, Tuesday was retro-edited upward, and the sleep block split into two overlapping segments because a second device also had an opinion about that night.
The test logic was right. The assertion was right. The fixture was a living person's body, and a body will not hold still for your suite.
That is the whole argument of this page, and it survives whatever the sandbox table further down says. If you came here searching for a Fitbit sandbox, the answer you want is in the table; the answer you need is that a hosted sandbox, where one exists, is a reality check and never a test fixture. Reality checks run by hand. Fixtures run on every pull request. So take the conclusion first, before the inventory, because the conclusion does not depend on it.
The architecture does not change with the answer#
Suppose every provider shipped a beautiful hosted sandbox tomorrow. You would still not point continuous integration at it, and here is why — four reasons, all of them specific to health data rather than to APIs in general.
The data is a person. The opening failure is not a hypothetical; it is the normal behavior of wearable data. Days get retro-edited when a device syncs late, sleep gets re-staged hours after you woke up, and a second device joins the account and starts contributing overlapping intervals. Any assertion on a real account's values has a shelf life measured in days. A sandbox with synthetic data avoids this — but only if the synthetic data is static, at which point it is a fixture with extra latency and a network dependency.
Token state is shared, and refreshing is destructive. RFC 6749 makes both rotation and old-token revocation a MAY, so you have to tolerate providers that do either. Where a provider does rotate, the OAuth Security BCP describes what rotation is for: the old refresh token is invalidated, and "if a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token, which will inform the authorization server of the breach... it will revoke the active refresh token." That passage is quoted from the working group's editing repository rather than a published RFC, so treat the section numbering and exact wording as provisional. Two CI jobs running in parallel against one shared account are, from the provider's side, indistinguishable from that attack. The cost is not a red build: it is a human re-authorizing a real wearable account through a browser before anything can go green again.
Quota is shared, and the test that eats it is the one you most want to run. The scenario that exercises pagination, cursoring, partial failure and resume is a historical backfill, which is also the only scenario that reliably reaches a provider's ceiling. Run it in CI on every pull request against one account and you will spend the day rate-limited, with an unrelated test failing because a colleague merged first.
A real account is real health data. It ends up in CI logs, in failed-assertion diffs, in test artifacts retained for ninety days, in the screenshot someone pastes into a ticket. If your organization has done any work on storing health data securely, a shared CI runner is a strange place to have quietly exempted.
So: the fake carries everything that runs automatically, and a single real account per provider exists only to be checked by hand, deliberately, on a cadence you set. That is the default. The table below changes which providers you can also reality-check cheaply; it does not change the shape.
What we could confirm, and what we could not#
We check claims by fetching primary documentation rather than recalling it. Our research environment reaches the internet through an allowlist proxy, and on 2026-07-30 almost every fitness-provider documentation host was refused at the CONNECT tunnel before any request touched the origin. A refusal at our proxy is a fact about our network. It is not evidence about the provider.
So this table has three statuses, and seven of its nine rows are the unhelpful one. We are publishing it anyway, because a table that says "we could not confirm this" is more useful than a confident table that is wrong, and because the shape of it tells you something real: this information is genuinely hard to get, and if you find a blog post that states all nine rows with confidence, ask how they checked.
| Provider | Status, checked 2026-07-30 | What we actually checked |
|---|---|---|
| Fitbit | COULD-NOT-VERIFY | dev.fitbit.com, api.fitbit.com and www.fitbit.com were all refused by our proxy. No fallback source available. We make no claim about whether a sandbox, test account or personal token exists. |
| Strava | COULD-NOT-VERIFY | Docs host refused. Fallback checked: the official strava GitHub organization's public repository list contains no API-docs or sandbox repo. Absence there is not evidence either way. |
| Garmin | COULD-NOT-VERIFY | Four Garmin hosts refused. In particular we could not check whether the Health API gates access behind a partner program instead of offering a sandbox. |
| Oura | COULD-NOT-VERIFY | Docs hosts refused. Fallback checked: the ouraring GitHub organization has no public repositories, so no official mirror existed to read. |
| WHOOP | COULD-NOT-VERIFY | Docs host refused. Fallback checked: github.com/whoop is an unrelated personal account, not a WHOOP-corporate organization. |
| Terra | COULD-NOT-VERIFY | Docs hosts refused. Fallback checked: Terra's published Python client reference contains zero occurrences of "sandbox", "testing", "test user" or "mock". That is weak negative evidence about one SDK's surface and says nothing about what the dashboard offers. |
| Google Fit REST API | COULD-NOT-VERIFY | developers.google.com and health.google both refused. |
| Android Health Connect | CONFIRMED-EXISTS — but a device tool, not a hosted sandbox | Google documents the Health Connect Toolbox as "a companion developer tool to help you test your app's integration with Health Connect. It can read and write data directly to Health Connect", shipped as an APK you install with adb install. Source: Google's Health Connect Toolbox documentation |
| Apple HealthKit | CONFIRMED-ABSENT — there is no server API to sandbox | Setup is entirely device and Xcode side: enable the capability, check isHealthDataAvailable(), instantiate an HKHealthStore. Apple does publish one narrowly-scoped simulator page, Accessing Sample Data in the Simulator, but it covers clinical health records only — three sample accounts added by hand through the Health app, with Apple stating you cannot create your own samples of that type. There is no general sandbox and no way to seed ordinary workout or quantity data. Source: Apple's Setting up HealthKit guide |
Two honest observations about that table before you use it.
First, the two rows we could answer are the two platforms with no cloud API at all. Apple and Google ship a local test affordance precisely because there is no remote service anyone could stand a sandbox up in front of. Do not generalize from those two rows to the six cloud providers. They are answerable for a structural reason that does not apply to Fitbit or WHOOP.
Second, every COULD-NOT-VERIFY row is a job for you, not a conclusion. Open the provider's current developer documentation, find the answer, and write the date next to it in your own repository — because whatever you find will be true for a while and then quietly stop being true. Our per-provider integration guides cover credentials and scopes for each one; this page picks up after you have a working token.
You will also notice that this page states no sandbox URL, no token lifetime and no rate-limit number. That is deliberate. We verified none of them in this round, and a fabricated number in a testing page is worse than a gap, because you would build a backoff assertion on it. Read the current number off the provider's own documentation, and off the response headers when it sends them.
What runs where#
Three layers, and only the middle one is usually missing.
The local fake runs on every pull request, offline and deterministic, and it is what proves your parsing, your day-boundary math, your dedupe, your backoff, your token state machine and your webhook handler.
Recorded fixtures captured from real responses are the fake's payload corpus, and they are the cheapest thing on this page. Record real responses once, scrub them, and commit them as the fake's defaults. A fixture you invented tests your imagination; a fixture the provider actually sent tests your parser — including against the field you would never have thought to type. Record-replay tooling for this is mature and domain-agnostic — VCR, WebMock, vcrpy, RESPX, MSW, WireMock — pick the one native to your stack and move on. There is nothing health-specific about the recording mechanism, and everything health-specific about what you record.
One real staging account per provider runs by hand, on a named cadence, before a release, and proves exactly one thing: that the fake and the fixtures still resemble the provider.
What belongs in the fake#
This is where the page earns its keep, because the fake is the only artifact here that does not go stale when a provider redesigns its developer portal.
The happy path is worth almost nothing. The value of the fake is entirely in its switches, and every switch below corresponds to a failure that has a health-data shape:
// The fake is a real HTTP server your tests point at.
// Its worth is in the switches, not in the 200 OK.
type ProviderFake = {
// --- token lifecycle ---
rotateRefreshTokens: boolean; // provider-dependent: test both settings
refreshFailsWith?: "invalid_grant"; // one code, at least six causes
revokeAlwaysReturns: 200; // RFC 7009 requires 200 even for junk
// --- the day it serves back ---
day: (localDate: string) => DayPayload;
// --- transport and protocol faults, per route ---
faults: Partial<Record<Route, Fault>>;
// --- outbound ---
emitWebhook: (event: Event, opts: { duplicate?: boolean; delayMs?: number }) => void;
};
type Fault =
| { kind: "429"; retryAfter?: string } // omit it sometimes: RFC 6585 says MAY
| { kind: "503"; retryAfter?: string }
| { kind: "truncated" }
| { kind: "htmlErrorPage" } // the CDN answered, not the API
| { kind: "slow"; ms: number };
Taking those in turn.
Token lifecycle. rotateRefreshTokens must be a switch and not a constant, because RFC 6749 leaves rotation optional and your integration set will contain both kinds. Turn it on and your concurrent-refresh bug appears immediately, in a test, instead of appearing in production as a de-authorized user. refreshFailsWith: "invalid_grant" matters because RFC 6749 gives that one error code at least six possible causes, so your code cannot distinguish a revoked consent from clock skew from a replayed rotated token. Assert your own state transition rather than a diagnosis; testing OAuth flows works through why.
Revocation returns 200 no matter what. RFC 7009 requires a 200 even when the client submits a token the server has never heard of, so revokeAlwaysReturns: 200 in the fake is not a convenience — it is the spec, and it means any test asserting revoke() == 200 cannot fail. It asserts that you sent a well-formed POST. What you assert instead, and the propagation window that complicates it, is worked through on testing that a user's data is really gone.
Ugly days, not clean ones. The day function is where health-specific testing lives, and its output should be adversarial by default: a day with two overlapping sleep segments from two devices, a day that gets retro-edited between two reads, a night that crosses a DST transition, a day with a two-hour gap in the middle, a device whose clock is two minutes off, and a day that arrives with a timezone the user was not in. Clean synthetic sine waves are precisely why reconciliation bugs reach production. Building adversarial wearable fixtures is the page on how to generate those, and day boundaries and timezones is the design they are attacking.
Faults, including the ones you would not think to write. 429 is defined in RFC 6585 §4, not in RFC 9110, and RFC 6585 says a 429 response "MAY include a Retry-After header". May. So retryAfter in the fake has to be optional, and one of your cases must be a 429 with no Retry-After at all — otherwise a provider that omits it will busy-loop you. RFC 9110 also allows Retry-After to be an HTTP-date rather than a delay in seconds, so a parser that only handles integers is non-compliant and you should have a fixture that proves yours is not. For transport-level nastiness, WireMock's Fault enum and Toxiproxy between them cover everything from a reset connection to a socket that closes halfway through a JSON object; wire them in and spend your thinking on what your ingest does with the half. Testing rate limits and outages is where those cases live.
Outbound webhooks. If the provider pushes to you, the fake should push too, including a duplicate delivery and an out-of-order pair, because in health data a doubly-processed event silently doubles a user's day rather than throwing. See replaying signed webhook payloads locally.
The one real staging account, and the contract test that uses it#
The fake's failure mode is obvious and fatal: it is a model of the provider written by someone who is not the provider, and it drifts. A fake that has never been checked against reality will happily give you a green suite while the provider has renamed a field, added an activity-type enum your switch falls through on, or started returning sleep stages in a different unit.
One real staging account per provider fixes this, and one is enough. Its entire job is to answer a single question on a schedule you control: does the fake still resemble the thing it is faking?
// Manual. Tagged so CI skips it. Needs a real token and a network.
test.manual("provider contract: daily summary", async () => {
const window = { start: "2026-07-20", end: "2026-07-21" };
const live = await realClient.dailySummary(window); // one call, one account
const fake = await fakeClient.dailySummary(window);
// 1. Compare SHAPES, never values. The values are a person and they move.
expect(shapeOf(normalize(fake))).toEqual(shapeOf(normalize(live)));
// 2. The real detector: fields the provider sent that our schema
// has never heard of. This is how a silent API change reaches you.
expect(unknownFields(live, fixtureSchema)).toEqual([]);
// 3. Enum values outside the set our parser branches on.
expect(unseenEnums(live, fixtureSchema)).toEqual([]);
});
Assertions two and three are the point. Assertion one is a weak check that mostly guards against gross restructuring. The unknown-field and unseen-enum checks are what actually catch provider drift, and they are cheap to implement because you already have a schema for the fixtures.
Four rules for running it, from our own experience keeping these alive:
- It is allowed to fail without blocking anyone. It is non-deterministic by construction: a network, a real account, a provider deploy. Wired into a merge gate it will be disabled within a month and then it protects nothing.
- Commit the date of the last successful run. A fake's authority is exactly as old as its last contract check. When someone asks "can we trust this fixture", the answer is a date in the repository, not a shrug.
- Run it on a named trigger, not on a vibe. Before a release, and whenever a provider emails about an API version. Quarterly is a floor, not a target.
- Do not assert on the provider's status code alone. A contract test whose strongest assertion is that the live call returned 200 is another test that cannot fail.
That last one bites hardest if your contract pass includes revocation, for the reason above: the revocation response tells you nothing, so you have to make a subsequent data call and assert that it fails with invalid_token and a 401. RFC 7009 also allows a propagation delay between the servers that know about the invalidation and the ones that do not, so that follow-up needs a bounded retry window — which is one more reason it belongs in the manual pass rather than in CI, where the flake would get it deleted.
What one staging account will never show you#
Be clear about the ceiling, because it is low. One account is one body, in one timezone, with one device mix, one locale, one account age and one history depth. It cannot produce:
- a user whose local midnight is not yours, which is where day-boundary bugs live;
- two devices contributing overlapping samples to the same account, which is where dedupe bugs live;
- a user with four years of history, which is where your backfill's pagination and quota budgeting live — a fresh test account is the one case that never hits the ceiling;
- a user who revokes consent halfway through a sync;
- a locale where the provider returns a different date format or unit.
All of that variation belongs in the fake, and none of it belongs in the staging account. The staging account is a calibration instrument. The fake is the test environment. Confusing the two produces either a slow flaky suite or a fake nobody trusts, and usually both.
The limits of this page#
Stated plainly, because this is the page in our testing cluster most likely to age badly:
- The table is dated 2026-07-30 and is mostly unverified. We reached two of nine provider documentation sources. Every COULD-NOT-VERIFY means our environment could not reach the host, not that the provider lacks a sandbox. Check the provider's own current documentation before you plan around any row.
- Provider test surfaces change. Partner programs open and close, API versions deprecate, portals get rebuilt. Whatever you confirm today, record the date beside it.
- We publish no numbers here on purpose. No sandbox host, no token lifetime, no request quota. We verified none of them this round, and in a testing page an invented number becomes an assertion in someone's retry loop.
- The architecture section is engineering judgement, and we are labeling it as such. No provider publishes guidance on fixtures versus live calls, and no specification addresses it. The local-fake-plus-one-staging-account split is our recommendation from building these integrations, not a documented practice.
The recommendation is nevertheless the same whatever you discover in the provider docs, which is why it is at the top of this page rather than in the table. Get the token working with the per-provider integration guides linked above, then build the fake, then keep one real account around to prove the fake is still telling the truth.
Frequently asked questions
- Should I wait for a provider sandbox before writing integration tests?
- No, and the answer does not depend on whether one exists. Even a perfect hosted sandbox is the wrong target for continuous integration: its data is a living person's body and retro-edits itself under your assertions, its token state is shared so two parallel CI jobs look like a stolen-refresh-token attack, its quota is shared and the backfill test is the one that eats it, and its responses are real health data landing in your CI logs. Build the local fake now and keep one real account for a manual reality check. On Fitbit specifically: we could not confirm a sandbox either way on 2026-07-30, because every Fitbit developer host was refused by our outbound proxy, which is a fact about our network rather than about Fitbit. Check their current documentation and write the date beside whatever you find.
- Why did a test that passed for three weeks against a real provider account suddenly go red?
- Almost certainly because the data moved, not because your code did. Wearable days get retro-edited when a device syncs late, sleep gets re-staged hours after the fact, and a second device joining the account starts contributing overlapping intervals. Any assertion on a live account's values has a shelf life of days. Assert on values only against fixtures you control, and use the real account only to check that the fixtures still resemble the provider.
- How often should the contract test against the real provider run?
- On a named trigger rather than a cadence you will forget: before a release, and whenever the provider announces an API version change. Quarterly is a floor. Two rules make it survive: it must be allowed to fail without blocking a merge, because it is non-deterministic by construction, and the date of its last successful run must be committed to the repository. A fake's authority is exactly as old as its last contract check.
- Can one real staging account replace the fake?
- No, and the ceiling is low. One account is one body, one timezone, one device mix and one history depth. It cannot give you a user whose local midnight differs from yours, two devices contributing overlapping samples, four years of history to exercise backfill pagination and quota, or a user who revokes consent mid-sync. That variation belongs in the fake. The staging account is a calibration instrument, not a test environment.
- What should a fake return that a provider's own sandbox probably would not?
- Ugly days and rude faults. Two overlapping sleep segments from two devices, a day retro-edited between two reads, a night crossing a DST transition, a mid-day gap, a device clock two minutes off. On the fault side: a 429 with no Retry-After header at all, since RFC 6585 makes that header a MAY rather than a MUST, a Retry-After expressed as an HTTP-date rather than seconds, a truncated body, and an HTML error page from a CDN instead of JSON. A vendor sandbox is built to demonstrate the happy path; your fake exists to attack it.
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