Your Playwright Test Sent an Email. Now What?
You've automated the signup form.
await page.goto('/signup');
await page.getByLabel('Email').fill('test@example.com');
await page.getByRole('button', { name: 'Sign up' }).click();
Your application responds with:
📩 We sent you a verification email.
And now your E2E test has a problem.
How does Playwright get that email?
How does it extract the verification link or OTP?
And how do you automate the entire flow in CI without someone opening
Gmail manually?
This is one of those workflows that's easy to test manually but gets
surprisingly awkward when you try to automate the whole journey.
What we want is this:
Create user
↓
Application sends email
↓
Wait for email
↓
Retrieve email through API
↓
Extract verification link
↓
Open link with Playwright
↓
Assert account is verified ✓
Let's build it.
Why not just use Gmail?
For manual testing, you absolutely can.
You can even create aliases:
myemail+test1@gmail.com
myemail+test2@gmail.com
myemail+test3@gmail.com
But once your tests run automatically, things become more complicated.
Your CI runner needs access to the inbox.
Tests might run in parallel.
You need to know which email belongs to which test.
Old messages can interfere with new test runs.
And suddenly your E2E suite needs Gmail credentials or OAuth
configuration.
Instead, I prefer treating email like any other part of an automated
test:
Send the email to a test inbox and access it programmatically.
For this example, I'll use mailQA, a tool I've been
building specifically for testing application-generated emails.
1. Create a unique test email address
Instead of using a real mailbox, let's generate an address specifically
for our test.
const emailAddress = `signup-${Date.now()}@myteam.mailqa.io`;
Now use that address normally in Playwright:
await page.goto('/signup');
await page.getByLabel('Email').fill(emailAddress);
await page
.getByLabel('Password')
.fill('TestPassword123!');
await page.getByRole('button', {
name: 'Sign up'
}).click();
As far as your application is concerned, this is just another email
address.
It sends the verification email normally.
2. Wait for the email 📩
Here's where things get interesting.
Instead of opening an inbox manually, our test retrieves the message
programmatically.
Conceptually, it looks like this:
const email = await mailqa.waitForEmail({
to: emailAddress,
timeout: 30_000
});
Now the email becomes part of our test.
expect(email.subject).toContain('Verify your email');
expect(email.to).toContain(emailAddress);
expect(email.html).toContain('Verify your account');
So we're not only testing that an email was sent.
We're testing the email itself.
3. Extract the verification link
Suppose the email contains:
<a href="https://example.com/verify?token=abc123">
Verify your email
</a>
We can extract that URL from the email:
const verificationUrl = extractVerificationLink(email.html);
Now we have:
https://example.com/verify?token=abc123
No human opened an inbox.
No one clicked anything manually.
Our test has everything it needs to continue.
4. Continue the Playwright test ðŸŽ
Receiving the email isn't the end of our test.
It's just another step.
await page.goto(verificationUrl);
await expect(
page.getByText('Email verified successfully')
).toBeVisible();
Our complete flow now looks like:
Playwright
│
├── Sign up
│
â–¼
Application
│
├── Send verification email
│
â–¼
Test inbox
│
├── Retrieve email
├── Assert content
└── Extract verification URL
│
â–¼
Playwright
│
└── Verify account ✓
No Gmail. No manually clicking links.
And most importantly, the whole thing can run unattended in CI/CD.
What about OTP codes?
The same approach works really well for OTP authentication.
Imagine your application sends:
Your verification code is 482910
Retrieve the email:
const email = await mailqa.waitForEmail({
to: emailAddress
});
Extract the code:
const otp = extractOtp(email.text);
And give it back to Playwright:
await page.getByLabel('Verification code').fill(otp);
await page.getByRole('button', {
name: 'Verify'
}).click();
Now your test can automate:
Login
↓
Request OTP
↓
Email sent
↓
Retrieve email
↓
Extract 482910
↓
Enter OTP
↓
Authenticated ✓
Password reset emails?
Same idea.
Request password reset
↓
Application sends email
↓
Retrieve email
↓
Extract reset URL
↓
Open URL with Playwright
↓
Choose new password
↓
Login with new password
↓
✓ Test passed
Magic links?
Also the same pattern:
Request magic link
↓
Receive email
↓
Extract link
↓
Open link
↓
Assert authenticated ✓
Once your tests can access email programmatically, a lot of previously
awkward E2E workflows become straightforward.
Don't forget test isolation
Avoid having every test use:
test@myteam.mailqa.io
Imagine 10 Playwright workers running simultaneously. Which verification
email belongs to which test?
Instead, generate unique addresses:
const testId = crypto.randomUUID();
const emailAddress =
`signup-${testId}@myteam.mailqa.io`;
Now every test gets its own address, making parallel execution safer and
failed tests easier to debug.
What about Cypress?
The principle is exactly the same.
Cypress
│
├── Trigger signup
│
â–¼
Application
│
├── Send email
│
â–¼
Test inbox
│
├── Retrieve email
│
â–¼
Cypress
│
└── Continue test ✓
I'll cover the complete Cypress implementation separately.
Email should be part of your E2E test
The idea is simple:
If email is part of the user journey, email should be part of the
automated test too.
Your E2E test shouldn't have to stop at:
📩 We've sent you an email.
You should be able to automate the whole thing:
Signup
→ Email
→ Verification
→ Authenticated user
→ ✓
That's the problem I'm trying to solve with
mailQA.io.
mailQA provides dedicated test email inboxes and API/SDK access designed
for QA and development teams running automated tests with tools like
Playwright, Cypress and CI/CD pipelines.
There's a 14-day trial if you want to experiment with it:
👉 Try mailQA.io
But I'm also curious how other teams are handling this.
How are you currently testing email verification, OTPs, password
resets, and other email flows in your E2E tests?
I'd love to hear about your setup in the comments.
Top comments (0)