Testing Nodemailer Email Flows with Ethereal Fake SMTP in Node.js
Nodemailer is the most popular Node.js email library. Testing Nodemailer flows has two approaches: mock the transporter entirely (unit tests) or use Ethereal's fake SMTP server (integration tests). This guide covers both approaches, with Jest examples for testing email content, attachments, and error handling without sending real emails.
Key Takeaways
Mock the transporter for unit tests. Replace the Nodemailer transporter with a Jest mock to test that your email-sending logic passes correct arguments — no network needed.
Use Ethereal for integration tests. Ethereal generates temporary test SMTP accounts that capture emails in a web UI. Messages are accessible via a preview URL — ideal for manual inspection in CI.
Extract and test email content from sendMail arguments. In unit tests, capture what arguments were passed to sendMail and assert on subject, to, html, and attachments.
Test the service, not the transport. Your email service (building the message) should be tested separately from transport (actually sending it). This separation makes both easier to test.
Test error handling. What happens when sendMail throws a network error? A bounced address? An invalid attachment path? These are bugs waiting to happen in production.
Two Approaches to Testing Nodemailer
Approach 1: Mock the Transporter (Unit Tests)
Replace the Nodemailer transporter with a Jest mock. This tests your email construction logic in complete isolation — no network, no external service.
Best for: Testing that your service builds correct email content, subject lines, and recipients.
Approach 2: Ethereal Fake SMTP (Integration Tests)
Ethereal (nodemailer-smtp-transport) generates temporary fake SMTP accounts. Nodemailer actually sends through them, and messages appear in a web preview URL.
Best for: Testing that your email sending pipeline works end-to-end, and verifying the rendered HTML preview.
Setting Up Your Email Service
Structure your email service for testability — separate email construction from transport:
// src/email/service.js
import nodemailer from 'nodemailer';
import { renderTemplate } from './templates.js';
export class EmailService {
constructor(transporter) {
this.transporter = transporter ?? this._createTransporter();
}
_createTransporter() {
return nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT ?? '587'),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
}
async sendWelcomeEmail(user) {
const html = await renderTemplate('welcome', { name: user.name });
const info = await this.transporter.sendMail({
from: '"HelpMeTest" <noreply@helpmetest.com>',
to: user.email,
replyTo: 'support@helpmetest.com',
subject: 'Welcome to HelpMeTest!',
html,
text: `Welcome, ${user.name}! Visit https://helpmetest.com to get started.`,
});
return info;
}
async sendPasswordReset(user, resetToken) {
const resetUrl = `https://helpmetest.com/reset-password/${resetToken}`;
const html = await renderTemplate('password-reset', { name: user.name, resetUrl });
return this.transporter.sendMail({
from: '"HelpMeTest" <noreply@helpmetest.com>',
to: user.email,
subject: 'Reset your HelpMeTest password',
html,
});
}
async sendInvoice(user, invoice, pdfBuffer) {
const html = await renderTemplate('invoice', { invoice });
return this.transporter.sendMail({
from: '"HelpMeTest Billing" <billing@helpmetest.com>',
to: user.email,
subject: `Invoice ${invoice.number} from HelpMeTest`,
html,
attachments: [
{
filename: `invoice-${invoice.number}.pdf`,
content: pdfBuffer,
contentType: 'application/pdf',
},
],
});
}
}Unit Tests: Mocking the Transporter
// src/__tests__/email-service.test.js
import { jest } from '@jest/globals';
import { EmailService } from '../email/service.js';
function createMockTransporter() {
return {
sendMail: jest.fn().mockResolvedValue({
messageId: 'mock-message-id@example.com',
accepted: ['user@example.com'],
rejected: [],
}),
};
}
describe('EmailService.sendWelcomeEmail', () => {
let service;
let mockTransporter;
beforeEach(() => {
mockTransporter = createMockTransporter();
service = new EmailService(mockTransporter);
});
it('sends email to the correct recipient', async () => {
await service.sendWelcomeEmail({
email: 'jane@example.com',
name: 'Jane Smith',
});
const [callArgs] = mockTransporter.sendMail.mock.calls;
expect(callArgs[0].to).toBe('jane@example.com');
});
it('uses correct subject', async () => {
await service.sendWelcomeEmail({ email: 'x@x.com', name: 'X' });
const [args] = mockTransporter.sendMail.mock.calls;
expect(args[0].subject).toBe('Welcome to HelpMeTest!');
});
it('sends from branded address', async () => {
await service.sendWelcomeEmail({ email: 'x@x.com', name: 'X' });
const [args] = mockTransporter.sendMail.mock.calls;
expect(args[0].from).toContain('noreply@helpmetest.com');
expect(args[0].from).toContain('HelpMeTest');
});
it('includes reply-to header', async () => {
await service.sendWelcomeEmail({ email: 'x@x.com', name: 'X' });
const [args] = mockTransporter.sendMail.mock.calls;
expect(args[0].replyTo).toBe('support@helpmetest.com');
});
it('includes personalized name in HTML body', async () => {
await service.sendWelcomeEmail({ email: 'x@x.com', name: 'Alice Johnson' });
const [args] = mockTransporter.sendMail.mock.calls;
expect(args[0].html).toContain('Alice Johnson');
});
it('includes both html and text body', async () => {
await service.sendWelcomeEmail({ email: 'x@x.com', name: 'X' });
const [args] = mockTransporter.sendMail.mock.calls;
expect(args[0].html).toBeTruthy();
expect(args[0].text).toBeTruthy();
});
it('calls sendMail exactly once', async () => {
await service.sendWelcomeEmail({ email: 'x@x.com', name: 'X' });
expect(mockTransporter.sendMail).toHaveBeenCalledTimes(1);
});
});
describe('EmailService.sendPasswordReset', () => {
let service;
let mockTransporter;
beforeEach(() => {
mockTransporter = createMockTransporter();
service = new EmailService(mockTransporter);
});
it('includes reset token in email body', async () => {
const token = 'abc123def456';
await service.sendPasswordReset(
{ email: 'user@example.com', name: 'User' },
token
);
const [args] = mockTransporter.sendMail.mock.calls;
expect(args[0].html).toContain(`/reset-password/${token}`);
});
it('uses password reset subject', async () => {
await service.sendPasswordReset({ email: 'x@x.com', name: 'X' }, 'token123');
const [args] = mockTransporter.sendMail.mock.calls;
expect(args[0].subject).toContain('Reset');
});
});
describe('EmailService.sendInvoice', () => {
let service;
let mockTransporter;
beforeEach(() => {
mockTransporter = createMockTransporter();
service = new EmailService(mockTransporter);
});
it('attaches PDF with correct filename', async () => {
const pdfBuffer = Buffer.from('%PDF-1.4 fake content');
const invoice = { number: 'INV-001', total: 500 };
await service.sendInvoice({ email: 'client@example.com', name: 'Client' }, invoice, pdfBuffer);
const [args] = mockTransporter.sendMail.mock.calls;
expect(args[0].attachments).toHaveLength(1);
expect(args[0].attachments[0].filename).toBe('invoice-INV-001.pdf');
expect(args[0].attachments[0].contentType).toBe('application/pdf');
});
it('sends from billing address', async () => {
const pdfBuffer = Buffer.from('%PDF');
await service.sendInvoice({ email: 'x@x.com', name: 'X' }, { number: 'INV-001', total: 0 }, pdfBuffer);
const [args] = mockTransporter.sendMail.mock.calls;
expect(args[0].from).toContain('billing@helpmetest.com');
});
});Error Handling Tests
describe('EmailService error handling', () => {
it('propagates SMTP connection errors', async () => {
const mockTransporter = {
sendMail: jest.fn().mockRejectedValue(new Error('ECONNREFUSED')),
};
const service = new EmailService(mockTransporter);
await expect(
service.sendWelcomeEmail({ email: 'user@example.com', name: 'User' })
).rejects.toThrow('ECONNREFUSED');
});
it('propagates invalid recipient errors', async () => {
const mockTransporter = {
sendMail: jest.fn().mockRejectedValue(
Object.assign(new Error('Invalid address'), { code: 'EADDRESS' })
),
};
const service = new EmailService(mockTransporter);
await expect(
service.sendWelcomeEmail({ email: 'not-an-email', name: 'Bad' })
).rejects.toThrow();
});
it('returns info object on success', async () => {
const mockInfo = {
messageId: 'test@test.com',
accepted: ['user@example.com'],
};
const mockTransporter = {
sendMail: jest.fn().mockResolvedValue(mockInfo),
};
const service = new EmailService(mockTransporter);
const result = await service.sendWelcomeEmail({
email: 'user@example.com',
name: 'User',
});
expect(result.messageId).toBe('test@test.com');
expect(result.accepted).toContain('user@example.com');
});
});Integration Tests with Ethereal
Ethereal generates real SMTP credentials that capture emails without delivering them:
// tests/integration/email.test.js
import nodemailer from 'nodemailer';
import { EmailService } from '../../src/email/service.js';
describe('EmailService Ethereal integration', () => {
let testAccount;
let service;
beforeAll(async () => {
// Generate a temporary Ethereal test account
testAccount = await nodemailer.createTestAccount();
const transporter = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
});
service = new EmailService(transporter);
});
it('sends welcome email and provides preview URL', async () => {
const info = await service.sendWelcomeEmail({
email: testAccount.user, // Send to the test account itself
name: 'Integration Test User',
});
// Ethereal returns a URL where you can view the email
const previewUrl = nodemailer.getTestMessageUrl(info);
console.log('Preview URL:', previewUrl);
expect(info.accepted).toContain(testAccount.user);
expect(previewUrl).toMatch(/^https:\/\/ethereal\.email\/message\//);
});
it('sends password reset email with token in URL', async () => {
const info = await service.sendPasswordReset(
{ email: testAccount.user, name: 'Test User' },
'test-reset-token-12345'
);
const previewUrl = nodemailer.getTestMessageUrl(info);
console.log('Reset email preview:', previewUrl);
expect(info.rejected).toHaveLength(0);
});
});The getTestMessageUrl(info) function returns a URL like https://ethereal.email/message/abc123 where you can view the rendered email in a browser — useful for manually verifying HTML rendering in CI artifacts.
Testing Template Rendering
Test email templates independently from the sending logic:
// src/__tests__/email-templates.test.js
import { renderTemplate } from '../email/templates.js';
describe('Welcome email template', () => {
it('renders with user name', async () => {
const html = await renderTemplate('welcome', { name: 'Bob Smith' });
expect(html).toContain('Bob Smith');
});
it('contains getting started link', async () => {
const html = await renderTemplate('welcome', { name: 'User' });
expect(html).toMatch(/href="https?:\/\/helpmetest\.com/);
});
it('has unsubscribe link', async () => {
const html = await renderTemplate('welcome', { name: 'User', unsubscribeUrl: 'https://helpmetest.com/unsubscribe/token123' });
expect(html).toContain('Unsubscribe');
expect(html).toContain('/unsubscribe/');
});
it('is valid HTML (has html, head, body tags)', async () => {
const html = await renderTemplate('welcome', { name: 'User' });
expect(html.toLowerCase()).toContain('<html');
expect(html.toLowerCase()).toContain('<body');
expect(html.toLowerCase()).toContain('</html>');
});
it('contains inline CSS (for email client compatibility)', async () => {
const html = await renderTemplate('welcome', { name: 'User' });
// Email templates should have inline styles, not linked stylesheets
expect(html).toMatch(/style="[^"]+"/);
expect(html).not.toContain('<link rel="stylesheet"');
});
});Testing With Jest's spyOn Pattern
For code that creates the transporter internally, use jest.spyOn:
import nodemailer from 'nodemailer';
import { sendWelcomeEmail } from '../src/email/standalone.js';
describe('sendWelcomeEmail (standalone function)', () => {
let createTransportSpy;
let sendMailMock;
beforeEach(() => {
sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test@test.com' });
createTransportSpy = jest.spyOn(nodemailer, 'createTransport')
.mockReturnValue({ sendMail: sendMailMock });
});
afterEach(() => {
createTransportSpy.mockRestore();
});
it('sends to correct address', async () => {
await sendWelcomeEmail('user@example.com', 'User Name');
expect(sendMailMock).toHaveBeenCalledWith(
expect.objectContaining({ to: 'user@example.com' })
);
});
});CI Configuration
name: Email Unit 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: npm test -- --testPathPattern='email'
# No external services needed for mocked unit tests
# Ethereal tests also work without additional services (it uses the cloud)Choosing the Right Approach
| Scenario | Recommended approach |
|---|---|
| Testing that correct arguments are passed | Mock transporter (unit) |
| Testing HTML template content | Mock transporter or template unit tests |
| Testing that Nodemailer is configured correctly | Ethereal integration test |
| Testing full send pipeline in CI | Ethereal or MailHog |
| Reviewing rendered HTML visually | Ethereal (has preview URL) |
| Parallel CI tests with no race conditions | Mock transporter |
For the full cycle — email arrives, user clicks the link, browser opens the correct page — HelpMeTest adds the browser-level E2E layer that complements Nodemailer unit tests.