Writing Integration Tests for Passkey Flows

Once a virtual authenticator is wired up, the question becomes which scenarios to encode. This page covers seven that each map to a class of real production incident, the shape a good test takes, and the line between what belongs in a suite and what belongs on a release checklist. It sits under Passkey Testing and Conformance; the harness itself is covered in testing with the Chrome virtual authenticator.


Scenario Reference

Scenario Asserts Catches
register, then authenticate a session is issued the whole round trip
second registration, same device refusal handled as an outcome exclude-list construction
revoke, then re-enrol registration succeeds revoked rows left in the list
ceremony refused page returns to idle, both paths available unrecoverable UI states
no matching credential generic failure, no distinguishing response enumeration leaks
ceremony abandoned timeout path and challenge cleanup hung interfaces
scripts disabled password sign-in still works a broken fallback
Seven scenarios worth automating Each one covers a distinct failure that has reached production in real deployments. Seven tests; each maps to a real production incident class register then authenticate the round trip works at all duplicate enrolment refused exclude list scoping and error handling re-enrol after revocation revoked rows leaving the exclude list ceremony refused the page recovers to a usable idle state no matching credential no attempt to distinguish it from a cancellation abandoned ceremony the timeout path and challenge cleanup scripts disabled the password fallback still signs a user in

Root Cause Analysis

1. Tests that assert only “no exception”. A ceremony that fails and a page that silently does nothing look identical to an assertion that merely checks nothing threw. Assert the resulting state.

2. Tests that bypass the page. Calling the credential API directly from test code skips the part where your application wires it up, which is where the defects are. Drive the real sign-in page.

3. Tests that share state. A credential created in one test and left attached changes the meaning of the next. Configure and tear down per test.

4. Tests that never assert on the server. The outcome the user sees and the reason your server recorded are different facts, and only the second tells you the test passed for the right reason.

5. Trying to automate the unautomatable. Cross-device flows and real biometrics cannot be driven, and attempts to approximate them produce flaky tests that get disabled and then deleted.

The shape of one integration test Arrange the device, run the ceremony through the real page, assert both the outcome and the recorded reason. Four assertions per scenario, not one configure the virtual authenticator drive the real sign-in page assert outcome session issued or refused assert reason the server-side code recorded Asserting the reason code as well as the outcome is what stops a test passing for the wrong reason after a refactor.

Step-by-Step Resolution

Step 1 — start from a seeded account, not from a clean database

Each test needs a known account with a known credential state. Seeding directly is faster and less brittle than driving a registration flow to set up an authentication test.

Step 2 — drive the real page

Navigate to your actual sign-in surface, click your actual button, and let your actual client code call the API. Anything less tests a smaller system than the one you ship.

Step 3 — assert the outcome and the reason

The session cookie or the error state the user sees, plus the reason code your verification path recorded. Two assertions, and the second is the one that survives a refactor.

Step 4 — assert recoverability after every failure

For each failing scenario, assert that the page is usable again — button clickable, password form present — without a reload.

Step 5 — keep one no-JavaScript test

Load the page with scripting disabled and complete a password sign-in. One test, and it guards the population most at risk from a passkey rollout.

Integration test versus end-to-end test One drives a browser against your stack; the other involves a person and does not belong in a suite. Automate the first exhaustively; keep the second short integration — automate virtual authenticator real page, real server runs in seconds deterministic end-to-end — checklist real device, real biometric cross-device and Bluetooth minutes, and needs a human not deterministic A team trying to automate the right-hand column ends up with a flaky suite and no coverage of the left.

Verification and Testing

The suite itself needs a sanity check: run it twice in a row and confirm identical results. A suite that passes on a clean run and fails on a repeat has state leaking between tests, almost always an authenticator that was not removed.

Deliberately break something and confirm the right test fails. Remove the exclude list from your options and the duplicate-enrolment test should fail; loosen the origin check and a verification test should fail. A suite where breaking the code does not break a test is a suite measuring nothing.

Watch the runtime. These tests should complete in seconds. A suite that takes minutes usually contains a real timeout being waited out, which means a ceremony option needs a shorter window rather than the test needing more patience.


Pitfalls

1. Driving registration to set up an authentication test. Slow, brittle, and it makes an authentication failure look like a registration bug.

2. Snapshot-testing the options payload. The challenge changes every time; the assertion should be on structure, not on bytes.

3. Disabling a flaky test rather than fixing the isolation. The flakiness is almost always shared state, and it is a two-line fix.


Frequently Asked Questions

Should integration tests hit a real database?

Yes, for anything touching credential storage. The encoding boundaries between your code and your column types are exactly where the subtle bugs live, and an in-memory substitute has different behaviour precisely there.

How do I seed a credential without running a ceremony?

Capture one registration from a virtual authenticator, store the resulting row as a fixture, and insert it during setup. The credential is real, it verifies, and the setup takes milliseconds instead of a full ceremony.

Do I need a test per browser?

Not for the logic. The ceremony behaviour that differs between engines is mostly around conditional mediation and the platform surface, which is worth a smaller matrix on one additional engine rather than duplicating the whole suite.

What about testing the conditional autofill flow?

Partially automatable: you can assert that the ceremony is armed, that the annotation is present on the live input, and that aborting it for a modal ceremony works. What you cannot drive is the platform’s autofill dropdown, so the final selection step stays manual.

Should these run against a deployed environment or locally?

Locally in continuous integration, plus a small subset against a deployed environment after each release. The deployed run catches configuration problems — origin allow-lists, RP ID, permissions policy — that a local run cannot see.

How many tests is enough here?

The seven scenarios, times the two or three device configurations that matter for each. That is typically fifteen to twenty tests, which is small enough to stay fast and broad enough to cover what users actually report.



Keeping the Suite Honest Over Time

Integration suites decay in a characteristic way: a test becomes intermittent, somebody marks it skipped to unblock a release, and a year later nobody remembers what it was protecting. Passkey suites are particularly prone to it because the intermittency usually comes from shared state rather than from anything genuinely non-deterministic, and shared state is easy to reintroduce.

Three habits prevent most of it. First, never skip a passkey test without recording what it covered — the seven scenarios each map to a real incident class, and a skipped test is a class of defect that has quietly stopped being caught. Second, treat any intermittency as an isolation bug until proven otherwise; in practice it almost always is an authenticator or a seeded credential surviving a teardown. Third, run the suite twice in continuous integration on any change to the test helpers, because that is the change most likely to break isolation without breaking any individual test.

It is also worth asserting on the shape of your own telemetry rather than only on user-visible outcomes. A test that checks the server recorded credential_unknown rather than merely that the request returned 401 will keep failing usefully after somebody refactors the error handling, whereas a status-code assertion will pass through a refactor that quietly collapses three distinct reasons into one. The status code is what the user sees; the reason code is what you will be reading during an incident, and it deserves the same protection.

Finally, revisit the device configurations once or twice a year. The population these tests are meant to represent shifts as platforms release, and a matrix assembled when the project started can end up thoroughly testing a configuration almost nobody has while ignoring the one most of your users now hold.

Related