Skip to content

Testing ​

Mocking MailerService tells you a method was called, not what the user would receive. MailerTestModule runs the real mailer (templates, i18n, mail classes, delivery rules) on a transport that sends nothing, and gives you a TestMailbox with every email captured after rendering, plus assertions to check recipients, subjects, links and attachments. New in v4.

Setup ​

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { MailerTestModule, TestMailbox } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/adapters/handlebars.adapter';
import { join } from 'node:path';
import { UsersService } from './users.service';
import { WelcomeMail } from './mail/welcome.mail';

describe('UsersService', () => {
  let moduleRef: TestingModule;
  let mailbox: TestMailbox;

  beforeEach(async () => {
    moduleRef = await Test.createTestingModule({
      imports: [
        MailerTestModule.register({
          defaults: { from: 'App <app@example.com>' },
          template: {
            dir: join(__dirname, 'templates'),
            adapter: new HandlebarsAdapter(),
          },
        }),
      ],
      providers: [UsersService],
    }).compile();

    mailbox = moduleRef.get(TestMailbox);
  });

  afterEach(() => moduleRef.close());

  it('sends a welcome email with a verification link', async () => {
    await moduleRef.get(UsersService).onSignup({ email: 'ada@example.com', name: 'Ada', token: 'abc' });

    const mail = mailbox.assertSent({ to: 'ada@example.com', mail: WelcomeMail });
    expect(mail.subject).toBe('Welcome, Ada');
    expect(mail.link('/verify').searchParams.get('token')).toBe('abc');
  });
});

MailerTestModule.register(options) accepts any MailerOptions except transport and transports: templates, i18n, defaults, intercept, suppress, plugins and so on. The module's TestMailbox is the transport: each email is built once, as a real transport would build it (stream attachments included), and kept in memory, so nothing leaves the process. The module is global and provides MailerService, MailerBatchService, MailerEventService and TestMailbox.

retry defaults to false in tests, so a simulated failure fails right away. Pass retry explicitly to test retry behavior.

Querying the mailbox ​

MemberDescription
mailsEvery captured email, oldest first
lastThe most recent email, or undefined
find(query?)The latest email matching the query, or undefined
filter(query?)Every email matching the query
assertSent(query?)Returns the latest match, or throws listing what was sent
assertNotSent(query?)Throws if any email matches (by default: if any email was sent)
assertCount(count, query?)Throws unless exactly count emails match
failNext(error?)Makes the next send fail with error; call it again to queue more failures
clear()Forgets captured emails and pending failures

A query matches when every field given matches:

FieldMatches
toA recipient in to, cc, bcc or the envelope (case-insensitive)
subjectThe exact subject, or a RegExp
templateThe template name
mailThe mail class passed to send(MailClass)
whereAny other condition: (mail: CapturedMail) => boolean
typescript
mailbox.assertCount(2, { subject: /invoice/i });
mailbox.assertNotSent({ to: 'bounced@example.com' });
mailbox.assertSent({ where: (mail) => mail.attachments.length === 1 });

Captured emails ​

Each entry is a CapturedMail with the fields as sent, after templates were rendered:

FieldDescription
messageIdThe Message-ID
fromThe sender address
to, cc, bccRecipient addresses as plain strings
subject, html, textThe rendered content
template, context, localeWhat was passed to the template
headers, attachmentsAs given to nodemailer
mailableThe mail class, when sent with send(MailClass)
envelopeThe SMTP envelope: { from, to }, with every recipient
rawThe MIME source, as an .eml file holds it
sentAtWhen it was captured
linksEvery href in the HTML body, entities decoded
link(match)The first link containing the string (or matching the RegExp), parsed as a URL; throws if none
attachment(filename)The attachment with that filename; throws if none
typescript
const mail = mailbox.assertSent({ to: 'ada@example.com' });
const invoice = mail.attachment('invoice.pdf');
expect(invoice.contentType).toBe('application/pdf');
expect(mail.raw).toContain('X-Invoice-Id: 42');

Simulating failures ​

typescript
it('reports a bounced address', async () => {
  mailbox.failNext(Object.assign(new Error('550 No such user'), { responseCode: 550 }));

  await expect(service.notify('nobody@example.com')).rejects.toThrow('550');
  mailbox.assertNotSent();
});

To test retries, enable them and use a fast backoff:

typescript
MailerTestModule.register({ retry: { attempts: 2, backoff: { delay: 0, jitter: 'none' } } });

mailbox.failNext(Object.assign(new Error('Connection lost'), { code: 'ECONNECTION' }));
await service.notify('ada@example.com');
mailbox.assertCount(1); // the second attempt was captured

Call mailbox.clear() in a beforeEach when one mailbox is shared across tests.

Using TestMailbox as a transport ​

TestMailbox is also a nodemailer transport. Use it directly when you build the module yourself, for example to test your real MailerModule configuration:

typescript
import { Test } from '@nestjs/testing';
import { MailerModule, TestMailbox } from '@nestjs-modules/mailer';

const mailbox = new TestMailbox();

const moduleRef = await Test.createTestingModule({
  imports: [
    MailerModule.forRoot({
      transport: mailbox,
      retry: false,
    }),
  ],
}).compile();

Its send() result looks like a successful SMTP send: accepted lists the envelope recipients, response is 250 OK: captured by TestMailbox, and message is a Buffer with the raw MIME source (CRLF line endings).

Raw messages (raw, as @nestjs/mail sends them) are captured too; their from, to, cc and subject are read from the MIME headers (rawHeader()).

Capturing next to another transport ​

mailbox.plugin() returns a nodemailer stream plugin that captures every email while your configured transport still sends it, for example in an end-to-end test against a local SMTP catcher:

typescript
const mailbox = new TestMailbox();

MailerModule.forRoot({
  transport: { host: 'localhost', port: 1025, ignoreTLS: true },
  plugins: [mailbox.plugin()],
});

Without Nest ​

MailerService can be created directly, without a Nest module or a transport factory, which is handy in plain unit tests or scripts:

typescript
import { MailerService, TestMailbox } from '@nestjs-modules/mailer';

const mailbox = new TestMailbox();
const mailer = new MailerService({ transport: mailbox, retry: false });

await mailer.sendMail({ to: 'ada@example.com', subject: 'Hi', text: 'Hi' });
mailbox.assertSent({ subject: 'Hi' });

Mail classes are then created with new (no dependency injection).

TIP

TestMailbox, FileTransport and LogTransport never deliver email. If one is configured while NODE_ENV is production, the mailer logs a warning at startup.

Released under the MIT License.