SpringMail 邮件服务 - 第三章. Mailpit 与 GreenMail

SpringMail 邮件服务 - 第三章. Mailpit 与 GreenMail

本章将彻底解决 “调试邮件功能污染真实邮箱” 的痛点,掌握 Mailpit 可视化调试和 GreenMail 自动化测试两大利器。

本章学习路径

  • 认知阶段:理解本地邮件服务器的价值
  • 工具阶段:掌握 Mailpit 的安装与使用
  • 测试阶段:编写 GreenMail 集成测试
  • 实战阶段:构建完整的邮件测试体系

3.1. 为什么不应该连接真实邮箱调试

在第二章中,我们每次测试邮件功能都要真实发送到某个邮箱。这在学习阶段没问题,但在实际开发中会带来三个严重问题。

3.1.1. 真实邮箱调试的三大痛点

痛点一:污染收件箱

假设你正在开发一个用户注册功能,需要发送验证码邮件。在调试过程中,你可能需要测试 20 次才能把格式调整满意。这意味着你的测试邮箱会收到 20 封内容几乎相同的邮件。

更糟糕的是,如果你在测试 “批量发送” 功能,一次测试可能发出上百封邮件。这些测试邮件会淹没你的收件箱,甚至可能触发邮箱服务商的反垃圾机制,导致你的账号被临时封禁。

痛点二:无法验证发送失败的场景

在生产环境中,邮件发送可能因为各种原因失败:收件人邮箱不存在、SMTP 服务器拒绝连接、邮件体积超限等。

但在开发阶段,如果你用真实邮箱测试,很难模拟这些失败场景。你无法故意让 QQ 邮箱 “拒绝连接”,也无法创建一个 “不存在的邮箱” 来测试错误处理逻辑。

痛点三:团队协作困难

在团队开发中,每个开发者都需要配置自己的测试邮箱。这带来两个问题:

  1. 每个人的配置文件都不同,容易误提交到 Git(泄露密码)
  2. 测试邮件分散在不同人的邮箱中,无法统一查看和对比

3.1.2. 本地邮件服务器的价值

本地邮件服务器(如 Mailpit、GreenMail)可以完美解决上述问题。它们的核心思想是:在你的开发机器上启动一个 “假的 SMTP 服务器”,拦截所有发出的邮件,但不真正投递。

这带来了四个关键优势:

优势一:零污染

所有测试邮件都被拦截在本地,不会发送到真实邮箱。你可以随意测试,发送一万封邮件也不会有任何副作用。

优势二:可视化调试

Mailpit 提供了一个 Web 界面,你可以像使用 Gmail 一样查看所有拦截的邮件,检查 HTML 渲染效果、附件内容、邮件头信息。

优势三:自动化测试

GreenMail 可以嵌入到 JUnit 测试中,让你编写断言验证 “邮件是否发送成功”、“收件人是否正确”、“附件是否完整”。

优势四:团队统一

所有开发者使用相同的本地服务器配置(如 localhost:1025),不需要各自准备测试邮箱,配置文件可以安全地提交到 Git。


3.2. Mailpit:MailHog 的现代继任者

Mailpit 是目前最流行的本地邮件调试工具,它继承了 MailHog 的优秀设计,并修复了后者的诸多问题。

3.2.1. MailHog 停止维护的背景

在 2020 年之前,MailHog 是开发者的首选工具。它用 Go 语言编写,启动速度快,Web 界面简洁。但从 2020 年开始,MailHog 的作者停止了维护,最后一个版本停留在 v1.0.1。

随着时间推移,MailHog 暴露出一些问题:

  • 不支持 ARM 架构(无法在 M1/M2 Mac 上原生运行)
  • Web 界面老旧,不支持暗黑模式
  • 搜索功能弱,无法按发件人、主题过滤
  • 没有邮件保留策略,长时间运行会占用大量内存

社区呼吁作者更新,但始终没有回应。于是在 2022 年,一位新西兰开发者 axllent 创建了 Mailpit 项目,重写了 MailHog 的核心功能,并加入了许多现代化特性。

3.2.2. Mailpit 的核心优势

Mailpit 相比 MailHog 有以下改进:

改进一:跨平台支持

原生支持 x86、ARM、Apple Silicon,在 M1/M2 Mac 上运行流畅。

改进二:现代化 UI

  • 响应式设计,支持移动端访问
  • 暗黑模式
  • 实时搜索和过滤

