SMTP Testing: How to Test Email Sending in Node.js, Python, and Go

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 message

Go 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 sendMail call 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

Read more