When an email API test starts failing on one branch, the first instinct is often to rerun the same checkout and watch the inbox again. That gets messy when two fixes are being investigated at once. One branch changes retry timing, another changes the webhook parser, and both write into the same temporary mailbox. The result is a test report that is green-ish, confusing, and realy hard to trust.
I have found a simple combination more useful: one Git worktree per investigation, one fixture namespace per worktree, and one small evidence bundle per run. It turns parallel debugging from a shared-state problem into a few independent folders. The workflow is especially handy for GitHub Actions and other automation where a failed check should explain itself after the job is gone.
Why shared email fixtures make parallel branches noisy
An email API test usually has more state than the test file suggests. There is a recipient address, a message ID, a polling window, a subject or token assertion, and often a cleanup step. If two branches reuse any of those values, a passing assertion can read the wrong message.
That is why I avoid a generic address such as test@example.com for concurrent checks. Even a disposable address should have a run-scoped name, for example ci-${run_id}@inbox.test, or an equivalent fixture identifier supplied by the email test service. A search like fake e mail com should not be the process for choosing test data; the test needs a documented contract instead.
The same principle applies to signup flows that have several visible states. A small state machine for signup flows is a useful companion when the UI can show pending, expired, and verified states independently of the inbox lookup.
Create one worktree per investigation
Git worktrees let me keep multiple branches checked out at once without copying the whole repository. A short setup looks like this:
git fetch origin
git worktree add ../email-api-retry-fix -b debug/email-api-retry origin/main
git worktree add ../email-api-parser -b debug/email-api-parser origin/main
Now each investigation has its own source tree and its own .env.test or fixture configuration. I put the run identifier in the environment rather than in a global config file:
export EMAIL_TEST_RUN="$(git branch --show-current | tr '/' '-')-$(date +%s)"
export EMAIL_FIXTURE_PREFIX="${EMAIL_TEST_RUN}-"
./scripts/email-api-check.sh --fixture-prefix "$EMAIL_FIXTURE_PREFIX"
The branch name is not a security boundary, so I still keep credentials in the CI secret store. It is only a convenient human-readable label. Also, remove a worktree when the investigation is finished; leaving many stale dependancies around makes the local setup harder to understand.
Give every test run its own inbox contract
The check should produce a small contract before it sends anything. Mine normally records:
- the run ID and commit SHA
- the fixture or recipient identifier
- the expected event type
- the maximum polling duration
- the message marker that proves the right email was found
The assertion should reject a message from another run, even if the subject looks correct. Matching both a run marker and an event marker is a small extra step with a big payoff. For high-concurrency suites, concurrency keys for email API checks can make this ownership explicit at the API boundary too.
Here is the shape of a result I want the shell script to write:
{
"run_id": "debug-email-api-retry-1710000000",
"commit": "abc1234",
"event": "verification.sent",
"matched": true,
"poll_seconds": 8,
"message_id": "msg_42"
}
If the check needs a generated address, make the generation step visible in that result. Searching for terms such as dummy e mail may describe the intent, but it does not tell a future reader which fixture was actually used.
Keep GitHub Actions evidence attached to the branch
The workflow becomes much easier to triage when every job uploads the same small set of files, including failures:
- name: Run email API check
env:
EMAIL_TEST_RUN: ${{ github.head_ref || github.ref_name }}-${{ github.run_id }}
run: ./scripts/email-api-check.sh --out artifacts/email-result.json
- name: Upload email evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: email-api-${{ github.run_id }}
path: artifacts/
The before-and-after improvement is not fancy: before, a developer had to reconstruct which branch sent which message. After, the commit, fixture, assertion, and failure reason travel together. When a test fails, I can open the worktree, rerun the same command, and compare the artifact without guessing.
Q&A
Should every branch get a new inbox?
For parallel or long-running checks, yes. For a strictly sequential local test, a reusable fixture can be fine if cleanup is reliable. The important part is making that choice explicit.
Is a Git worktree required in CI?
No. CI already gives each job a fresh checkout in most setups. Worktrees are most valuable locally, where several branches are open and developers are tempted to reuse one directory.
What is the smallest useful artifact?
Commit SHA, run ID, fixture ID, expected event, matched message ID, and failure reason. Keep the payload small so people actualy read it.
The shortcut is simple: isolate code with worktrees, isolate test data with run-scoped fixtures, and isolate diagnosis with predictable artifacts. That gives Git and automation a cleaner handoff, while leaving fewer mysteries in the inbox.
Top comments (0)