Skip to content
AF
Testing

Testing That a User's Health Data Is Actually Deleted

Last verified July 27, 2026 · 13 min read

Write one assertion per store, with the store list enumerated from a registry checked into the repo, so the suite fails the day someone adds a table and forgets to purge it. The constraint that shapes every assertion here is RFC 7009: a token revocation endpoint returns HTTP 200 even for an invalid token, so a test that asserts revocation returned 200 cannot fail and proves nothing. Assert on the observable consequence instead — the next provider call fails, no new samples arrive after the tombstone, the connection row is gone. Never assert on the purge job's return value; assert on the stores it was supposed to empty.

Covered here:Health Connect

Run your deletion path against user 4821, wait for it to report success, then run one line:

select count(*) from daily_step_total where user_id = '4821';

In our experience that comes back non-zero the first time, and not because the purge job is buggy. The raw sample table is empty and the delete reported success. Every daily total, sleep score and resting heart rate the user was actually shown is still sitting there — which is to say the only numbers they would recognise as theirs are the ones that survived.

That single count(*) is worth more than the paragraph in your privacy notice that says the data is gone. Erasure is unusual among backend behaviours in that it is fully assertable: a test can fail when a rollup table, a cache, a dead-letter queue, a log line or an analytics event still holds the row. The obligation side belongs elsewhere — health data retention and deletion owns how long you may keep what, and GDPR for fitness apps owns the legal question. Neither is restated here, and nothing on this page should be read as a claim that a passing test satisfies a regulator. The pipeline this proves is designed in deleting and exporting a user's health data; this page is only about making the proof capable of failing.

Start with the assertion that cannot fail#

Almost every deletion suite we have seen contains a version of this:

const res = await providerClient.revokeToken(connection.refreshToken);
expect(res.status).toBe(200); // proves nothing

RFC 7009, which defines the OAuth token revocation endpoint, says the authorization server "responds with HTTP status code 200 if the token has been revoked successfully or if the client submitted an invalid token", and explains that "invalid tokens do not cause an error response since the client cannot handle such an error in a reasonable way." It adds that "the content of the response body is ignored by the client as all necessary information is conveyed in the response code."

So a conforming provider returns 200 for a token you invented, a token that expired last year, and a token you already revoked twice. That assertion cannot fail. It will pass against a system where revocation is entirely broken, and it will sit in the test report looking exactly like coverage.

This is the general rule the rest of the page applies: an assertion that cannot fail is worse than no assertion, because no assertion is honest about the gap. The same trap shows up three more times in a health app's deletion suite:

  • Asserting that your purge function returned true, or that rowsAffected was greater than zero. Both are properties of your code, not of the store.
  • Asserting on a mock you wrote — expect(mockIndex.delete).toHaveBeenCalledWith(userId) passes whether or not the real index ever applied it.
  • Asserting that an aggregate dropped to zero after deletion when the aggregate came from a stub. 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." If you program fake.overrides.aggregate and then assert on the value that comes back, you are asserting on your own arithmetic. (More on that library, including that it is still 1.0.0-alpha03, dated April 9 2025, on testing with Health Connect test data.)

The fix in every case is the same: assert on state you did not produce, read back through a path the code under test does not control.

Enumerate the stores from a registry, not from memory#

A hand-written list of stores is correct on the day it is written. The long-term value of a deletion test is that it catches the next engineer — the one who adds a sleep_stage_rollup table on a Thursday and never thinks about erasure.

So do not write the list. Discover it, and diff it against a registry file checked into the repo:

-- every relational store that can hold a user, discovered not remembered
select table_schema || '.' || table_name as store
from information_schema.columns
where column_name in ('user_id', 'account_id', 'provider_user_id', 'external_uuid')
  and table_schema not in ('pg_catalog', 'information_schema');
