SMTP Testing: How to Test Email Sending in Node.js, Python, and Go
SMTP testing validates that your application correctly constructs and sends emails through an SMTP server. The strategy differs based on what you're testing: unit tests mock the transport, integration tests use a fake SMTP server.
Two Levels of Email Tests
Unit tests verify email construction — subject, body, recipients, attachments — without sending anything. Mock the SMTP transport.
Integration tests verify the full send path — application connects to SMTP, message is delivered. Use a fake SMTP server (Mailpit, MailHog).
Node.js with Nodemailer
Nodemailer is the standard email library for Node.js. It supports pluggable transports, which makes testing straightforward.
Unit Testing with createTransport Mock
// email-service.js
import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
}
})
export async function sendWelcomeEmail(user) {
await transporter.sendMail({
from: 'noreply@myapp.com',
to: user.email,
subject: `Welcome to MyApp, ${user.name}!`,
html: `<p>Hi ${user.name}, confirm your account: <a href="${user.confirmUrl}">Confirm</a></p>`,
text: `Hi ${user.name}, confirm your account: ${user.confirmUrl}`,
})
}// email-service.test.js
import { vi, test, expect, beforeEach } from 'vitest'
import nodemailer from 'nodemailer'
vi.mock('nodemailer')
const mockSendMail = vi.fn().mockResolvedValue({ messageId: 'test-id' })
nodemailer.createTransport.mockReturnValue({ sendMail: mockSendMail })
// Re-import after mock
const { sendWelcomeEmail } = await import('./email-service.js')
beforeEach(() => {
mockSendMail.mockClear()
})
test('welcome email has correct recipient and subject', async () => {
await sendWelcomeEmail({
email: 'alice@example.com',
name: 'Alice',
confirmUrl: 'https://myapp.com/confirm/abc123'
})
expect(mockSendMail).toHaveBeenCalledOnce()
const [mailOptions] = mockSendMail.mock.calls[0]
expect(mailOptions.to).toBe('alice@example.com')
expect(mailOptions.subject).toBe('Welcome to MyApp, Alice!')
expect(mailOptions.html).toContain('https://myapp.com/confirm/abc123')
expect(mailOptions.from).toBe('noreply@myapp.com')
})
test('does not send email when confirmUrl is missing', async () => {
await expect(sendWelcomeEmail({ email: 'alice@example.com', name: 'Alice' }))
.rejects.toThrow()
expect(mockSendMail).not.toHaveBeenCalled()
})Integration Testing with Nodemailer + Mailpit
// Use real transporter, fake SMTP server
import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
host: 'localhost',
port: 1025,
ignoreTLS: true,
})
test('sends email through SMTP', async () => {
await transporter.sendMail({
from: 'test@example.com',
to: 'user@example.com',
subject: 'Test',
text: 'Hello'
})
const res = await fetch('http://localhost:8025/api/v1/messages')
const { messages } = await res.json()
expect(messages[0].Subject).toBe('Test')
})Python with smtplib
Python's standard library includes smtplib for sending emails and email for constructing them.
Mocking smtplib in Unit Tests
# email_service.py
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import os
def send_welcome_email(to_email: str, name: str, confirm_url: str):
msg = MIMEMultipart('alternative')
msg['From'] = 'noreply@myapp.com'
msg['To'] = to_email
msg['Subject'] = f'Welcome to MyApp, {name}!'
html = f'<p>Hi {name}, <a href="{confirm_url}">confirm your account</a></p>'
text = f'Hi {name}, confirm your account: {confirm_url}'
msg.attach(MIMEText(text, 'plain'))
msg.attach(MIMEText(html, 'html'))
with smtplib.SMTP(os.environ['SMTP_HOST'], int(os.environ['SMTP_PORT'])) as smtp:
smtp.sendmail('noreply@myapp.com', to_email, msg.as_string())# test_email_service.py
from unittest.mock import patch, MagicMock
import pytest
from email_service import send_welcome_email
def test_welcome_email_recipients_and_subject():
with patch('smtplib.SMTP') as mock_smtp_class:
mock_smtp = MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
send_welcome_email('alice@example.com', 'Alice', 'https://app.com/confirm/abc')
mock_smtp.sendmail.assert_called_once()
from_addr, to_addr, message = mock_smtp.sendmail.call_args[0]
assert from_addr == 'noreply@myapp.com'
assert to_addr == 'alice@example.com'
assert 'Welcome to MyApp, Alice!' in message
assert 'https://app.com/confirm/abc' in messageGo with net/smtp
// email.go
package email
import (
"fmt"
"net/smtp"
)
type Sender interface {
SendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error
}
type RealSender struct{}
func (r RealSender) SendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
return smtp.SendMail(addr, a, from, to, msg)
}
type EmailService struct {
Sender Sender
Host string
Port int
}
func (s *EmailService) SendWelcome(to, name, confirmURL string) error {
subject := fmt.Sprintf("Welcome to MyApp, %s!", name)
body := fmt.Sprintf("Hi %s, confirm your account: %s", name, confirmURL)
msg := []byte(fmt.Sprintf(
"From: noreply@myapp.com\r\nTo: %s\r\nSubject: %s\r\n\r\n%s",
to, subject, body,
))
addr := fmt.Sprintf("%s:%d", s.Host, s.Port)
return s.Sender.SendMail(addr, nil, "noreply@myapp.com", []string{to}, msg)
}// email_test.go
package email
import (
"net/smtp"
"strings"
"testing"
)
type MockSender struct {
Calls []struct {
Addr string
From string
To []string
Msg []byte
}
}
func (m *MockSender) SendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
m.Calls = append(m.Calls, struct {
Addr string
From string
To []string
Msg []byte
}{addr, from, to, msg})
return nil
}
func TestSendWelcome(t *testing.T) {
mock := &MockSender{}
svc := &EmailService{Sender: mock, Host: "localhost", Port: 1025}
err := svc.SendWelcome("alice@example.com", "Alice", "https://app.com/confirm/abc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(mock.Calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(mock.Calls))
}
call := mock.Calls[0]
if call.To[0] != "alice@example.com" {
t.Errorf("expected to=alice@example.com, got %s", call.To[0])
}
if !strings.Contains(string(call.Msg), "Welcome to MyApp, Alice!") {
t.Error("email missing expected subject")
}
}Testing Email Attachments
test('invoice email includes PDF attachment', async () => {
await sendInvoice({ email: 'customer@example.com', invoiceId: 'INV-001' })
const messages = await getMailpitMessages()
const email = messages[0]
// Check via Mailpit API
const msgDetail = await fetch(`http://localhost:8025/api/v1/message/${email.ID}`)
const detail = await msgDetail.json()
expect(detail.Attachments).toHaveLength(1)
expect(detail.Attachments[0].FileName).toBe('invoice-INV-001.pdf')
expect(detail.Attachments[0].ContentType).toBe('application/pdf')
})Testing Rate Limits and Retries
test('retries on SMTP connection failure', async () => {
let attempts = 0
const mockTransport = {
sendMail: vi.fn().mockImplementation(async () => {
attempts++
if (attempts < 3) throw new Error('ECONNREFUSED')
return { messageId: 'ok' }
})
}
await sendEmailWithRetry(mockTransport, mailOptions)
expect(mockTransport.sendMail).toHaveBeenCalledTimes(3)
})Summary
- Unit tests: mock the SMTP transport, assert on
sendMailcall arguments - Integration tests: use Mailpit/MailHog, assert on captured messages via HTTP API
- Attachment tests: retrieve full message detail from the fake SMTP API
- Retry tests: mock the transport to fail N times, verify retry logic