改进三:高级功能

  • 支持 SMTP 认证(可以模拟需要密码的 SMTP 服务器)
  • 支持 HTTPS(可以测试 SSL 连接)
  • 邮件自动过期(避免内存占用过高)
  • REST API(可以通过接口查询邮件)

改进四:活跃维护

截至 2026 年,Mailpit 仍在积极更新,每月都有新版本发布。

3.2.3. Docker 一键启动 Mailpit

Mailpit 提供了官方 Docker 镜像,这是最简单的启动方式。

步骤 1:确认 Docker 已安装

在终端执行以下命令,确认 Docker 正常运行:

1
docker --version

如果输出类似 Docker version 24.0.7 的信息,说明 Docker 已安装。如果提示命令不存在,请先安装 Docker Desktop。

步骤 2:启动 Mailpit 容器

在终端执行以下命令:

1
docker run -d --name mailpit -p 8025:8025 -p 1025:1025 axllent/mailpit:latest

命令解读:

  • -d:后台运行容器
  • --name mailpit:给容器命名为 mailpit(方便后续管理)
  • -p 8025:8025:映射 Web 界面端口(访问 http://localhost: 8025 查看邮件)
  • -p 1025:1025:映射 SMTP 端口(Spring Boot 连接这个端口发送邮件)
  • axllent/mailpit:latest:使用最新版本的 Mailpit 镜像

执行后,Docker 会自动下载镜像并启动容器。首次运行需要下载约 20MB 的镜像,后续启动只需 1 秒。

步骤 3:验证 Mailpit 是否启动成功

打开浏览器,访问 http://localhost:8025,你应该能看到 Mailpit 的 Web 界面。界面类似邮箱客户端,左侧是邮件列表(目前为空),右侧是邮件详情。

image-20260125105823708

如果无法访问,执行以下命令检查容器状态:

1
docker ps

你应该能看到一行包含 mailpit 的记录,状态为 Up。如果状态是 Exited,说明容器启动失败,执行 docker logs mailpit 查看错误日志。

常用管理命令

1
2
3
4
5
6
7
8
9
10
11
# 停止 Mailpit
docker stop mailpit

# 启动 Mailpit(容器已存在时)
docker start mailpit

# 删除 Mailpit 容器
docker rm -f mailpit

# 查看 Mailpit 日志
docker logs -f mailpit

3.2.4. 修改 Spring Boot 配置连接 Mailpit

Mailpit 启动后,我们需要修改 Spring Boot 的配置,让邮件发送到 Mailpit 而不是真实的 SMTP 服务器。

src/main/resources 目录下创建一个新的配置文件 application-dev.yml(开发环境专用配置):

📄 文件:src/main/resources/application-dev.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
mail:
host: localhost
port: 1025
username:
password:
properties:
mail:
smtp:
auth: false
starttls:
enable: false
required: false

配置解读:

  • host: localhost:连接本地的 Mailpit
  • port: 1025:Mailpit 的 SMTP 端口
  • usernamepassword 留空:Mailpit 默认不需要认证
  • auth: false:关闭 SMTP 认证
  • starttls.enable: false:关闭加密(本地调试不需要)

现在修改 application.yml,在文件开头添加:

1
2
3
spring:
profiles:
active: dev

这样 Spring Boot 会自动加载 application-dev.yml 的配置。

为什么要分离配置文件?

这样做有两个好处:

  1. 环境隔离:开发环境用 Mailpit,生产环境用真实 SMTP,通过 spring.profiles.active 切换
  2. 安全性application-dev.yml 不包含敏感信息,可以安全提交到 Git

在生产环境部署时,只需要设置环境变量 SPRING_PROFILES_ACTIVE=prod,Spring Boot 就会加载 application-prod.yml 中的真实 SMTP 配置。

3.2.5. Web 界面调试 HTML 邮件与附件

配置完成后,我们来测试一下。运行第二章中的任意测试方法(比如发送订单确认邮件),然后打开 http://localhost:8025

你会看到邮件列表中出现了一封新邮件。点击它,右侧会显示邮件详情。

image-20260125200841542

功能一:查看 HTML 渲染效果

Mailpit 默认显示 HTML 版本的邮件。你可以看到邮件的实际渲染效果,包括样式、颜色、布局。

点击顶部的 HTML 标签,可以查看原始 HTML 代码。点击 Plain text 标签,可以查看纯文本版本(如果邮件同时包含 HTML 和纯文本)。

功能二:检查邮件头信息

点击 Headers 标签,可以查看完整的邮件头,包括:

  • From:发件人
  • To:收件人
  • Subject:主题
  • Content-Type:内容类型(如 text/html; charset=UTF-8
  • Message-ID:邮件唯一标识符

这些信息在排查问题时非常有用。比如,如果邮件中文乱码,检查 Content-Type 是否包含 charset=UTF-8

功能三:下载附件

如果邮件包含附件,Mailpit 会在邮件详情底部显示附件列表。点击附件名称可以直接下载,验证附件内容是否正确。

功能四:搜索和过滤

在左上角的搜索框中,可以按主题、发件人、收件人搜索邮件。比如输入 订单,会筛选出所有主题包含 “订单” 的邮件。

功能五:查看原始邮件

点击右上角的 Raw 按钮,可以查看邮件的原始 MIME 格式。这对于理解邮件的底层结构非常有帮助。


3.3. GreenMail:单元测试的最佳伴侣

Mailpit 解决了 “可视化调试” 的问题,但它无法自动化。每次测试后,你需要手动打开浏览器检查邮件。在持续集成(CI)环境中,这是不可接受的。

GreenMail 填补了这个空白。它是一个嵌入式的邮件服务器,可以在 JUnit 测试中启动,让你编写断言验证邮件发送逻辑。

3.3.1. 为什么需要邮件测试

在传统的单元测试中,我们会 Mock 掉外部依赖(如数据库、HTTP 接口)。但邮件发送是一个特殊的场景。

Mock 方案的局限性

假设我们用 Mockito Mock 掉 JavaMailSender

1
2
3
4
5
6
7
8
9
10
@Mock
private JavaMailSender mailSender;

@Test
void testSendMail() {
mailService.sendSimpleText("test@example.com", "主题", "内容");

// 只能验证方法被调用了,但无法验证邮件内容
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
}

这种测试只能验证 “send 方法被调用了”,但无法验证:

  • 邮件主题是否正确
  • 收件人地址是否正确
  • HTML 内容是否渲染正确
  • 附件是否完整
  • MIME 格式是否符合标准

GreenMail 的价值

GreenMail 提供了一个 “真实的” SMTP 服务器(虽然是内存中的),让我们可以:

  1. 发送真实的邮件(经过完整的 MIME 编码)
  2. 通过 API 查询服务器收到的邮件
  3. 验证邮件的每一个细节(主题、正文、附件、邮件头)

这就像测试 HTTP 接口时使用 MockMvc 而不是 Mock Controller 一样,我们测试的是 “完整的邮件发送流程”,而不是 “某个方法被调用了”。

3.3.2. GreenMail 的核心概念

在引入依赖之前,我们先理解 GreenMail 的三个核心概念。

概念一:ServerSetup(服务器配置)

定义 GreenMail 启动哪些协议服务器(SMTP/IMAP/POP3)以及监听的端口

GreenMail 不仅支持 SMTP(发信),还支持 IMAP 和 POP3(收信)。在测试中,我们通常只需要 SMTP。

GreenMail 提供了预定义的配置:

  • ServerSetupTest.SMTP:启动 SMTP 服务器,监听 3025 端口
  • ServerSetupTest.SMTPS:启动加密的 SMTP 服务器,监听 3465 端口
  • ServerSetupTest.IMAP:启动 IMAP 服务器,监听 3143 端口
  • ServerSetupTest.ALL:启动所有协议服务器

为什么端口是 3025 而不是标准的 25?因为 1024 以下的端口需要管理员权限,GreenMail 使用 标准端口 + 3000 作为测试端口。

概念二:GreenMailConfiguration(配置对象)

GreenMail 配置,定义用户账号、认证方式、邮件存储策略等高级配置

这个对象控制 GreenMail 的行为,常用配置包括:

  • withUser(email, username, password):创建测试用户(用于 SMTP 认证)
  • withDisabledAuthentication():关闭认证(简化测试)
  • withMaxMessageSize(bytes):限制邮件大小

概念三:GreenMailExtension(JUnit 5 扩展)

JUnit 5 扩展,自动管理 GreenMail 的生命周期,在测试前启动、测试后清理

这是 GreenMail 与 JUnit 5 的集成点。它负责:

  1. 在测试方法执行前启动 GreenMail 服务器
  2. 在测试方法执行后停止服务器并清理邮件
  3. 提供 API 查询收到的邮件

3.3.3. 引入依赖并理解版本选择

pom.xml 中添加 GreenMail 依赖:

1
2
3
4
5
6
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-junit5</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>

依赖解读:

  • greenmail-junit5:包含 JUnit 5 扩展,如果你用的是 JUnit 4,需要引入 greenmail-junit4
  • version 2.0.1:截至 2026 年的稳定版本,支持 Jakarta Mail(Spring Boot 3 必需)
  • scope test:仅在测试时使用,不会打包到生产环境

版本兼容性警告:GreenMail 1.x 版本使用的是 javax.mail,与 Spring Boot 3 不兼容。必须使用 2.0+ 版本。

3.3.4. 编写第一个测试:逐步构建

我们不直接给出完整代码,而是一步步构建,理解每个部分的作用。

步骤 1:创建测试类并注册扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.demo.service;

import com.icegreen.greenmail.junit5.GreenMailExtension;
import com.icegreen.greenmail.util.ServerSetupTest;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class MailServiceTest {

@RegisterExtension
static GreenMailExtension greenMail = new GreenMailExtension(ServerSetupTest.SMTP);
}

代码解读:

  • @RegisterExtension:JUnit 5 的扩展注册注解,告诉 JUnit “这个测试类需要使用 GreenMail 扩展”
  • static:扩展必须是静态的,因为它的生命周期由 JUnit 管理,不属于某个测试实例
  • ServerSetupTest.SMTP:只启动 SMTP 服务器(端口 3025)

步骤 2:配置用户认证

1
2
3
4
5
6
7
8
import com.icegreen.greenmail.configuration.GreenMailConfiguration;

@RegisterExtension
static GreenMailExtension greenMail = new GreenMailExtension(ServerSetupTest.SMTP)
.withConfiguration(
GreenMailConfiguration.aConfig()
.withUser("test@example.com", "testuser", "password")
);

withUser 方法的三个参数:

  1. 邮箱地址:这个用户的邮箱(可以是任意值,GreenMail 不会真正验证)
  2. 用户名:SMTP 认证时使用的用户名
  3. 密码:SMTP 认证时使用的密码

为什么需要创建用户?因为 GreenMail 默认开启了 SMTP 认证。如果你的 Spring Boot 配置中设置了 mail.smtp.auth=true,就必须提供匹配的用户名和密码。

步骤 3:配置生命周期策略

1
2
3
4
5
@RegisterExtension
static GreenMailExtension greenMail = new GreenMailExtension(ServerSetupTest.SMTP)
// 注意,这里的用户名和密码一定要和 yml 配置的保持一致,否则会遇到验证错误
.withConfiguration(GreenMailConfiguration.aConfig().withUser("test@example.com", "testuser", "password"))
.withPerMethodLifecycle(true);

withPerMethodLifecycle 控制 GreenMail 的启动时机:

  • true(推荐):每个测试方法执行前重启 GreenMail,确保测试隔离(邮件不会互相干扰)
  • false:整个测试类只启动一次 GreenMail,所有测试方法共享同一个服务器

什么时候用 false?当你有 100 个测试方法,且它们之间没有依赖关系时,用 false 可以加快测试速度(避免重复启动)。但要注意清理邮件,避免测试互相影响。

步骤 4:配置 Spring Boot 连接 GreenMail

注意: 这里是测试文件夹下的 application.yml,不要搞错

src/test/resources/application.yml 中添加:

1
2
3
4
5
6
7
8
9
10
spring:
mail:
host: localhost
port: 3025
username: testuser
password: password
properties:
mail:
smtp:
auth: true

配置解读:

  • port: 3025:GreenMail 的 SMTP 端口
  • usernamepassword:必须与 withUser 中的参数匹配
  • auth: true:开启认证(与 GreenMail 的默认行为一致)

步骤 5:修改 MailService 使用配置中的发件人

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.springframework.beans.factory.annotation.Value;

@Service
public class MailService {

private final JavaMailSender mailSender;

@Value("${spring.mail.username}")
private String from;

public MailService(JavaMailSender mailSender) {
this.mailSender = mailSender;
}

public void sendSimpleText(String to, String subject, String content) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(from); // 使用配置文件中的发件人
message.setTo(to);
message.setSubject(subject);
message.setText(content);
mailSender.send(message);
}
}

为什么要这样改?因为在测试环境中,我们不能使用真实的 QQ 邮箱作为发件人。通过 @Value 注入配置,可以在不同环境使用不同的发件人地址。

步骤 6:编写测试方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

import static org.junit.jupiter.api.Assertions.*;

@Test
void testSendSimpleText() throws Exception {
// 发送邮件
mailService.sendSimpleText(
"recipient@example.com",
"测试主题",
"测试内容"
);

// 验证邮件是否被 GreenMail 接收
MimeMessage[] receivedMessages = greenMail.getReceivedMessages();
assertEquals(1, receivedMessages.length, "应该收到1封邮件");

MimeMessage message = receivedMessages[0];
assertEquals("测试主题", message.getSubject(), "主题不匹配");
assertTrue(message.getContent().toString().contains("测试内容"), "内容不匹配");
assertEquals("recipient@example.com", message.getAllRecipients()[0].toString(), "收件人不匹配");
}

getReceivedMessages() 方法返回 GreenMail 接收到的所有邮件(按接收顺序排列)。每封邮件都是一个标准的 jakarta.mail.internet.MimeMessage 对象,我们可以用 JavaMail API 读取它的任何属性。

3.3.5. GreenMail 核心 API 详解

现在我们已经有了一个可运行的测试,接下来深入学习 GreenMail 提供的 API。

API 一:getReceivedMessages() - 返回所有接收邮箱

1
MimeMessage[] getReceivedMessages()

返回所有接收到的邮件。注意:

  • 返回的是数组,不是 List
  • 邮件按接收顺序排列(最先发送的在 index 0)
  • 如果没有邮件,返回空数组(不是 null)

API 二:getReceivedMessagesForDomain(domain) - 返回指定域名邮箱

1
MimeMessage[] getReceivedMessagesForDomain(String domain)

只返回发送到指定域名的邮件。比如:

1
2
// 只获取发送到 @example.com 的邮件
MimeMessage[] messages = greenMail.getReceivedMessagesForDomain("example.com");

适用场景:测试中发送了多封邮件到不同域名,需要分别验证。

API 三:waitForIncomingEmail(timeout, count) - 等待指定数量的邮件到达

1
boolean waitForIncomingEmail(long timeout, int count)

等待指定数量的邮件到达,最多等待 timeout 毫秒。返回值:

  • true:在超时前收到了 count 封邮件
  • false:超时了,邮件数量不足

示例:

1
2
3
4
5
6
// 发送邮件
mailService.sendSimpleText("test@example.com", "主题", "内容");

// 等待最多 5 秒,直到收到 1 封邮件
boolean received = greenMail.waitForIncomingEmail(5000, 1);
assertTrue(received, "邮件未在 5 秒内到达");

这个 API 在测试异步发送时非常有用,比 Awaitility 更简洁。

API 四:purgeEmailFromAllMailboxes() - 清空邮箱中的邮件

1
void purgeEmailFromAllMailboxes()

清空所有邮箱中的邮件。适用场景:

  • 使用 withPerMethodLifecycle(false) 时,需要在每个测试方法开始前手动清理
  • 一个测试方法中发送了多封邮件,需要在中途清空

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
void testMultipleScenarios() {
// 场景 1:发送验证码
mailService.sendVerificationCode("user@example.com", "123456");
assertEquals(1, greenMail.getReceivedMessages().length);

// 清空邮件
greenMail.purgeEmailFromAllMailboxes();

// 场景 2:发送订单通知
mailService.sendOrderNotification("user@example.com", orderData);
assertEquals(1, greenMail.getReceivedMessages().length);
}

3.3.6. 验证邮件内容的完整示例

现在我们知道了 GreenMail 的 API,来看几个完整的验证场景。

场景一:验证 HTML 邮件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
void testSendHtmlMail() throws Exception {
String htmlContent = "<html><body>" +
"<h1>订单确认</h1>" +
"<p>订单号:<strong>ORD001</strong></p>" +
"</body></html>";

mailService.sendHtmlMail("user@example.com", "订单确认", htmlContent);

// 等待邮件到达
assertTrue(greenMail.waitForIncomingEmail(2000, 1), "邮件未到达");

MimeMessage message = greenMail.getReceivedMessages()[0];

// 验证内容类型
String contentType = message.getContentType();
assertTrue(contentType.contains("text/html"), "应该是 HTML 邮件,实际是:" + contentType);

// 验证 HTML 内容
String content = message.getContent().toString();
assertTrue(content.contains("<h1>订单确认</h1>"), "缺少标题");
assertTrue(content.contains("ORD001"), "缺少订单号");
}

场景二:验证多个收件人

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
void testSendToMultipleRecipients() throws Exception {
// 发送给 3 个收件人
mailService.sendSimpleText(
"user1@example.com,user2@example.com,user3@example.com",
"群发通知",
"这是一封群发邮件"
);

MimeMessage message = greenMail.getReceivedMessages()[0];
Address[] recipients = message.getAllRecipients();

assertEquals(3, recipients.length, "应该有 3 个收件人");

// 验证每个收件人地址
Set<String> emails = Arrays.stream(recipients)
.map(Address::toString)
.collect(Collectors.toSet());

assertTrue(emails.contains("user1@example.com"));
assertTrue(emails.contains("user2@example.com"));
assertTrue(emails.contains("user3@example.com"));
}

场景三:验证附件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Test
void testSendMailWithAttachment() throws Exception {
// 创建测试文件
Path testFile = Files.createTempFile("test", ".txt");
Files.writeString(testFile, "这是测试附件的内容");

mailService.sendMailWithAttachment(
"user@example.com",
"附件测试",
"请查收附件",
testFile.toString()
);

MimeMessage message = greenMail.getReceivedMessages()[0];

// 验证邮件是 multipart 类型
Object content = message.getContent();
assertTrue(content instanceof MimeMultipart, "应该包含附件");

MimeMultipart multipart = (MimeMultipart) content;
assertEquals(2, multipart.getCount(), "应该有 2 个部分(正文 + 附件)");

// 第一部分是正文
BodyPart textPart = multipart.getBodyPart(0);
assertTrue(textPart.getContent().toString().contains("请查收附件"));

// 第二部分是附件
BodyPart attachmentPart = multipart.getBodyPart(1);
assertEquals("test.txt", attachmentPart.getFileName(), "附件名不匹配");

// 验证附件内容
InputStream attachmentStream = attachmentPart.getInputStream();
String attachmentContent = new String(attachmentStream.readAllBytes());
assertEquals("这是测试附件的内容", attachmentContent);

// 清理测试文件
Files.deleteIfExists(testFile);
}

3.3.7. 使用 Awaitility 处理异步发送

在实际项目中,邮件发送通常是异步的。假设我们的 MailService 有一个异步方法:

1
2
3
4
@Async
public void sendSimpleTextAsync(String to, String subject, String content) {
sendSimpleText(to, subject, content);
}

直接测试会失败,因为测试方法执行完毕时,邮件可能还没发送。

方案一:使用 GreenMail 的 waitForIncomingEmail

1
2
3
4
5
6
7
8
9
10
11
@Test
void testAsyncSending() {
mailService.sendSimpleTextAsync("user@example.com", "异步测试", "内容");

// 等待最多 5 秒
boolean received = greenMail.waitForIncomingEmail(5000, 1);
assertTrue(received, "邮件未在 5 秒内到达");

MimeMessage message = greenMail.getReceivedMessages()[0];
assertEquals("异步测试", message.getSubject());
}

方案二:使用 Awaitility(更灵活)

引入依赖:

1
2
3
4
5
6
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<version>4.2.0</version>
<scope>test</scope>
</dependency>

编写测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import static org.awaitility.Awaitility.await;
import static java.util.concurrent.TimeUnit.SECONDS;

@Test
void testAsyncSendingWithAwaitility() {
mailService.sendSimpleTextAsync("user@example.com", "异步测试", "内容");

// 等待最多 5 秒,每 100ms 检查一次
await().atMost(5, SECONDS)
.pollInterval(100, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
MimeMessage[] messages = greenMail.getReceivedMessages();
assertEquals(1, messages.length, "应该收到 1 封邮件");
assertEquals("异步测试", messages[0].getSubject());
});
}

