Email verification testing is the part of a signup flow that most teams rehearse once, on the happy path, and then declare finished. The interesting failures are elsewhere: a link opened twice, a link opened on another browser, a code that arrives after the user has already been verified, a message that never arrives at all. This article walks through the states a verification flow really has and the cases worth covering in each of them.
The states a verification flow moves through
Before writing a test, write down the states. A flow that is described as verified or not verified is hiding at least four distinct situations, and each has its own correct behaviour.
| State | What the system believes | What the user can do |
|---|---|---|
| Unverified, no mail sent | The account exists but is unproven | Ask for another message |
| Unverified, message in flight | A token exists and has a lifetime | Wait, or ask again |
| Verified | The address was proven by a valid token | Use the account normally |
| Change pending | A new address is proposed, old one still valid | Confirm or cancel |
Most defects live in the transitions, not in the states. Ask what happens when a user requests a second message while the first is still in flight, or confirms the new address while still signed in on the old one.
What breaks when a confirmation link is opened twice?
Nothing should, and that is exactly why it is worth testing. A confirmation link is delivered over a channel the user does not fully control: mail clients preview links, security scanners follow them, and users click them a second time out of impatience.
The behaviour you want is that the first valid use consumes the flow and every later use produces a clear, harmless outcome — an “already verified” page, or a sign-in prompt — rather than an error the user cannot interpret. What you do not want is a second verification event that resets a password, re-issues a session, or fails with a message that suggests the account is broken.
There is a related case that catches teams out: two accounts, one address. If your product allows the same address on two accounts, a single confirmation message must not be able to verify both. Test it deliberately.
Testing the change-email and reset paths
Changing an address and resetting a password reuse the same machinery with a different consequence, and that is where a shared implementation starts to leak.
When a user changes an address, both addresses exist for a while. The old one should still be able to recover the account, and the new one should not take effect until it is proven. When a user resets a password, the confirmation should not also serve as a verification, and vice versa. Testing these paths means checking that a token minted for one purpose is refused by the other, which is a small assertion that prevents a large class of bug.
A useful fixture for this work is a mailbox that no other case is using, so that the message you open is certainly the one the flow just sent. The temp mail tool creates exactly that kind of address, and the OTP testing in end-to-end suites article deals with extracting the code once it lands.
What should happen when the mail never arrives?
This is the path teams skip, and it is the one users meet most often, because mail fails for reasons nobody controls: a typo in the address, a provider that silently discards the message, a sender that is briefly throttled.
The flow should treat non-arrival as a normal outcome. The user should be able to ask for another message without waiting an unreasonable time, the interface should say plainly that the message may take a moment, and the account should not be left in a state where the user cannot ask again because a request is “already pending”. Nothing here is a specific number of seconds or attempts; the point is that the design has an answer at all.
Reading a code from a throwaway inbox
When a flow sends a short code rather than a link, the test needs the code from the message. Reading it from a mailbox created for the run keeps the assertion narrow: the message you find can only belong to the case you are running, which removes the most common cause of a test that passes for the wrong reason.
It also keeps real inboxes out of the test entirely. A colleague’s address should never be the recipient of your regression suite, both because they will receive the traffic and because your assertions would then depend on a mailbox you do not control.
For developers: tokens, idempotency and the unhappy path
Model the token, not just the form. Three properties deserve explicit coverage.
Single use is the first. A token should be consumable once, and the second attempt should be a benign outcome rather than a server error. Where the transition changes something consequential, make the operation idempotent so that a repeat produces the same final state rather than a second effect.
Expiry is the second. Expired tokens should be distinguishable from invalid ones in your logs and from the user’s point of view, because the remedies differ: an expired token means start again, an invalid token may mean the wrong link was copied. What an expired token must not do is leave the account in a half-changed state.
Concurrency is the third. Two clicks arriving together, or a link opened in two tabs, should not be able to verify the same flow twice. That is a property of the data layer, not of the button, so test it by issuing both requests rather than by clicking twice slowly.
Finally, keep the boundary clear in your fixtures and in your notes: these addresses exist to receive test mail for a moment, they are not real identities, and no fixture should imply otherwise.
Next steps
Take the verification flow you shipped most recently and walk the state table above, marking the transitions you have actually tested. Then run the ones you have not, using an address created fresh for each case from the temp mail page. If you are preparing a wider launch, the transactional email testing checklist covers the surrounding mail that a verified account will start receiving.