test("every store that can hold user data is declared", async () => {
  const declared = new Set(registry.map((s) => s.id));
  const discovered = [
    ...(await tablesWithUserColumn()).map((t) => `pg:${t}`),
    ...(await listQueueTopics()).map((t) => `topic:${t}`),
    ...(await listSearchIndexes()).map((i) => `index:${i}`),
    ...(await listBucketPrefixes()).map((p) => `blob:${p}`),
    ...(await listCacheKeyspaces()).map((c) => `cache:${c}`),
  ];
  expect(discovered.filter((id) => !declared.has(id))).toEqual([]);
});

Two details make this earn its keep. First, include provider_user_id and your platform identifier columns in the discovery query, because the tables that link your user to a Fitbit or Oura account are personal data even when they hold no health values, and they are the ones nobody thinks of as user tables. Second, give every registry entry a probe(userId) function that returns what the store still holds. The registry test forces whoever adds a store to write its probe while they still remember the schema, and the same probes drive the sweep:

for (const store of registry.filter((s) => s.disposition === "purge")) {
  test(`${store.id} holds nothing for a deleted user`, async () => {
    expect(await store.probe(deletedUserId)).toEqual([]);
  });
}

Entries whose disposition is expire (backups, logs) or retain are excluded from the sweep by declaration, in the file, with a reason — which means the exclusions are reviewable in a diff instead of invisible in a test that never looked.

The assertion, per store type#

The registry gets you the list. Each store type needs its own idea of what "still holds the user" means, and for several of them the obvious assertion is the one that cannot fail.

StoreAssert thisNot this
Raw sample rowsZero rows on a fresh connection, after the job commitsThe job's return value, or rowsAffected
Daily rollups, continuous aggregatesQuery the materialized object directly and assert zero before anything triggers a refreshThat the raw table is empty and the aggregate must have followed
Per-user cachesCall the app's own summary endpoint and assert empty or not-foundA key-prefix scan alone — the layer you scan may not be the layer the request path reads
In-flight queue messagesEnqueue a provider payload for the user before the tombstone, consume it after, assert no rows and no re-created userThat the queue is drained at the end of the test; it always is
Dead-letter queueThe same replay from the DLQ — and, first, that DLQ entries can be selected by user at allThat the DLQ is empty
Search indexPoll a read to a bounded timeout, assert not-foundThe delete call's status code; index deletes are frequently asynchronous
Logs and tracesSend a provider payload carrying a sentinel heart-rate value through the real logging path, then assert the sink never received the sentinelAbsence after deletion — you cannot retroactively purge an append-only sink
Product analyticsThat the forwarder drops events for a tombstoned user, and that the vendor deletion call is recorded per requestThat you called the vendor SDK on a mock
Embeddings, features, model inputsThat the user is absent from the query that builds the next training snapshotThat the vector delete returned OK
Records you wrote into HealthKit or Health ConnectThat your code issues the platform delete with the identifiers you stored at ingestThat a faked aggregate went to zero

Why each of those stores is easy to miss is set out in the deletion pipeline design linked at the top. Four of the rows need a word about the test rather than about the store.

The rollup needs its own probe. Never infer it from an empty raw table. Query the materialized object directly, and do it before anything triggers a refresh, because a refresh between the purge and the assertion turns a real failure green.

The queue test has to be ordered, not merely present. The assertion is about interleaving, so the test has to control the interleaving explicitly: enqueue before the tombstone, consume after. The dead-letter queue is the same test from a worse starting position, and if probe(userId) cannot be written against it at all, that is the finding — fix the DLQ schema rather than deleting the test. Replay mechanics for signed provider payloads live in testing webhooks locally.

The log assertion runs at write time, not at delete time. Absence-after-deletion is unassertable against an append-only sink. The only version that can fail is a redaction test: push a payload carrying a bpm value that appears nowhere else in your system through the real logging path, then grep the sink for that value.

The platform row asserts your call, and nothing more. What the test really validates is that you kept the identifier mapping at ingest, so it fails the day someone drops the id column. It is not evidence that anything on the device is clean, and it should not be written as though it were.

Revocation: assert the consequence#

Back to the RFC 7009 problem, because deleting locally while leaving the grant live is the most-skipped step in erasure and the one that quietly refills everything you just purged.

