Playwright handles the browser side of automation better than any tool that came before it. The other half — provisioning a real inbox per test, capturing a real webhook, doing both without ngrok and without a shared mailbox — is where teams burn weeks rebuilding the same plumbing. YoBox is that plumbing, pre-built, free, and reachable over plain HTTP.
This guide is the practical companion to Playwright + YoBox. It focuses on the patterns that show up in production e2e suites: fixtures, retries, traces, and the rare-but-painful edge cases.
The fixture pattern
Fixtures are the right place to put YoBox integration. They're scoped, typed, and auto-cleaned by Playwright.
// tests/fixtures.ts
import { test as base } from "@playwright/test";
const YOBOX = process.env.YOBOX ?? "https://yobox.dev/api";
export const test = base.extend<{
inbox: { id: string; address: string };
hook: { id: string; url: string };
}>({
inbox: async ({}, use) => {
const r = await fetch(${YOBOX}/mail/new, { method: "POST" });
use(await r.json());
},
hook: async ({}, use) => {
const r = await fetch(${YOBOX}/hooks/new, { method: "POST" });
use(await r.json());
},
});
export const expect = base.expect;
Every test that lists inbox or hook in its args gets a fresh, isolated resource.
The waiter
A 30-second poll with a 1.5-second interval covers 99% of email delivery scenarios.
export async function waitForEmail(id: string, opts = { timeout: 30000, interval: 1500 }) {
const start = Date.now();
while (Date.now() - start < opts.timeout) {
const r = await fetch(${process.env.YOBOX}/mail/${id}/messages);
const data = await r.json();
if (data.messages?.length) return data.messages[0];
await new Promise((r) => setTimeout(r, opts.interval));
}
throw new Error(Email timeout after ${opts.timeout}ms);
}
Signup, OTP, redirect
test("signup flow", async ({ page, inbox }) => {
await page.goto("/signup");
await page.getByLabel("Email").fill(inbox.address);
await page.getByLabel("Password").fill("Sup3rSecret!2026");
await page.getByRole("button", { name: "Create account" }).click();
const msg = await waitForEmail(inbox.id);
const otp = msg.text.match(/\b\d{6}\b/)![0];
await page.getByLabel("Code").fill(otp);
await page.getByRole("button", { name: "Verify" }).click();
await expect(page).toHaveURL(/\/welcome/);
});
Password reset
Same pattern, different email.
test("password reset", async ({ page, inbox }) => {
// assume the user already exists with this inbox
await page.goto("/forgot");
await page.getByLabel("Email").fill(inbox.address);
await page.getByRole("button", { name: "Send link" }).click();
const msg = await waitForEmail(inbox.id);
const link = msg.text.match(/https?:\/\/[^\s)]+/)![0];
await page.goto(link);
await page.getByLabel("New password").fill("Resetted!42");
await page.getByRole("button", { name: "Update" }).click();
await expect(page.getByText("Password updated")).toBeVisible();
});
Outbound webhooks
test("Stripe-style invoice.paid", async ({ page, hook }) => {
await page.goto("/admin/integrations");
await page.getByLabel("Webhook URL").fill(hook.url);
await page.getByRole("button", { name: "Save" }).click();
await page.getByRole("button", { name: "Send test invoice" }).click();
const req = await waitForHook(hook.id);
expect(req.method).toBe("POST");
const body = JSON.parse(req.body);
expect(body.event).toBe("invoice.paid");
expect(body.data.amount_cents).toBeGreaterThan(0);
});
Multi-context: two users, one test
Playwright shines at multi-user scenarios. Pair two inboxes for invite flows:
`ts, { method: "POST" }).then(r => r.json()),
test("invite flow", async ({ browser }) => {
const [inviter, invitee] = await Promise.all([
fetch(${process.env.YOBOX}/mail/new, { method: "POST" }).then(r => r.json()),
fetch(${process.env.YOBOX}/mail/new
]);
const ctxA = await browser.newContext();
const pageA = await ctxA.newPage();
await pageA.goto("/team/invite");
await pageA.getByLabel("Email").fill(invitee.address);
await pageA.getByRole("button", { name: "Send invite" }).click();
const msg = await waitForEmail(invitee.id);
const link = msg.text.match(/https?:\/\/[^\s)]+/)![0];
const ctxB = await browser.newContext();
const pageB = await ctxB.newPage();
await pageB.goto(link);
await expect(pageB.getByText("Welcome to the team")).toBeVisible();
});
`typescript
Traces, retries, and debugging
playwright.config.ts:
export default defineConfig({
retries: process.env.CI ? 1 : 0,
use: { trace: "on-first-retry", video: "retain-on-failure" },
});
Attach the email body to failed runs so the trace viewer shows it:
const msg = await waitForEmail(inbox.id);
await testInfo.attach("inbox.txt", { body: msg.text, contentType: "text/plain" });
Comparison: per-test vs per-worker fixtures
Scope Pros Cons
test Full isolation, simplest mental model Slightly more HTTP calls
worker Faster for read-only resources Cross-test leakage risk for inboxes
Use test scope for inboxes and hooks. Always.
Pairs with
Cypress + YoBox for teams running both runners.
Password Generator for test user credentials.
Regex Assistant for extraction patterns.
Docker Builder for CI for containerized runs.
Common pitfalls
page.waitForTimeout — replace with waitForEmail / waitForHook.
Hard-coded OTPs in fixtures — extract every time, even in happy-path tests.
Forgetting retries: 1 in CI — masks real bugs and causes false reds, in equal measure. One retry is the sweet spot.
Mixing inbox scopes — keep them per-test.
FAQ
Does this work with auth.setup.ts?
Yes — set up the user once with a per-worker fixture, but still use a per-test inbox for fresh OTPs.
Can I run cross-browser?
Yes. YoBox is browser-agnostic; the same fixture serves Chromium, Firefox, and WebKit projects.
What about mobile emulation?
Same fixture, same waiter. The viewport doesn't matter.
How do I assert email headers?
The messages endpoint returns subject, from, to, and headers — assert directly on those.
Conclusion
Playwright + YoBox is the closest the e2e world gets to free lunch. Two fixtures, one waiter, and you can honestly test signup, password reset, magic links, invites, and outbound webhooks — in parallel, across browsers, in CI, without ngrok and without a shared mailbox.
See also: Playwright + YoBox guide, Cypress E2E with YoBox, Realistic Mock Data.
Advanced: API mocking + real email
Playwright's \page.route\ lets you mock third-party APIs at the network layer while real emails still flow through YoBox. That hybrid is the sweet spot for testing flows that depend on a payment provider you don't want to ping.
\\ts
await page.route("/api.stripe.com/", (route) =>
route.fulfill({ status: 200, body: JSON.stringify({ id: "pi_test" }) })
);
\\
Advanced: visual regression on email templates
Render the YoBox HTML body inside a Playwright page, screenshot it, and snapshot-compare. Catches template regressions that text assertions miss.
Migration from Cypress
The YoBox fixture pattern ports 1:1; the rest is syntax. Most teams migrate one folder per week and run both runners in CI during the transition.
Reporting
Playwright HTML reports render attached artifacts inline — attach the email body and the webhook payload to every test so reports tell the full story without a debugger.
A production-grade Playwright setup
The default playwright.config.ts ships with reasonable defaults, but a real project benefits from a few opinionated additions:
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [["html"], ["junit", { outputFile: "junit.xml" }]],
use: {
baseURL: process.env.BASE_URL ?? "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{ name: "chromium", use: devices["Desktop Chrome"] },
{ name: "webkit", use: devices["Desktop Safari"] },
{ name: "firefox", use: devices["Desktop Firefox"] },
{ name: "mobile", use: devices["Pixel 7"] },
],
});
A reusable YoBox fixture
Fixtures are how Playwright keeps tests readable. Wrap inbox creation and webhook URL generation so every test starts with the right primitives.
`ts
// tests/fixtures/yobox.ts
import { test as base, expect } from "@playwright/test";
import { request } from "node:https";
type Yobox = {
newInbox(): Promise<{ address: string; pollOtp(): Promise }>;
newHook(): Promise<{ url: string; waitFor(method?: string): Promise }>;
};
export const test = base.extend<{ yobox: Yobox }>({
yobox: async ({}, use) => {
await use({
async newInbox() {
const r = await fetch("https://yobox.dev/api/mail/new", { method: "POST" });
const { address, token } = await r.json();
return {
address,
async pollOtp() {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const m = await fetch(https://yobox.dev/api/mail/${token}/latest).then(r => r.json());
const code = m?.text?.match(/\b\d{6}\b/)?.[0];
if (code) return code;
await new Promise(r => setTimeout(r, 1000));
}
throw new Error("OTP timeout");
},
};
},
async newHook() {
const id = crypto.randomUUID();
const url = https://yobox.dev/api/hooks/${id};
return {
url,
async waitFor(method = "POST") {
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
const r = await fetch(https://yobox.dev/api/hooks/${id}).then(r => r.json());
const hit = r.requests?.find((x: any) => x.method === method);
if (hit) return hit;
await new Promise(r => setTimeout(r, 500));
}
throw new Error("Webhook timeout");
},
};
},
});
},
});
export { expect };
`
End-to-end signup test
`ts
import { test, expect } from "./fixtures/yobox";
test("signup with OTP", async ({ page, yobox }) => {
const inbox = await yobox.newInbox();
await page.goto("/signup");
await page.getByLabel("Email").fill(inbox.address);
await page.getByRole("button", { name: "Send code" }).click();
const code = await inbox.pollOtp();
await page.getByLabel("Verification code").fill(code);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByRole("heading", { name: /welcome/i })).toBeVisible();
});
`
Async webhook test
`ts
test("integration emits webhook", async ({ page, yobox }) => {
const hook = await yobox.newHook();
await page.goto("/integrations/new");
await page.getByLabel("Webhook URL").fill(hook.url);
await page.getByRole("button", { name: "Save" }).click();
await page.getByRole("button", { name: "Send test event" }).click();
const received = await hook.waitFor("POST");
expect(received.headers["content-type"]).toContain("application/json");
expect(JSON.parse(received.body).event).toBe("integration.test");
});
`
CI/CD with Playwright
.github/workflows/e2e.yml
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
For containerized runs, see the Docker builder pattern for Cypress and Playwright CI.
Playwright vs. Cypress for YoBox workflows
Concern Playwright Cypress
Multi-tab / multi-origin First class Workarounds
Network interception Both ways Both ways
Parallel execution Built-in Dashboard / sharding
Polling YoBox inbox in-test Native fetch cy.task
Mobile emulation Built-in Viewport-only
Pick Playwright when you need multi-context (signup in one tab, admin approval in another). Pick Cypress when your team already lives in the Cypress runner.
Troubleshooting
OTP times out.
Provider delivery delay. Increase the deadline to 60s, and verify your sending service isn't throttling YoBox addresses.
Webhook never arrives.
The most common bug: the URL written to the form has a typo because of HTML autofill. Use page.getByLabel(...).fill(value) and re-read the field with inputValue() to confirm.
Tests flake under parallelism.
Make sure each test creates its own inbox and webhook. Shared state across workers is the single most common flake source.
FAQ
Can I run Playwright against production?
You can — but use ephemeral YoBox inboxes and webhook URLs so test artifacts don't litter your real systems.
How do I debug a single test?
npx playwright test --debug path/to/test.spec.ts. The Inspector lets you step through and modify selectors live.
Does YoBox have a rate limit?
The public endpoints are designed for human and CI-scale use. If you push beyond that, reach out — happy to whitelist sensible workloads.
YoBox Team
Builder behind YoBox — a privacy-first toolbox for developers and QA engineers covering disposable email, webhook capture, regex, secure passwords, Docker, and end-to-end testing.