Integration tests for authentication often fail after the assertion has already passed. The user was created, the verification message arrived, and the token worked. The next run then sees old mail, an existing account, or a mailbox that another test still owns.
I have found that this is rarely only a mail delivery problem. It is a test-data lifecycle problem. A REST API fixture needs an owner, an expiry time, and a cleanup contract just like a production resource does. Without those fields, CI is asked to guess what can be deleted.
Why cleanup is part of the API contract
Suppose a test calls POST /users, receives a temporary address, and waits for a verification message. If the test process is killed after the response, a normal afterEach hook never runs. The mailbox and the database row remain behind.
The next run may reuse the address by accident. A broad cleanup job can be worse: it may remove data belonging to a still-running job. Both failures come from the same missing decision: who owns this fixture, and when does that ownership end?
This boundary is also useful for mailbox providers. Whether the test uses tempmailso, a local inbox, or a service searched with a phrase like temp mail com, the application should treat the mailbox identifier as test data, not as a hidden global dependency.
Give every fixture an owner
At fixture creation time, generate a run identifier and persist it with the mailbox record. I prefer an explicit table rather than putting cleanup metadata in an opaque test log.
CREATE TABLE test_mailbox_fixtures (
id uuid PRIMARY KEY,
run_id text NOT NULL,
address text NOT NULL,
status text NOT NULL CHECK (status IN ('active', 'released', 'expired')),
lease_until timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
released_at timestamptz
);
CREATE INDEX test_mailbox_fixtures_cleanup_idx
ON test_mailbox_fixtures (status, lease_until);
The run_id is more useful than a test name. A test name changes during refactoring, while a CI run can be traced through logs, artifacts, and the deployment that started it. The lease gives cleanup a bounded rule instead of a guess.
When a fixture is allocated, the API should return its identifier to the test runner. It should not return a shared mailbox selected from a static environment variable. That shortcut saves a few lines and creates a race between parallel jobs.
Model cleanup in PostgreSQL
Cleanup should be idempotent. A worker can claim expired rows, delete the external mailbox if needed, and mark the local record as released. If the worker crashes halfway through, another attempt must be safe.
One simple claim uses row locks:
WITH candidates AS (
SELECT id
FROM test_mailbox_fixtures
WHERE status = 'active'
AND lease_until < now()
ORDER BY lease_until
FOR UPDATE SKIP LOCKED
LIMIT 50
)
UPDATE test_mailbox_fixtures AS f
SET status = 'released', released_at = now()
FROM candidates
WHERE f.id = candidates.id
RETURNING f.id, f.address;
In a real service, I would usually keep the external deletion and the database transition separate, because a network call should not hold a database transaction open. Mark a row as release_requested, perform the provider operation, then record the result. If the provider says the mailbox is already gone, that is normally a successful cleanup outcome.
The important detail is the state model. A boolean called cleaned cannot explain whether cleanup was never attempted, is in progress, or failed after the provider accepted the request. Those states matter when CI has a short retention window.
Keep teardown safe to retry
The test runner should call a release endpoint when it finishes, but the backend must still expire abandoned fixtures. The release endpoint can be as small as:
POST /test-fixtures/{id}/release
Idempotency-Key: ci-run-84c2-fixture-19
The handler verifies ownership by run_id, transitions only an active fixture, and returns the current state for repeated calls. A second request should get released, not a confusing 404. This makes retries observable and keeps the teardown path boring.
For email assertions, use a separate receipt record containing the fixture ID, message ID, and observed timestamp. Avoid logging full message bodies or verification URLs. A phrase such as fake e mail com or dummy e mail may appear in a test case description, but it should never become a production identifier or an excuse to retain sensitive content.
Tests that depend on time should also make their clock boundary explicit. The guide on OTP tests with explicit clock boundaries is a useful companion when token expiry and mailbox expiry interact.
Connect the mailbox boundary to CI
At the start of a job, create a unique run_id. Put it in every fixture request, log field, and artifact name. At the end, release known fixtures. Separately, run the expiry worker often enough that a cancelled job does not accumulate data for days.
The CI artifact should contain counts and identifiers, not email contents:
{
"run_id": "ci-84c2",
"fixtures_created": 12,
"fixtures_released": 12,
"fixtures_expired": 0,
"receipts_observed": 12
}
This evidence fits well beside build manifests for release-email verification: both approaches make an email-dependent workflow explainable after the job is gone.
A practical checklist
- Create one fixture per parallel run, not one shared address per environment.
- Persist
run_id, ownership, status, andlease_untilin PostgreSQL. - Make release requests idempotent and safe after a process crash.
- Keep email receipts small and redact message bodies and tokens.
- Use
SKIP LOCKEDor an equivalent claim strategy for concurrent cleanup. - Report counts and IDs as CI evidence.
- Alert on expired-fixture growth, not on one isolated cleanup failure.
Q&A
Should the test runner delete the mailbox directly?
No. Let the backend own the lifecycle so cleanup rules are consistent across local runs, CI, and cancelled jobs. The runner can request release, but it should not bypass ownership checks.
Is a lease enough without a cleanup worker?
No. A lease only defines when a fixture becomes eligible. A worker, scheduled task, or controlled reconciliation loop still needs to enforce the policy.
What if the external mailbox provider is unavailable?
Keep the local row and retry with backoff. Expiry should be visible as a cleanup state, not implemented by deleting the database record and losing the evidence.
The broader lesson is simple: test fixtures are short-lived infrastructure. When a REST API gives each fixture an owner and a cleanup contract, authentication tests become easier to run in parallel, easier to debug, and much less likely to poison the next build.
Top comments (0)