DEV Community

Cover image for I got tired of janky nodemailer mocks on every project
Pruthvisinh Rajput
Pruthvisinh Rajput

Posted on

I got tired of janky nodemailer mocks on every project

Every Node.js project I've joined has the same file somewhere.

A giant jest.mock() block for nodemailer. Some manual spy setup. A comment that says "don't touch this." And at least one developer who spent three hours debugging it when transporter.sendMail silently stopped being called in tests.

I kept rebuilding the same abstraction on every project. Eventually I stopped rebuilding it and just shipped it as a library.


The fix: Mail.fake()

Mail.fake();

await Mail.to('user@example.com').send(new WelcomeEmail(user));

Mail.assertSent(WelcomeEmail, (mail) => mail.hasTo('user@example.com'));
Enter fullscreen mode Exit fullscreen mode

No SMTP server. No network. No mock setup. The test fails if the wrong email goes to the wrong address. That's it.


What actually goes wrong with nodemailer mocks

Here's the pattern I kept seeing:

// Some version of this exists in nearly every Node.js codebase
jest.mock('nodemailer', () => ({
  createTransport: jest.fn().mockReturnValue({
    sendMail: jest.fn().mockResolvedValue({ messageId: 'test-id' }),
  }),
}));
Enter fullscreen mode Exit fullscreen mode

It works. Until it doesn't.

  • Someone upgrades nodemailer and the mock stops matching the real API
  • A new developer adds a new email send call and the mock swallows it silently
  • You want to assert the email went to the right address and you're digging through sendMail.mock.calls[0][0].to
  • You switch from nodemailer to Resend and your entire mock layer needs to be rebuilt

Mail.fake() intercepts at the abstraction layer, not the transport layer. Your tests don't know or care what provider you're using.


A real test

import { Mail } from 'laramail';
import { WelcomeEmail } from './emails/WelcomeEmail';

describe('Registration', () => {
  beforeEach(() => Mail.fake());
  afterEach(() => Mail.restore());

  it('sends a welcome email on signup', async () => {
    await registerUser({ name: 'John', email: 'john@example.com' });

    // Did the right email class get sent?
    Mail.assertSent(WelcomeEmail);

    // Did it go to the right address?
    Mail.assertSent(WelcomeEmail, (mail) =>
      mail.hasTo('john@example.com')
    );

    // Did it have the right subject?
    Mail.assertSent(WelcomeEmail, (mail) =>
      mail.subjectContains('Welcome')
    );

    // Was it sent exactly once? Was nothing else sent?
    Mail.assertSentCount(WelcomeEmail, 1);
    Mail.assertNotSent(PasswordResetEmail);
  });
});
Enter fullscreen mode Exit fullscreen mode

Assertions that read like plain English. No mock.calls[0][0].


The Mailable class

The WelcomeEmail above is a Mailable — a self-contained, testable email object. This is the pattern from Laravel (and AdonisJS) that I missed most in Node.js.

import { Mailable } from 'laramail';

class WelcomeEmail extends Mailable {
  constructor(private user: { name: string; email: string }) {
    super();
  }

  build() {
    return this
      .subject(`Welcome, ${this.user.name}!`)
      .html(`
        <h1>Hello ${this.user.name}!</h1>
        <p>Thanks for joining. Your account is ready.</p>
      `);
  }
}

await Mail.to(user.email).send(new WelcomeEmail(user));
Enter fullscreen mode Exit fullscreen mode

Your email logic lives in one place. Easy to test, easy to reuse, easy to find.


Setup

import { Mail } from 'laramail';

Mail.configure({
  default: process.env.MAIL_DRIVER,
  from: { address: 'noreply@example.com', name: 'My App' },
  mailers: {
    smtp: {
      driver: 'smtp',
      host: process.env.SMTP_HOST,
      port: 587,
      auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
    },
    resend: {
      driver: 'resend',
      apiKey: process.env.RESEND_API_KEY,
    },
    sendgrid: {
      driver: 'sendgrid',
      apiKey: process.env.SENDGRID_API_KEY,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Switch providers by changing MAIL_DRIVER in your .env. No code changes anywhere else. That's a bonus side effect — the testing story is the reason I built this.


One more DX feature: staging redirect

In staging you never want to accidentally email real users. One line:

if (process.env.NODE_ENV === 'staging') {
  Mail.alwaysTo('dev@example.com');
}
Enter fullscreen mode Exit fullscreen mode

Every email your staging environment sends goes to dev@example.com. CC and BCC cleared automatically. Works with Mail.fake() too — fake mode bypasses it so your test assertions still see the original recipients.


What's included

  • Mail.fake() + Mail.assertSent() — zero-setup email testing
  • Mailable classes — self-contained, reusable, testable email objects
  • 6 providers — SMTP, SendGrid, AWS SES, Mailgun, Resend, Postmark
  • Provider failover — auto-chain if a provider fails
  • Rate limiting — sliding window, configurable per-mailer
  • Queue support — Bull / BullMQ
  • Template engines — Handlebars, EJS, Pug
  • Markdown emails — built-in components
  • Log transport — console output in dev, zero SMTP config
  • Staging redirectMail.alwaysTo()
  • CLI toolslaramail preview, laramail send-test, and more
  • 774 tests, TypeScript-first

The one-line pitch: AdonisJS mailer, but framework-agnostic. Works with Express, Fastify, NestJS, or any Node.js app.


Get started

npm install laramail
Enter fullscreen mode Exit fullscreen mode
  • GitHub — star it if this solves a problem you've hit
  • Documentation — full API reference including testing docs
  • npm

If you've been living with a fragile nodemailer mock on every project — I built this for you.

Top comments (0)