Awaitility 的优势:

  • 可以验证复杂的断言(不仅仅是邮件数量)
  • 可以自定义轮询间隔
  • 失败时会显示详细的错误信息

3.4. Testcontainers 方案(可选)

除了使用 GreenMail 的嵌入式服务器,我们还可以用 Testcontainers 启动一个真实的 Mailpit Docker 容器进行测试。

3.4.1. Testcontainers 的核心价值

在讲具体用法之前,我们先理解:为什么需要 Testcontainers?它解决了什么问题?

问题一:测试环境与开发环境不一致

在 3.3 节中,我们用 GreenMail 做测试,用 Mailpit 做开发。这带来一个隐患:GreenMail 和 Mailpit 的行为可能不完全一致。

比如,GreenMail 对某些非标准的 MIME 格式比较宽容,但 Mailpit 可能会拒绝。这导致 “测试通过,但开发环境有问题”。

问题二:无法测试真实的 Docker 环境

在生产环境中,我们可能会用 Docker 部署邮件服务。如果测试环境用的是嵌入式的 GreenMail,就无法发现 Docker 相关的问题(如网络配置、环境变量)。

Testcontainers 的解决方案

Testcontainers | 测试容器 | 一个 Java 库,可以在测试中启动真实的 Docker 容器,测试完成后自动销毁

