There are two usual answers to "how do you test the signup flow," and both are bad. One is to run it against production and manually delete the accounts afterwards, which works until someone forgets. The other is to test each piece in isolation, which never exercises the part that actually breaks: the handoff between your application, the mail provider, and a human opening a message.
A third option exists, and it costs less than either.
What the flow actually needs
An end-to-end signup test needs four things to be real: the form submission, the account record, the outbound message, and the verification step. Only one of those needs a human-shaped identity, and it is the message.
The rest can be scoped. A dedicated test API key with a small quota and, if the provider supports it, a restricted IP allowlist, bounds the blast radius of a mistake. A disposable address gives you a real inbox with a real MX that expires on its own. Together they let you run the genuine flow — real DNS, real SMTP, real message parsing — without a durable identity attached to anything.
The sequence
- Mint an address through the API, and record its
expires_atso you know the window. - Submit the signup form with that address, using an idempotency key so a retry does not create a second account.
- Poll the inbox on an interval with a hard deadline, using a cursor or
since_idso each poll is a delta rather than a re-read. - Extract the code and assert it matches the shape the UI expects — digits, length, and no leading-zero loss.
- Complete verification and assert the account reaches the expected state.
- Teardown: delete the address and, separately, delete the test account if the environment retains it.
Step 6 is the one that gets skipped, and it is the one that makes the difference between a test that is safe to run daily and a test that eventually blocks itself on quota.
The parts that make it reliable
Cursor, not full re-read. Polling the whole inbox every tick works and scales badly. A since_id parameter turns each poll into "anything newer than what I already saw," which is both cheaper and more precise as an assertion.
Deadline, not sleep. A fixed sleep passes locally and fails on a loaded runner. Poll on an interval with a hard ceiling and assert that the ceiling was reached only on genuine failure.
Always-teardown. Cleanup in a finally block, and it must tolerate a 404 because a previous partial run may have deleted the address already. A teardown that throws on "already gone" converts one flake into a failed pipeline.
One address per run. Not per assertion. Consumption scales with pipeline runs instead of with assertion count, which is the difference between a suite that survives a month and one that hits the quota in a morning.
What you can and cannot assert
Assert on identity and bounds: the message with this message_id arrived within the deadline, the extracted code matches the expected shape, the account transitioned once. Do not assert on exact timestamps, exact ordering when two sends are concurrent, or exact delivery latency — those produce flake regardless of how carefully they are written.
And be explicit about the boundary: this tests your flow. It does not test that a third party's signup accepts your address, and it should not. If a service's terms require a durable identity, a disposable address is the wrong instrument and no amount of automation changes that.
Cost of getting it wrong
A signup test that creates production accounts pollutes reporting, inflates activation metrics, and leaves records nobody owns. A signup test that skips verification tests nothing that matters. The disposable-address version costs a few API calls, runs in seconds, leaves no durable state, and exercises the same code path users hit. That is a rare combination.
The takeaway
Mint, submit, poll with a cursor, extract, verify, always clean up. One address per run, a deadline instead of a sleep, a scoped key instead of a production credential. The flow is real, the accounts are not, and the suite survives more than a week.