DEV Community

Cover image for How to Test Nodemailer Email Flows in Playwright
zerodrop
zerodrop

Posted on

How to Test Nodemailer Email Flows in Playwright

Nodemailer is the most widely used email library in Node.js. If your Express, Next.js, or NestJS app sends emails, there's a good chance it's using Nodemailer under the hood.

But testing those emails end-to-end in CI has always been the hard part. This guide shows how to test the complete email flow with Playwright and ZeroDrop — no Docker, no fake SMTP server, no mocking.


The typical Nodemailer setup

// lib/email.ts
import nodemailer from 'nodemailer';

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: Number(process.env.SMTP_PORT),
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});

export async function sendVerificationEmail(to: string, token: string) {
  const verifyUrl = `${process.env.APP_URL}/verify?token=${token}`;

  await transporter.sendMail({
    from: 'noreply@yourapp.com',
    to,
    subject: 'Verify your email',
    html: `<p>Click <a href="${verifyUrl}">here</a> to verify your email.</p>`,
  });
}

export async function sendPasswordResetEmail(to: string, token: string) {
  const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;

  await transporter.sendMail({
    from: 'noreply@yourapp.com',
    to,
    subject: 'Reset your password',
    html: `<p>Click <a href="${resetUrl}">here</a> to reset your password.</p>`,
  });
}

export async function sendOTPEmail(to: string, otp: string) {
  await transporter.sendMail({
    from: 'noreply@yourapp.com',
    to,
    subject: 'Your verification code',
    html: `<p>Your code is: <strong>${otp}</strong></p>`,
  });
}
Enter fullscreen mode Exit fullscreen mode

Stage 1 — Local development: Nodemailer test account

Nodemailer has a built-in test account generator using Ethereal Email:

// For local development only
const testAccount = await nodemailer.createTestAccount();

const transporter = nodemailer.createTransport({
  host: 'smtp.ethereal.email',
  port: 587,
  auth: {
    user: testAccount.user,
    pass: testAccount.pass,
  },
});

// After sending, preview the email
const info = await transporter.sendMail({ ... });
console.log('Preview URL:', nodemailer.getTestMessageUrl(info));
Enter fullscreen mode Exit fullscreen mode

This is useful for inspecting emails locally — the preview URL opens in a browser. But you can't automate reading from Ethereal in a Playwright test, and it doesn't work in CI.

What it solves: Visual inspection during local development.

What it doesn't solve: Automated testing. No programmatic API to read the email in a test.


Stage 2 — CI: Nodemailer + real SMTP + ZeroDrop

For automated testing in GitHub Actions, point Nodemailer at a real SMTP provider (Resend, SendGrid, Postmark) and use ZeroDrop to catch the emails:

npm install zerodrop-client
Enter fullscreen mode Exit fullscreen mode
import { test, expect } from '@playwright/test';
import { ZeroDrop } from 'zerodrop-client';

const mail = new ZeroDrop();

test('email verification flow', async ({ page }) => {
  // 1. Generate a disposable inbox
  const inbox = process.env.TEST_INBOX ?? mail.generateInbox();

  // 2. Sign up — Nodemailer sends the verification email
  await page.goto('/signup');
  await page.fill('[name="email"]', inbox);
  await page.fill('[name="password"]', 'TestPass123!');
  await page.click('[type="submit"]');

  await expect(page).toHaveURL('/check-email');

  // 3. ZeroDrop catches the email — magic link auto-extracted
  const email = await mail.waitForLatest(inbox, { timeout: 30000 });

  expect(email.subject).toContain('Verify your email');
  expect(email.magicLink).not.toBeNull();

  // 4. Click the verification link
  await page.goto(email.magicLink!);
  await expect(page).toHaveURL('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

OTP flow

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"]');

  // OTP auto-extracted at the edge — no regex needed
  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');
});
Enter fullscreen mode Exit fullscreen mode

Password reset flow

test('password reset', 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"]');

  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();
});
Enter fullscreen mode Exit fullscreen mode

Configuring Nodemailer for CI

The key is using a real SMTP provider in CI while keeping Ethereal for local development:

// lib/email.ts
const isCI = process.env.CI === 'true';

const transporter = isCI
  ? nodemailer.createTransport({
      // Real SMTP in CI — emails actually delivered
      host: process.env.SMTP_HOST,      // e.g. smtp.resend.com
      port: 587,
      auth: {
        user: process.env.SMTP_USER,    // e.g. resend
        pass: process.env.SMTP_PASS,    // e.g. your Resend API key
      },
    })
  : nodemailer.createTransport({
      // Ethereal locally — visual inspection only
      host: 'smtp.ethereal.email',
      port: 587,
      auth: {
        user: process.env.ETHEREAL_USER,
        pass: process.env.ETHEREAL_PASS,
      },
    });
Enter fullscreen mode Exit fullscreen mode

Using Resend as the SMTP backend:

SMTP_HOST=smtp.resend.com
SMTP_PORT=587
SMTP_USER=resend
SMTP_PASS=re_your_api_key
Enter fullscreen mode Exit fullscreen mode

Using SendGrid:

SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=SG.your_api_key
Enter fullscreen mode Exit fullscreen mode

GitHub Actions workflow

name: E2E Tests

on: [push, pull_request]

jobs:
  test:
    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 chromium

      - name: Generate test inbox
        id: inbox
        uses: zerodrop-dev/create-inbox@8706a59 # v1.0.0

      - name: Run E2E tests
        run: npx playwright test
        env:
          CI: true
          TEST_INBOX: ${{ steps.inbox.outputs.inbox }}
          SMTP_HOST: smtp.resend.com
          SMTP_PORT: 587
          SMTP_USER: resend
          SMTP_PASS: ${{ secrets.RESEND_API_KEY }}
          APP_URL: ${{ secrets.STAGING_URL }}
Enter fullscreen mode Exit fullscreen mode

The full picture

Ethereal (local) Real SMTP (staging) Real SMTP + ZeroDrop (CI)
Visual inspection ✅ preview URL ✅ real inbox ✅ automated
No real emails sent
Automated in CI
Parallel test runs
OTP auto-extraction
Tests real delivery

Use Ethereal locally for template inspection. Use a real SMTP provider + ZeroDrop in CI for full end-to-end coverage.


Why not use MailHog?

MailHog is the traditional alternative — a fake SMTP server that catches emails locally. The problems:

  • Requires a running Docker container in CI
  • Adds 15-30 seconds cold start time per CI run
  • Parallel tests share one inbox — race conditions when multiple tests send emails simultaneously
  • Unmaintained since 2020

ZeroDrop solves all three: no Docker, no cold start, and every test gets its own isolated inbox.


ZeroDrop — disposable email inboxes for CI pipelines. Free, no signup, no Docker.
zerodrop.dev · docs · npm

Top comments (0)