Testcontainers 让我们可以在测试中启动一个真实的 Mailpit 容器,这样:

  • 测试环境与开发环境完全一致(都是 Mailpit)
  • 可以测试 Docker 相关的配置
  • 可以测试多个服务的集成(如同时启动 MySQL + Redis + Mailpit)

权衡:什么时候用 Testcontainers

场景推荐方案理由
单元测试(快速反馈)GreenMail启动快(毫秒级),无需 Docker
集成测试(验证完整流程)Testcontainers环境真实,发现更多问题
CI 环境(有 Docker)Testcontainers与生产环境一致
CI 环境(无 Docker)GreenMail无需额外依赖

3.4.2. Testcontainers 核心概念

在编写代码之前,我们先理解 Testcontainers 的三个核心概念。

概念一:GenericContainer(通用容器)

GenericContainer | 通用容器 | Testcontainers 提供的基础容器类,可以启动任意 Docker 镜像

它的核心方法:

  • withExposedPorts(port...):声明容器需要暴露哪些端口
  • getMappedPort(containerPort):获取容器端口映射到宿主机的随机端口
  • getHost():获取容器的 IP 地址(通常是 localhost)

概念二:@Container 注解

1
2
@Container
static GenericContainer<?> mailpit = new GenericContainer<>("axllent/mailpit:latest");

