Your Playwright Test Sent an Email. Now What?
You’ve automated the signup form.
await page.goto('/signup');
await page.getByLabel('Email').fill('[email protected]');
await page.getByRole('button', { name: 'Sign up' }).click();
Enter fullscreen mode Exit fullscreen mode
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 ✓
Enter fullscreen mode Exit fullscreen mode
Let’s build it.
Why not just use Gmail?
For manual testing, you absolutely can.
You can even create aliases:
[email protected]
[email protected]
[email protected]
Enter fullscreen mode Exit fullscreen mode
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`;
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode
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
});
Enter fullscreen mode Exit fullscreen mode
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');
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode
We can extract that URL from the email:
const verificationUrl = extractVerificationLink(email.html);
Enter fullscreen mode Exit fullscreen mode
Now we have:
https://example.com/verify?token=abc123
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode
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 ✓
Enter fullscreen mode Exit fullscreen mode
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
});
Enter fullscreen mode Exit fullscreen mode
Extract the code:
const otp = extractOtp(email.text);
Enter fullscreen mode Exit fullscreen mode
And give it back to Playwright:
await page.getByLabel('Verification code').fill(otp);
await page.getByRole('button', {
name: 'Verify'
}).click();
Enter fullscreen mode Exit fullscreen mode
Now your test can automate:
Login
↓
Request OTP
↓
Email sent
↓
Retrieve email
↓
Extract 482910
↓
Enter OTP
↓
Authenticated ✓
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Magic links?
Also the same pattern:
Request magic link
↓
Receive email
↓
Extract link
↓
Open link
↓
Assert authenticated ✓
Enter fullscreen mode Exit fullscreen mode
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:
[email protected]
Enter fullscreen mode Exit fullscreen mode
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`;
Enter fullscreen mode Exit fullscreen mode
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 ✓
Enter fullscreen mode Exit fullscreen mode
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
→ ✓
Enter fullscreen mode Exit fullscreen mode
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:
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.