Testing Next.js Email Flows in GitHub Actions
A complete setup for testing email verification, OTP, and password reset flows in your Next.js app — running in GitHub Actions CI with no Docker, no shared inboxes, and no mocking.
The problem
Next.js apps with email flows — signup verification, magic links, OTP codes, password resets — are notoriously hard to test in CI. The standard approaches either require running a Docker SMTP container (slow, adds cold start time) or mocking the email layer entirely (doesn't test real delivery).
ZeroDrop gives each GitHub Actions run a fresh disposable inbox over HTTPS. No Docker service block. No shared state between parallel runs.
Install
The GitHub Actions workflow
name: E2E Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build Next.js app
run: npm run build
env:
NEXT_PUBLIC_URL: ${{ secrets.STAGING_URL }}
- name: Generate test inbox
id: inbox
uses: zerodrop-dev/create-inbox@8706a59 # v1.0.0
- name: Run E2E tests
run: npx playwright test
env:
TEST_INBOX: ${{ steps.inbox.outputs.inbox }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
NEXT_PUBLIC_URL: ${{ secrets.STAGING_URL }}
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/Email verification test
import { test, expect } from '@playwright/test';
import { ZeroDrop } from 'zerodrop-client';
const mail = new ZeroDrop();
test('signup and verify email', async ({ page }) => {
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
await page.goto('/signup');
await page.fill('[name="email"]', inbox);
await page.fill('[name="password"]', 'TestPass123!');
await page.click('[type="submit"]');
// Catch verification email — magic link auto-extracted
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
expect(email.magicLink).not.toBeNull();
await page.goto(email.magicLink!);
await expect(page).toHaveURL('/dashboard');
});OTP verification test
test('OTP login', async ({ page }) => {
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
await page.goto('/login');
await page.fill('[name="email"]', inbox);
await page.click('[type="submit"]');
// Catch OTP email — code auto-extracted, no regex
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
expect(email.otp).not.toBeNull();
await page.fill('[name="otp"]', email.otp!);
await page.click('[type="submit"]');
await expect(page).toHaveURL('/dashboard');
});Password reset test
test('password reset flow', async ({ page }) => {
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
await page.goto('/forgot-password');
await page.fill('[name="email"]', inbox);
await page.click('[type="submit"]');
// Catch reset email
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
expect(email.magicLink).not.toBeNull();
await page.goto(email.magicLink!);
await page.fill('[name="password"]', 'NewPass123!');
await page.click('[type="submit"]');
await expect(page.getByText('Password updated')).toBeVisible();
});Parallel matrix builds
Each matrix job gets its own isolated inbox automatically:
jobs:
test:
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- name: Generate test inbox
id: inbox
uses: zerodrop-dev/create-inbox@8706a59
- name: Run tests
run: npx playwright test --project=${{ matrix.browser }}
env:
TEST_INBOX: ${{ steps.inbox.outputs.inbox }}Ready to test your Next.js email flows in CI?
Free tier. No signup. No Docker. Works in GitHub Actions in 5 minutes.