这个注解告诉 Testcontainers:“这是一个需要管理的容器”。Testcontainers 会:

  1. 在测试类加载时启动容器
  2. 在所有测试方法执行完后停止并删除容器

为什么必须是 static?因为容器的生命周期是 “类级别” 的,不属于某个测试实例。

概念三:@DynamicPropertySource(动态属性源)

1
2
3
4
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.mail.port", () -> mailpit.getMappedPort(1025));
}

这个注解的作用是:在 Spring 容器启动前,动态修改配置属性。

为什么需要它?因为 Testcontainers 启动容器时,会将容器端口映射到宿主机的随机端口(避免端口冲突)。我们无法在 application.yml 中写死端口号,必须在运行时动态获取。

3.4.3. 引入依赖并理解模块划分

Testcontainers 采用模块化设计,核心库只提供容器管理功能,具体的数据库、消息队列等需要单独引入。

最小依赖(通用容器)

1
2
3
4
5
6
7
8
9
10
11
12
13
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>

依赖解读:

  • testcontainers:核心库,提供 GenericContainer 等基础类
  • junit-jupiter:JUnit 5 集成,提供 @Container@Testcontainers 等注解

为什么不需要 Docker 依赖?

Testcontainers 通过 Docker 的 REST API 与 Docker 守护进程通信,不需要在项目中引入 Docker 客户端库。但你的机器上必须安装并运行 Docker。