There are three assertions available, and each can actually fail.

In CI, against a local fake, assert ordering. Revocation must be attempted before the first destructive purge task, and a webhook delivered after the tombstone must create nothing:

test("a payload delivered after the tombstone creates nothing", async () => {
  await ingest.enqueue(fixtures.fitbitDailySummary(userId));  // pre-tombstone
  await deleteUser(userId);
  await ingest.drain();                                        // consumed after
  expect(await db.userExists(userId)).toBe(false);
  // toHaveBeenCalledBefore comes from jest-extended, not core Jest. Without
  // that dependency, record call order yourself and assert on the indices.
  expect(revokeSpy).toHaveBeenCalledBefore(firstPurgeTaskSpy);
});

Against one real staging account per provider, on a slower cadence, assert the consequence. After revocation, an authenticated call to that provider fails, and nothing new arrives in a fixed observation window. Two caveats keep this from being flaky in a way that teaches people to ignore it. RFC 7009 acknowledges a propagation window "in which some servers know about the invalidation while others do not", so poll to a bounded timeout instead of asserting immediate failure. And RFC 6749 gives invalid_grant at least six possible causes under one error code, so assert your own state transition rather than the provider's discrimination between them — testing OAuth flows works through why, and owns the token lifecycle generally.

Assert both tokens, separately. RFC 7009 makes the cascade a SHOULD and a MAY: revoking a refresh token should invalidate access tokens from the same grant, and revoking an access token may revoke the refresh token. Neither is guaranteed, so a test that revokes one and assumes the other died is asserting the provider's optional behaviour.

One sourcing note, dated 2026-07-30: our research could not reach a single fitness provider's developer documentation host in that session, and RFC 7009 puts endpoint location out of scope, so we publish no provider revocation URLs here. Read the endpoint out of the provider's own current docs, and see provider sandboxes and test environments for what test credentials are actually available.

Backups, and where the ladder stops#

Nobody surgically edits a user out of a snapshot, and a test that claims to verify it is testing a lie. What you can assert is the design that replaces it: a documented, bounded retention window per backup class, plus deletion-on-restore.

The cheap half is a registry check: every entry whose disposition is expire carries a stated window and a named owner. It costs nothing and it fails the day somebody adds a warehouse copy with no expiry, which is exactly how a two-year-old snapshot of a deleted user's sleep data ends up inside a BI tool nobody associates with health records.

The real one is the restore drill, and it is the second-best idea on this page after the opening count(*). Restore a snapshot into a scratch environment. Then, as the first runbook step and before anything else touches the restored copy, replay the tombstone log — every erasure recorded since that snapshot was taken. Then point your existing per-store sweep at the restored environment, unmodified, and assert zero for every one of those users.

Three things make that afternoon worth spending. It is the only rung on this ladder that tests the runbook rather than the code, and deletion-on-restore is a runbook step by nature — there is no unit test shaped like it. It is the step that rots: restores are rare, so the ordering error where somebody brings the database up and reconnects traffic before replaying tombstones is silent, permanent, and discovered by a user who was erased last spring and just got a weekly summary email. And it is nearly free to build, because it reuses the probes you already wrote — the marginal cost is the restore, not the assertions.

Run it alongside your disaster-recovery drill rather than as its own ceremony, and record the date it last passed next to the date your provider contract test last passed. Both numbers are statements about how old your evidence is, and both belong in the repository rather than in somebody's memory.

What no test here will tell you#

Three honest limits, because pretending otherwise is the same defect as the 200.

You cannot assert on someone else's database. Not the wearable provider's, not your analytics vendor's, not your model provider's. The best available evidence is that you called their documented deletion mechanism and recorded the response and timestamp per request. That is a record. No amount of test engineering turns it into proof, and a test that mocks the vendor and asserts the mock is the cannot-fail pattern wearing a different hat.

