End-to-end testing with Playwright helps teams verify the workflows users actually experience: signing up, submitting forms, receiving messages, clicking secure links, and seeing the intended result in the application. The challenge is not making a browser click buttons; it is proving that asynchronous systems, APIs, authentication, email delivery, and test data work together without creating a slow, flaky suite.
What end-to-end testing with Playwright should prove
An end-to-end (E2E) test exercises a complete user-facing path across the boundaries that matter to your product. A strong test usually starts with a browser action, passes through your application and its backend, and verifies an observable outcome rather than an implementation detail.
For a transactional-email workflow, that might mean:
- A user creates an account in the browser.
- The application persists the user and requests an email from its sending provider over REST or SMTP.
- The provider accepts the message for delivery.
- A test mailbox receives the message.
- The test opens the confirmation link.
- The browser confirms the account and displays a success state.
That is substantially more useful than a unit test that merely checks whether sendEmail() was called. It also carries more operational risk: external networks are asynchronous, inbox placement is not deterministic, and one shared test account can cause parallel tests to interfere with each other.
The goal is therefore not to make every test hit every production dependency. The goal is to create a layered test strategy in which a small number of well-designed E2E tests validate the highest-risk integrations, while faster tests cover the majority of application logic.
Playwright is well suited to this work because it combines browser automation, first-class test assertions, network inspection, browser projects, traces, and direct HTTP API requests in one runner. Its actionability checks wait for elements to become usable before common interactions such as clicks, while its web-first assertions retry until a condition passes or the assertion timeout expires. That reduces the need for arbitrary sleeps and makes tests reflect user-visible behavior.
Define the boundary before writing the test
“End to end” is not one fixed level of realism. Before choosing tools, define exactly which systems are inside the test boundary and which are controlled substitutes.
Browser-to-backend E2E tests
This is the common baseline. Playwright runs a real browser against a local, preview, or dedicated test deployment. The browser calls your real frontend and backend, while dependencies such as payments, analytics, and email may be mocked or redirected to safe test services.
This level validates routing, rendering, client-side validation, cookies, CSRF protections, API contracts, database writes, and the core user journey. It is fast enough to run on pull requests when the environment is stable.
Browser-to-email-inbox E2E tests
This extends the boundary through email generation and delivery to a controlled inbox. It catches errors that browser-only tests miss: a malformed recipient address, a missing template variable, an incorrect sender identity, a broken HTML link, a bad token, or a queue worker that never processes the message.
Because delivery is asynchronous, these tests need polling and clear time budgets. They should run against a domain and inboxes dedicated to testing, never against a real customer address.
Production smoke tests
A production smoke test is intentionally narrow: for example, load a public page, complete a harmless form, or authenticate using a dedicated test account. It can prove deployment health, DNS reachability, TLS, and critical routing, but it should not create unnecessary customer-facing data or generate repeated real emails.
Do not confuse a production smoke test with a full regression suite. Full regression tests in production are expensive, destructive, difficult to clean up, and vulnerable to third-party incidents outside your control.
A useful test pyramid for email workflows
For one password-reset feature, a balanced test portfolio may include:
- Unit tests: token creation, expiration logic, email-template helpers, and address normalization.
- Integration tests: your application calls the email client with the right recipient, headers, subject, and template variables.
- Browser E2E tests: the user requests a reset and sees a neutral success message regardless of whether the address exists.
- Inbox E2E tests: a controlled test inbox receives the reset message and its link results in a working password-change journey.
- Scheduled deliverability checks: a periodic message is sent through the real sending path and inspected for authentication and configuration problems.
The inbox test is important, but it should be a small, high-value layer rather than the only form of coverage.
Set up a deterministic Playwright foundation
Reliable tests begin with isolation. If a test’s success depends on data created by another test, a shared inbox with unknown contents, or execution order, it will eventually fail in CI for reasons unrelated to a product regression.
Install and configure the runner
For a Node.js project, install Playwright Test and browser binaries with:
npm init playwright@latest
A practical playwright.config.ts can start the application locally, keep failures diagnosable, and run Chromium on every change. Other browsers can run in a separate CI job or at a lower frequency.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 45_000,
expect: {
timeout: 7_000,
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [['html'], ['list']],
use: {
baseURL: process.env.BASE_URL ?? 'http://127.0.0.1:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: {
command: 'npm run start:test',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
},
});
The test timeout and assertion timeout should be considered separately. A long test timeout does not make a weak assertion reliable; it just makes a broken run slower. Give browser assertions a modest retry window, and reserve a larger, explicit window for genuinely asynchronous work such as a queue-delivered email.
Use semantic locators
Prefer locators based on roles, labels, or stable test IDs over CSS classes and DOM position. A user sees an accessible button name, but does not see .btn-primary:nth-child(2).
await page.getByLabel('Email address').fill(email);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('heading', { name: 'Check your inbox' }))
.toBeVisible();
A data-testid is appropriate when there is no meaningful accessible locator, but it should identify a user-relevant object or state. Do not make tests depend on framework-generated class names, animation timing, or a private JavaScript function.
Avoid fixed sleeps
This is fragile:
await page.waitForTimeout(5000);
The application could finish in 50 milliseconds on a developer machine and take 7 seconds during a slow CI run. Instead, wait for the condition that establishes readiness:
await expect(page.getByText('Account created')).toBeVisible();
await expect.poll(async () => await getJobStatus(jobId), {
timeout: 30_000,
}).toBe('completed');
Fixed sleeps have a legitimate but narrow role when you are testing time-dependent behavior deliberately, such as an animation or a rate-limit window. They are not a synchronization strategy for ordinary application state.
Design test data for parallel execution
Parallel execution exposes data collisions that sequential local runs hide. Every test should create or reserve its own data, and every test should be capable of passing in any order.
Generate unique identifiers
Use a run-specific prefix plus a unique suffix. In Playwright, testInfo can supply a repeatable test identity, while crypto.randomUUID() is useful for globally unique values.
import { test, expect } from '@playwright/test';
import { randomUUID } from 'node:crypto';
function testEmail() {
return `pw-${randomUUID()}@e2e.example.test`;
}
test('a visitor can request an account confirmation email', async ({ page }) => {
const email = testEmail();
await page.goto('/sign-up');
await page.getByLabel('Email address').fill(email);
await page.getByLabel('Password').fill('Long-test-password-123!');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Check your inbox')).toBeVisible();
});
The example.test domain is reserved for testing, but it does not receive internet mail. For real inbox delivery, use a subdomain you control, such as e2e.yourcompany.example, and provision a test mailbox service for it.
Keep secrets out of the repository
Email-provider API keys, SMTP credentials, inbox API tokens, database URLs, and authenticated browser storage state are secrets. Put them in CI secret storage and local environment files excluded from version control.
Playwright’s saved authentication state can contain sensitive cookies or headers. If you use storageState, keep the generated auth file in a gitignored directory, use non-privileged test users, and rotate credentials when staff or environments change.
Clean up, but do not depend on cleanup
Delete created records where practical, especially expensive or personally identifiable records. Yet assume cleanup can fail because a test process was terminated, a provider was unavailable, or a retry ran after a partial failure.
The safer design is to combine cleanup with automatic expiry: test-specific users can be deleted by a scheduled job, and test environments can be rebuilt from scratch. In production-like shared environments, label test data clearly and ensure your retention policy covers it.
Test email sending without making the inbox the only assertion
An application can display “Email sent” even when the provider has rejected the request, a background worker has failed, or the message contains a broken link. Conversely, a provider can accept a message successfully while the recipient server delays it. Good tests distinguish these stages.
Stage 1: assert your application’s response
After a visitor requests a confirmation or reset, assert the safe user-facing response. For account-recovery paths, that response should usually avoid revealing whether an address exists.
test('password reset request returns a privacy-preserving confirmation', async ({ page }) => {
await page.goto('/forgot-password');
await page.getByLabel('Email address').fill('person@example.com');
await page.getByRole('button', { name: 'Send reset link' }).click();
await expect(page.getByText(/if an account exists/i)).toBeVisible();
});
This test is fast, reliable, and valuable even if the sending provider is mocked. It verifies that users receive the intended product behavior and helps prevent account enumeration.
Stage 2: assert your backend side effect
Where possible, use an application-owned API, database query, job queue inspection endpoint, or audit event to prove that the send was requested. This is stronger than asserting a toast notification, but it should be an authorized test-only observation rather than a production backdoor.
Playwright can make HTTP calls directly through its API request context. That makes it useful for setting up server state before a browser journey or validating a server-side result after it.
test('registration queues an account-confirmation message', async ({ page, request }) => {
const email = testEmail();
await page.goto('/sign-up');
await page.getByLabel('Email address').fill(email);
await page.getByLabel('Password').fill('Long-test-password-123!');
await page.getByRole('button', { name: 'Create account' }).click();
const response = await request.get(`/test-support/outbound-events?recipient=${encodeURIComponent(email)}`);
await expect(response).toBeOK();
const events = await response.json();
expect(events).toContainEqual(expect.objectContaining({
type: 'account_confirmation',
recipient: email,
}));
});
Do not expose endpoints like /test-support/outbound-events on a public production deployment. Protect them by network policy and environment configuration, or omit them entirely outside isolated test environments.
Stage 3: test the rendered message
A message can be accepted by an email API and still be wrong. Inspect the recipient, subject, text body, HTML body, headers, and links in a controlled mailbox or capture service.
For local development, tools such as Mailpit and MailHog can receive SMTP messages without sending them to the public internet. Your application uses an SMTP host and port pointing at the local capture service; the test queries that service’s API or interface to retrieve the message. This is ideal for frequent pull-request tests because it is fast, private, and deterministic.
For a staging environment that uses an email API or SMTP relay, use an inbox-testing service or a dedicated mailbox with programmatic retrieval. The exact mailbox API differs by service, but the testing pattern is consistent: poll by recipient and timestamp, select the newest matching message, verify its content, then visit the extracted link.
Build a real email-confirmation journey
An email-confirmation test is a useful reference implementation because it crosses the browser, application, database, email sender, recipient inbox, and browser again.
Start with a predictable sender architecture
Your application may send through an HTTP API or an SMTP relay. Both can work well. In either case, make the sender implementation configurable by environment:
- In local development, send to Mailpit or another local SMTP capture service.
- In integration CI, use a controlled capture mailbox or an email provider’s test environment where available.
- In staging, send through the same type of provider integration used in production, but with a test domain and isolated recipients.
- In production, use approved sender domains, authenticated infrastructure, production credentials, and strict secret controls.
A provider offering both REST API sending and an SMTP relay lets applications choose the integration that fits their stack. For implementation details such as sending endpoints, authentication headers, SMTP hostnames, and provider-specific domain setup, use the platform’s email API reference and setup guides rather than copying values from another provider.
Create a mailbox abstraction
Do not spread vendor-specific inbox calls through your test files. Put them behind a small interface. The rest of the suite should ask for a matching message, not care how it was retrieved.
export type ReceivedEmail = {
to: string[];
from: string;
subject: string;
text?: string;
html?: string;
headers: Record<string, string>;
};
export interface InboxClient {
waitForMessage(input: {
to: string;
subjectIncludes: string;
after: Date;
timeoutMs?: number;
}): Promise<ReceivedEmail>;
}
This separation pays off when you move from local SMTP capture to a hosted test inbox, change providers, or add a second delivery path.
Poll with a deadline and a useful error
Email delivery is not a synchronous request-response protocol. A provider may return a successful API response before the message has passed through a queue or reached the inbox. Poll at a modest interval with a hard deadline, and report the relevant identifiers on failure.
async function waitForEmail(
inbox: InboxClient,
to: string,
subjectIncludes: string,
sentAfter: Date,
) {
return inbox.waitForMessage({
to,
subjectIncludes,
after: sentAfter,
timeoutMs: 45_000,
});
}
A failure should tell you the recipient, expected subject, cutoff timestamp, and any provider message ID or application request ID. That makes failures actionable. “Timed out waiting for email” alone is rarely enough to determine whether the issue is test setup, an application queue, credentials, provider acceptance, or recipient delivery.
Extract and validate the link
Do not merely assert that an email contains a URL. Confirm the URL belongs to the expected domain, uses HTTPS outside local development, contains the expected path, and completes the intended journey.
function confirmationUrl(html: string): string {
const match = html.match(/https:\/\/app\.example\.com\/confirm\?token=[^"'\s<]+/);
if (!match) throw new Error('No confirmation URL found in email HTML');
return match[0];
}
test('new user can confirm their account from the email link', async ({ page }) => {
const email = testEmail();
const sentAfter = new Date();
await page.goto('/sign-up');
await page.getByLabel('Email address').fill(email);
await page.getByLabel('Password').fill('Long-test-password-123!');
await page.getByRole('button', { name: 'Create account' }).click();
const message = await waitForEmail(inbox, email, 'Confirm your account', sentAfter);
expect(message.to).toContain(email);
expect(message.subject).toContain('Confirm your account');
expect(message.html).toContain('Confirm your account');
const url = confirmationUrl(message.html ?? '');
await page.goto(url);
await expect(page.getByRole('heading', { name: 'Account confirmed' })).toBeVisible();
});
The regular expression is intentionally only an example. A production test should parse HTML with a proper parser and target an explicit link label or data-* attribute in your template. Parsing HTML with regex becomes brittle as templates change.
Know what SMTP and HTTP results actually mean
Email tests are easier to debug when teams distinguish provider acceptance from recipient delivery.
HTTP API responses
A transactional email API commonly uses HTTP status codes such as:
- 200 OK or 201 Created: the request was successfully processed or a send resource was created, depending on the API design.
- 202 Accepted: the provider accepted the request for asynchronous processing. It does not guarantee inbox placement.
- 400 Bad Request: invalid JSON, invalid fields, malformed recipient data, or another client-side validation error.
- 401 Unauthorized or 403 Forbidden: missing, invalid, revoked, or insufficiently authorized credentials.
- 409 Conflict: an idempotency or resource-state conflict, if the provider uses that pattern.
- 429 Too Many Requests: rate limiting; clients should honor retry guidance where supplied.
- 500, 502, 503, or 504: a server-side or upstream failure. Retrying may be appropriate only when your send operation is idempotent.
Treat a 202 response as “accepted for processing,” not “delivered to the inbox.” Your E2E suite should assert the appropriate level for the test: accepted API request, queued application event, captured message, or completed recipient action.
SMTP responses
SMTP status codes have a similar distinction. Examples include:
- 250 Requested mail action okay, completed: the receiving SMTP server accepted a command, often including the message after
DATA. - 251 User not local; will forward: the server accepts the mail for forwarding.
- 354 Start mail input: the server is ready to receive message content after
DATA. - 421 Service not available: a temporary server condition or connection closure.
- 450, 451, 452: temporary failures, which can be retried according to policy.
- 550: permanent mailbox, address, policy, or command failure.
- 551, 552, 553, 554: other permanent failures such as forwarding problems, storage limits, invalid mailbox syntax, or transaction rejection.
A 250 from your outbound relay means that relay accepted the message at that stage. It does not prove the final mailbox provider placed it in the inbox rather than spam, deferred it, or later rejected it. That is why captured-mail tests and periodic external deliverability checks answer different questions.
Test email authentication and DNS separately from UI tests
DNS records and sender authentication are essential for deliverability, but they are poor candidates for every browser test. DNS propagation, provider-generated DKIM values, and external recipient policy make them slow and environment-dependent.
Instead, run focused infrastructure checks after DNS changes and on a schedule.
SPF record syntax
SPF is published as a DNS TXT record at the sending domain. A simplified example authorizing one IPv4 address and one included sender service is:
example.com. 3600 IN TXT "v=spf1 ip4:198.51.100.24 include:spf.email-provider.example -all"
The v=spf1 prefix identifies the record. ip4: authorizes a specific IPv4 address, include: delegates evaluation to another domain’s SPF policy, and -all indicates a hard fail for senders not matched by prior mechanisms.
Do not publish several independent SPF TXT records beginning with v=spf1 for the same domain. SPF evaluation expects a single policy record; combine legitimate senders into one record. Also watch the DNS lookup limit imposed by SPF evaluation. Deeply nested include, redirect, a, mx, and exists mechanisms can cause a permerror even when each individual provider instruction looks valid.
DKIM record syntax
DKIM uses a selector-specific DNS TXT record. The selector is chosen by the sender and appears in the message’s DKIM-Signature header. An illustrative RSA record looks like this:
s1._domainkey.example.com. 3600 IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..."
The public key in p= must be the exact value generated by the sending system. Do not invent, shorten, rewrap, or edit it. Some providers use CNAME records for DKIM delegation instead of placing the public key directly in TXT; follow the provider’s exact host and target values in that case.
A test that receives a message can inspect headers for a DKIM-Signature and, when the mailbox exposes authentication results, look for a passing DKIM result. The strongest practical validation is receiving the message through an independent mailbox and reviewing its authentication headers, not merely checking that a DNS record exists.
DMARC record syntax
DMARC is published at _dmarc as a TXT record. A conservative monitoring policy might be:
_dmarc.example.com. 3600 IN TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com; pct=100"
A more mature enforcement policy could be:
_dmarc.example.com. 3600 IN TXT "v=DMARC1; p=quarantine; adkim=s; aspf=s; rua=mailto:dmarc-reports@example.com; pct=100"
p=none requests monitoring, p=quarantine asks receivers to treat failing mail suspiciously, and p=reject is the strictest requested handling. adkim=s and aspf=s request strict alignment for DKIM and SPF respectively. Enforcement should be introduced after reviewing aggregate reports and confirming every legitimate sender is authenticated and aligned.
Do not add p=reject solely because a tutorial says it is best practice. A forgotten billing platform, support desk, CRM, or recruiting tool can become unable to send aligned mail for your domain. DMARC is an operational program, not a one-time DNS checkbox.
Useful verification tools
Use command-line DNS checks during setup:
dig TXT example.com +short
dig TXT s1._domainkey.example.com +short
dig TXT _dmarc.example.com +short
MXToolbox can check MX, SPF, DKIM, DMARC, blacklist, and related delivery signals. mail-tester.com accepts a sent message and analyzes spam-related content, message structure, and mail-server configuration. These tools are best used as scheduled smoke checks or during deliverability investigations, not as a gating dependency for every pull request.
Handle external dependencies without hiding real failures
Network mocking is valuable, but it must be intentional. If every E2E test mocks the email request, the suite cannot catch an expired API key, invalid sender domain, or accidental production endpoint change.
When to mock
Mock an external dependency when the goal is to test your UI’s behavior under a known response: a rate limit, an API validation error, a timeout, or an unavailable downstream service. Playwright can monitor and modify browser HTTP traffic, which is useful when the browser itself makes the request.
await page.route('**/api/newsletter/subscribe', async route => {
await route.fulfill({
status: 429,
contentType: 'application/json',
body: JSON.stringify({ error: 'Try again later' }),
});
});
Then assert the user sees an understandable, recoverable error. This test is deterministic and does not spend provider quota.
When not to mock
Do not mock the core integration in the one or two tests intended to prove that integration works. Keep a small “contract” or “canary” group that uses real non-production credentials, a dedicated sender domain, and controlled recipients.
Examples include:
- A staging test that sends through the real provider API and confirms a captured inbox receives the message.
- An SMTP test that authenticates to the configured relay and verifies a message is accepted.
- A scheduled test that checks DNS authentication and analyzes headers from an externally delivered message.
Tag these tests separately so CI can run them after deployment, nightly, or on demand rather than blocking every developer iteration.
Use idempotency for retries
Network calls can fail after a server has accepted work but before the client receives the response. A blind retry can create duplicate emails. Where an email API supports idempotency keys, use a stable key for one logical send attempt. Where it does not, implement deduplication in your application around an event ID or notification record.
This matters in tests too. A retrying test that sends two confirmation emails may select the wrong message and produce misleading failures. Filter by unique recipient, expected subject, and a time cutoff; ideally include a test-run identifier in a non-user-visible message header or application metadata if your sender supports it.
Make failures diagnosable in CI
An E2E test that fails only in CI is frustrating; an E2E test that fails with screenshots, a trace, console output, request details, and mail metadata is a debugging asset.
Capture browser artifacts
Configure traces on first retry, screenshots on failure, and videos when needed. A Playwright trace can show the sequence of actions, DOM snapshots, network activity, and errors around a failure. It is especially useful for race conditions, redirects, and elements obscured by overlays.
Be careful with sensitive data. Trace files, screenshots, videos, and HTML reports can contain names, addresses, account details, access tokens in URLs, or email content. Restrict artifact retention and access, redact secrets from application logs, and use synthetic test data.
Log correlation IDs
For email workflows, record a correlation ID from the browser request through the backend job and sender request if your architecture supports it. When an inbox test fails, you should be able to answer:
- Did the browser submit the request?
- Did the backend create the notification event?
- Did the queue worker process it?
- Did the email provider accept it?
- Did the capture inbox receive it?
- Did the confirmation URL resolve and consume the token?
A single identifier connecting those stages dramatically reduces time to resolution.
Classify failures correctly
Not every red run is an application bug. Common categories are:
- Product regression: UI, backend, template, token, or authorization behavior changed incorrectly.
- Test defect: unstable locator, shared state, weak polling filter, or stale fixture.
- Environment defect: database unavailable, DNS failure, queue worker stopped, expired secret, or test mailbox outage.
- Third-party incident: email provider, CI provider, identity provider, or recipient mailbox service is degraded.
Your reporting should retain this distinction. Repeatedly rerunning a test can mask a flaky test or increase mail volume, while immediately blaming the application can send engineers in the wrong direction.
A practical CI execution plan
The best CI plan matches test cost and risk. Running every browser, inbox, and deliverability test for every commit is rarely necessary.
Pull-request checks
Run fast, deterministic checks on each pull request:
- Unit and integration tests.
- Playwright browser journeys against an ephemeral or isolated test environment.
- Local SMTP capture tests for critical templates and links.
- Mocked error-path tests for provider failures, rate limits, and timeouts.
These tests provide rapid feedback and should not require access to a public inbox or an externally routable sender domain.
Deployment checks
After deployment to staging, run a compact real-integration suite:
- Create a unique test account.
- Send one confirmation or password-reset email through the configured provider path.
- Retrieve it from a controlled inbox.
- Verify recipient, sender, subject, and link behavior.
- Confirm the target page loads and the token is consumed exactly once.
This is where you detect configuration drift: an incorrect environment variable, a revoked key, a changed sender domain, or a worker process missing from the deployment.
Scheduled operational checks
Run scheduled checks daily or weekly, depending on your volume and change rate:
- Send a test message to mail-tester.com or another controlled external analysis address.
- Review SPF, DKIM, and DMARC results.
- Check blacklist and reputation signals with tools such as MXToolbox.
- Inspect a real recipient mailbox for unexpected spam placement or header failures.
- Monitor bounce, complaint, deferred, and delivery-event metrics from your sender.
Scheduled operational checks complement automated E2E tests. They are not a substitute for monitoring, but they help catch sender-domain and deliverability regressions that application tests cannot reliably predict.
Common Playwright E2E testing mistakes
Several patterns create false confidence or fragile suites.
Testing internal implementation instead of user outcomes
Asserting that a React state variable changed, a private endpoint was called exactly once, or a CSS class exists does not prove the customer journey works. Prefer visible messages, accessible controls, durable server effects, and completed destination flows.
Using one shared recipient
A shared inbox creates race conditions. One test may read another test’s email, and reruns can select an old message. Use unique recipient aliases or inbox addresses per test and filter by a timestamp recorded immediately before the triggering action.
Treating email acceptance as delivery
An HTTP 202 or SMTP 250 is important, but it is not an inbox guarantee. Test acceptance in fast integration tests; test receipt and link functionality in a smaller real-delivery suite.
Making a public email provider part of every PR run
This makes feedback slower, costs more, and creates failures that developers cannot reproduce locally. Use local SMTP capture for routine runs and reserve real delivery for staging or scheduled canaries.
Ignoring authentication-state isolation
Reusing one logged-in account is efficient only when tests do not mutate shared server-side state. If tests change profile, billing, permissions, settings, or security state, create separate users or isolate data by tenant. Parallelism without isolation is an intermittent-failure generator.
Retrying without investigating
Retries can handle a transient browser or infrastructure issue, but they should not be a permanent bandage. Track retry rates. A test that passes on its second attempt frequently enough is telling you something important about synchronization, data collisions, or an unreliable dependency.
Conclusion
End-to-end testing with Playwright is most effective when it is treated as a system-design discipline rather than a collection of browser scripts. Start with user-visible browser journeys, isolate data, use semantic locators and condition-based assertions, and make test failures observable through traces and correlation IDs.
For transactional email, test in layers. Use fast local capture tests to validate templates and links, then run a smaller real-integration suite that proves your REST API or SMTP relay configuration can deliver to a controlled inbox. Finally, validate SPF, DKIM, DMARC, and real-world deliverability on a schedule with independent tools and mailbox headers.
That combination provides meaningful confidence without turning every pull request into an unreliable experiment against the public email ecosystem.
FAQ
Is Playwright good for end-to-end testing?
Yes. Playwright is well suited to browser-based E2E testing because it supports modern browsers, automatic waiting for common actions, retrying assertions, browser projects, traces, screenshots, video capture, and direct HTTP API requests. It works best when tests focus on user-visible behavior and isolated test data.
Can Playwright test emails?
Playwright can test the browser portions of an email workflow and can call APIs directly, but it does not itself host an inbox. Pair it with a local SMTP capture service such as Mailpit or MailHog, or with a controlled inbox service that exposes an API. Then poll for the message, inspect it, extract the link, and continue the journey in Playwright.
Should every Playwright test send a real email?
No. Most tests should use local capture or a mock because real delivery is slower and depends on external systems. Keep a small set of staging canary tests that send through the real provider and confirm receipt in a controlled inbox. Run deliverability checks separately on a schedule.
What is the difference between SMTP 250 and delivered email?
SMTP 250 indicates that the SMTP server accepted the relevant command or message at that hop. It does not guarantee that the final recipient accepted the message, placed it in the inbox, or avoided spam filtering. Delivery and inbox placement require later stages of validation.
How do I test SPF, DKIM, and DMARC?
Publish the correct DNS records for your sender, verify them with DNS tools such as dig and MXToolbox, then send a message to a controlled mailbox or mail-tester.com and inspect the authentication results. Validate each sender used by your organization, not only the primary transactional-email platform.