3.4.4. 启动 Mailpit 容器的完整示例

现在我们逐步构建一个使用 Testcontainers 的测试。

步骤 1:创建测试类并声明容器

1
2
3
4
5
6
7
8
9
10
11
12
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest
@Testcontainers
class MailServiceWithTestcontainersTest {

@Container
static GenericContainer<?> mailpit = new GenericContainer<>("axllent/mailpit:latest")
.withExposedPorts(1025, 8025);
}

代码解读:

  • @Testcontainers:启用 Testcontainers 支持,JUnit 会扫描 @Container 注解
  • GenericContainer<>("axllent/mailpit:latest"):使用 Mailpit 的官方镜像
  • withExposedPorts(1025, 8025):暴露 SMTP 端口(1025)和 Web 界面端口(8025)

步骤 2:配置动态端口

1
2
3
4
5
6
7
@DynamicPropertySource
static void configureMailHost(DynamicPropertyRegistry registry) {
registry.add("spring.mail.host", mailpit::getHost);
registry.add("spring.mail.port", () -> mailpit.getMappedPort(1025));
registry.add("spring.mail.username", () -> "test@example.com");
registry.add("spring.mail.from", () -> "test@example.com");
}

registry.add 方法的两个参数:

  1. 配置键:要覆盖的 Spring 配置属性(如 spring.mail.port
  2. 值提供者:一个 Lambda 表达式,返回配置值

为什么用 Lambda 而不是直接传值?因为在 @DynamicPropertySource 执行时,容器可能还没完全启动,端口映射还没完成。Lambda 会延迟执行,确保获取到正确的端口。

步骤 3:编写测试方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Autowired
private MailService mailService;

@Test
void testSendMailToTestcontainers() {
mailService.sendSimpleText(
"test@example.com",
"Testcontainers 测试",
"这是发送到 Testcontainers 中 Mailpit 的邮件"
);

// 邮件已发送,但我们无法直接通过 Java API 验证
// 需要调用 Mailpit 的 REST API
System.out.println("邮件已发送到 Testcontainers 中的 Mailpit");
System.out.println("Web 界面地址: http://localhost:" + mailpit.getMappedPort(8025));
}

步骤 4:通过 REST API 验证邮件

Mailpit 提供了 REST API 查询邮件。我们可以用 Spring 的 RestTemplate 调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import org.springframework.web.client.RestTemplate;
import org.springframework.http.ResponseEntity;

@Test
void testSendMailAndVerify() {
mailService.sendSimpleText("test@example.com", "测试主题", "测试内容");

// 等待邮件到达
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}

// 调用 Mailpit API 获取邮件列表
RestTemplate restTemplate = new RestTemplate();
String apiUrl = "http://localhost:" + mailpit.getMappedPort(8025) + "/api/v1/messages";

ResponseEntity<String> response = restTemplate.getForEntity(apiUrl, String.class);
String body = response.getBody();

// 验证响应中包含我们的邮件
assertNotNull(body);
assertTrue(body.contains("测试主题"), "邮件列表中应该包含测试邮件");
}