The registry test only sees what introspection reaches. A SaaS tool someone connected through a no-code integration, a spreadsheet export, a support tool with its own copy of a user's workout history — none of those appear in information_schema. That gap closes with a periodic human review of data egress, not with a better query, and it should be written down as a review rather than assumed away.

A green suite is engineering evidence, not a legal position. It tells you that on this commit, for a user with this shape of data, every store you know about was empty afterwards. That is a genuinely strong claim and far more than a policy document supports. It is not a compliance conclusion, and the pages linked at the top own that question.

The point of all of it is narrow and worth restating: deletion is the one pipeline whose success is indistinguishable from never having run. Write assertions that can tell the difference, generate the list of them from a file that breaks the build when it goes stale, and delete any assertion you cannot imagine failing.

Next, on the same seam: testing offline sync and conflict resolution for the other scenario that only reproduces under controlled interleaving, and testing rate limits and outages for injecting the faults a purge job hits mid-run.

Frequently asked questions

Why does asserting a 200 from a token revocation endpoint prove nothing?
Because RFC 7009 requires it. The specification says the authorization server responds with HTTP status code 200 if the token has been revoked successfully or if the client submitted an invalid token, and explains that invalid tokens do not cause an error response since the client cannot handle such an error in a reasonable way. A conforming provider therefore returns 200 for a token you made up, a token that expired last year, and a token you never sent. An assertion on that status code is asserting that you sent a well-formed POST over HTTPS. It cannot fail on a system where revocation is completely broken, which makes it worse than having no assertion at all, because it reads as coverage in a test report.
How do I make a deletion test fail when someone adds a new store and forgets to purge it?
Do not hand-write the store list in the test. Keep a registry file in the repo with one entry per store, then write a test that discovers stores by introspection and fails on anything undeclared: every table carrying a user id or provider account id from the information schema, every queue topic, every search index, every object-storage prefix, every cache keyspace. The diff between discovered and declared is the assertion. Adding a table without a registry entry then breaks the build on the branch that added it, which is the only point at which the person who knows what the table holds is still looking at it. Give each registry entry a probe function that answers whether the store still holds a given user, and the same registry drives both the completeness check and the per-store sweep.
What is the right assertion for a store whose delete is asynchronous, like a search index?
Poll a read until a bounded timeout, and assert on the read result rather than on the delete call's status. Index deletions are frequently applied asynchronously, so a purge that returns success can still serve a hit for a while, and a test that checks only the return code will pass against an index that never applied the change. The same shape applies to any store with an acknowledged propagation window, including provider token revocation, where RFC 7009 explicitly acknowledges a delay in which some servers know about the invalidation while others do not. Bound the wait, fail on timeout, and record the observed convergence time so a regression that stretches it becomes visible instead of merely slow.
What does a restore drill have to check before backup erasure is more than a claim?
That a previously deleted user is still absent after the restore completes. Nobody surgically edits a snapshot, so the honest design is a documented retention window per backup class plus deletion-on-restore, and deletion-on-restore is the step that rots because restores are rare. The drill assertion is concrete: restore a snapshot into a scratch environment, run the tombstone replay as the first runbook step, then run the same per-store sweep the deletion suite uses against the restored copy and assert zero rows for every tombstone recorded since the snapshot was taken. A drill that only verifies the database came back up does not test the part of erasure that involves backups at all.
What can only a real provider account prove about a revoked wearable grant?
That the grant is genuinely dead upstream, which is the single thing a fake cannot tell you, because a fake returns whatever you programmed into it. Keep one real staging account per provider, run it on a slow cadence, and assert the consequence: after revocation an authenticated call to that provider fails, and nothing new arrives for that user inside a fixed observation window. Poll to a bounded timeout rather than asserting immediate failure, because RFC 7009 acknowledges that some of a provider's servers learn about an invalidation before others do. Everything else stays in CI against the local fake, where you can assert the part that actually breaks: that revocation is attempted before the first destructive purge task, and that a payload delivered after the tombstone creates no rows and no resurrected user. Do not point CI at a live provider, and do not let a fake be the only thing you ever revoke against.

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