Mailpit API 返回的是 JSON 格式,完整的验证需要解析 JSON。这里为了简化,只检查字符串包含关系。

由于这方案比较繁琐,是比较极端的测试方向,我们就不过多追溯

3.4.5. 三种测试方案的终极对比

现在我们学习了三种测试方案,做一个全面对比:

维度GreenMail 扩展Testcontainers + Mailpit
启动速度⭐⭐⭐⭐⭐(毫秒级)⭐⭐⭐(5-10 秒)
环境真实性⭐⭐⭐(嵌入式)⭐⭐⭐⭐⭐(与开发环境一致)
验证便利性⭐⭐⭐⭐⭐(Java API)⭐⭐⭐(需要 REST API)
依赖要求Docker
CI 友好度⭐⭐⭐⭐⭐⭐⭐⭐⭐
适用场景单元测试、快速反馈端到端测试、生产验证

推荐策略

  1. 日常开发:使用 GreenMail 扩展(快速反馈)
  2. 预发布验证:使用 Testcontainers + Mailpit(与生产一致)

3.5. 本章小结与测试策略速查

在这一章中,我们掌握了本地邮件调试和自动化测试的完整方案。

核心知识回顾

我们解决了以下问题:

  • 为什么不应该用真实邮箱调试(污染收件箱、无法模拟失败、团队协作困难)
  • 如何使用 Mailpit 进行可视化调试(Docker 启动、配置切换、Web 界面)
  • 如何使用 GreenMail 编写自动化测试(JUnit 扩展、断言验证、异步处理)
  • 如何使用 Testcontainers 构建隔离的测试环境

三种测试方案对比

方案适用场景优点缺点
Mailpit本地开发调试可视化、实时查看、支持搜索无法自动化
GreenMail单元测试、CI 环境嵌入式、快速、可断言不是真实 SMTP 服务器
Testcontainers + Mailpit集成测试环境真实、隔离性好启动慢、需要 Docker