docker-mailserver 测试套件全解析:基于 BATS 的单元与集成测试编写、运行与调试指南
【免费下载链接】docker-mailserverProduction-ready fullstack but simple mail server (SMTP, IMAP, LDAP, Antispam, Antivirus, etc.) running inside a container.项目地址: https://gitcode.com/gh_mirrors/do/docker-mailserver
本指南以 docker-mailserver 仓库的 tests.md 为核心骨架,结合 Makefile 与
test/目录下的真实源码,系统讲解该容器化邮件服务器项目的测试体系:如何理解test/目录结构、如何使用 BATS(Bash Automated Testing System)与项目内置的 helper 函数、如何通过make运行单个/多个/并行测试、以及如何为自己的功能变更编写高质量测试。读完本文,你将掌握 docker-mailserver 贡献者日常使用的完整测试工作流,并能据此为本仓库的新功能补上可复现、无竞态条件的测试用例。
为什么需要这套测试体系
docker-mailserver(下文简称 DMS)是集 SMTP、IMAP、LDAP、反垃圾、反病毒等于一体的容器化邮件服务器。它的行为横跨 Postfix、Dovecot、Rspamd、ClamAV 等多个服务的配置与协作,任何一处改动都可能引入回归。为此,DMS 在test/目录中维护了一套丰富的单元测试与集成测试:
"Program testing can be used to show the presence of bugs, but never to show their absence!" —— Edsger Wybe Dijkstra(测试可以证明缺陷的存在,却无法证明缺陷的不存在)
正如 DMS 文档所引用的这句名言,测试的价值在于为变更建立信心基线。如果你打算修改现有功能或集成新特性,几乎必然要与这套测试套件打交道。文档还特别提醒:当前不支持在 macOS 上运行 lint 与测试,请使用 Linux 虚拟机(推荐 Debian/Ubuntu)——这与下文 Makefile 中对 GNU 工具链(如parallel、globstar)的依赖直接相关。
测试框架与目录结构
DMS 使用 BATS。
test/目录包含多个子目录,核心构成如下:
| 目录 | 作用 |
|---|---|
test/bats/ | BATS 的 git 子模块(测试运行器本体) |
test/helper/ | 几乎所有测试都会加载的公共支持函数 |
test/tests/ | 实际测试用例,按parallel/与serial/分类存放 |
test/config/ | 测试用配置与覆写文件(如 dovecot、postfix、ldap、rspamd 等) |
test/files/ | 测试辅助文件(邮件模板、SSL 证书、SMTP 原始交互脚本等) |
test/test_helper/ | 引入的第三方 BATS 库(bats-assert、bats-support) |
从 test/helper/common.bash 可以看到,helper 的初始化会通过load依次载入bats-support、bats-assert、sending与log_and_filtering,这正是assert_success、assert_output等断言宏的来源:
function __load_bats_helper() { load "${REPOSITORY_ROOT}/test/test_helper/bats-support/load" load "${REPOSITORY_ROOT}/test/test_helper/bats-assert/load" load "${REPOSITORY_ROOT}/test/helper/sending" load "${REPOSITORY_ROOT}/test/helper/log_and_filtering" }文档提示:测试套件正处于重构过程中,测试会被逐步移入
test/tests/parallel/,新测试应直接放在parallel/下。这一状态与当前仓库中test/tests/parallel/set{1,2,3}与test/tests/serial/并存的结构一致。
helper 函数体系:编写测试的基石
文档强烈建议:优先使用项目提供的 helper 函数。它们不仅简化测试编写,还会尽力避免竞态条件(race condition)与其他副作用。了解这些函数有两个途径:
- 阅读现有测试,观察已使用的 helper;
- 阅读
test/helper/目录下所有可被测试文件加载的文件。
每个函数都带有详细的文档注释,务必仔细阅读。下面按用途梳理各 helper 文件的核心能力。
容器初始化与生命周期(test/helper/setup.bash)
setup.bash 负责测试的前置检查、配置目录创建与容器启停:
_init_with_defaults:为测试文件创建独立的临时配置目录(见下文TEST_TMP_CONFIG),并挂载test/files只读卷与配置卷。它还会导出TEST_TIMEOUT_IN_SECONDS(默认 120)与NUMBER_OF_LOG_LINES(默认 10)等变量;_common_container_setup:组合_common_container_create(docker create)与_common_container_start(docker start),最后通过_wait_for_finished_setup_in_container等待容器日志出现is up and running。之所以用create+start而非直接run,是为了允许在容器启动前修改配置;_default_teardown:默认清理函数,执行docker rm -f移除测试容器,通常放在teardown_file中调用。
值得注意,_common_container_create中(见 setup.bash)默认通过--env关闭了 Amavis、ClamAV、更新检查、SpamAssassin、Fail2ban 等重型功能,并将POSTFIX_INET_PROTOCOLS/DOVECOT_INET_PROTOCOLS设为ipv4、LOG_LEVEL设为debug——这为绝大多数测试提供了一个干净、快速启动的基线容器。需要额外环境变量时,在测试文件中通过CUSTOM_SETUP_ARGUMENTS数组追加。
容器内命令执行(test/helper/common.bash)
common.bash 是 helper 的核心,提供四组能力:
① 容器内执行命令:_exec_in_container(直接执行)、_run_in_container(配合 BATSrun捕获输出与退出码)、以及带 Bash 包装的_exec_in_container_bash/_run_in_container_bash。
② 超时重试机制:_repeat_until_success_or_timeout会循环执行命令直到成功或超时,并支持--fatal-test在容器意外退出时提前中止;容器内版本为_repeat_in_container_until_success_or_timeout。这是消除竞态条件的关键工具——例如 setup.bash 中_wait_for_finished_setup_in_container就借助它轮询容器日志。
③ 等待条件就绪:_wait_for_smtp_port_in_container、_wait_for_tcp_port_in_container(端口就绪)、_wait_for_service(supervisor 服务进入 RUNNING 状态)、_wait_for_empty_mail_queue_in_container(Postfix 队列清空)、_wait_until_account_maildir_exists(账号目录创建完成)等。例如 common.bash 中:
function _wait_for_service() { local SERVICE_NAME="${1:?Service name must be provided}" local CONTAINER_NAME=$(__handle_container_name "${2:-}") _repeat_until_success_or_timeout \ --fatal-test "_container_is_running ${CONTAINER_NAME}" \ "${TEST_TIMEOUT_IN_SECONDS}" \ _should_have_service_running_in_container "${SERVICE_NAME}" }④ 通用断言辅助:_should_have_service_running_in_container(通过supervisorctl status检查服务)、_file_exists_in_container/_file_does_not_exist_in_container、_count_files_in_directory_in_container、_get_container_ip、_container_is_running等。
此外,容器命名有强制约定:必须使用dms-test_前缀。__handle_container_name(见 common.bash)会校验显式传入的名字或回退到CONTAINER_NAME环境变量,否则直接报错退出,从而保证整个套件中容器名可预期、可清理(make clean也正是按^(dms-test|mail)_.*模式匹配容器)。
发信辅助(test/helper/sending.bash)
sending.bash 封装了swaks发信,简化邮件类测试:
_send_email:从CONTAINER_NAME容器内发送邮件,默认参数为--ehlo mail.external.tld --from user@external.tld --to user1@localhost.localdomain --server 0.0.0.0 --port 25。可通过--data指定test/files/emails/下的模板文件或内联数据;函数默认异步返回(不等待队列清空)。若预期邮件会被拒绝,使用--expect-rejection前缀,此时不会断言成功;_send_email_with_msgid:附加Message-ID: <local-part>@dms-tests>头,便于后续从日志中按 Message-ID 关联追踪;_send_spam:发送 GTUBE 标准垃圾邮件测试串(XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X),用于验证 Rspamd/SpamAssassin 的拦截行为。
日志断言与过滤(test/helper/log_and_filtering.bash)
log_and_filtering.bash 提供针对/var/log/supervisor/<SERVICE>.log或/var/log/mail/<SERVICE>.log的断言:
_service_log_should_contain_string/_service_log_should_not_contain_string:固定字符串匹配(grep --fixed-strings);_service_log_should_contain_string_regexp/_service_log_should_not_contain_string_regexp:扩展正则匹配(grep --extended-regexp);_print_mail_log_of_queue_id_from_msgid:等待邮件队列清空后,从mail.log中解析 Postfix Queue ID 并打印相关日志,是端到端追踪一封邮件生命周期的利器;_show_complete_mail_log:打印完整邮件日志,文档建议仅在无法更精确过滤时使用。
TLS 与证书测试(test/helper/tls.bash)
tls.bash 面向 SSL/TLS 相关测试:_should_successfully_negotiate_tls会对 25/587/465/143/993 五个端口逐一进行 TLS 协商;_negotiate_tls通过openssl s_client校验证书链与 FQDN 匹配(含通配符证书、SNI 场景),并验证"不提供 CA 时验证失败、提供 CA 后验证 OK"的完整信任链行为。
变更检测测试(test/helper/change-detection.bash)
change-detection.bash 用于测试 DMS 的 changedetector 机制(检测配置变更并重启相关服务):_wait_until_change_detection_event_begins与_wait_until_change_detection_event_completes分别统计/var/log/supervisor/changedetector.log中的Change detected与Completed handling of detected change事件数;_get_logs_since_last_change_detection则提取最近一次变更事件以来的全部日志。注意这些函数要求容器LOG_LEVEL=debug及以上。
独立临时配置目录
如果测试需要新增或创建额外配置文件,helper 会为每个容器管理一个一次性配置目录:路径保存在TEST_TMP_CONFIG环境变量中(宿主侧),容器内对应/tmp/docker-mailserver。以 setup.bash 为例:
export TEST_TMP_CONFIG TEST_TMP_CONFIG=$(_duplicate_config_for_container . "${CONTAINER_NAME}") ... export TEST_CONFIG_VOLUME="${TEST_TMP_CONFIG}:/tmp/docker-mailserver"这保证了多个并行测试互不污染彼此的配置,也是"无竞态副作用"设计的具体体现。相关机制可参考仓库 PR #4359 的讨论。
测试如何运行:并行与串行
DMS 将测试分为两类:
test/tests/parallel/:多个测试文件并发运行以缩短整个套件耗时;单个文件内部的测试用例当前按顺序执行。parallel/又被细分为若干 set(当前为set1、set2、set3);test/tests/serial/:每个测试文件排队串行执行,无法支持并发运行的测试归于此。
文档特别强调:不要在并行 set 运行期间混跑串行测试。不过在使用make tests时这一点已由 Makefile 自动处理(见 Makefile,它会依次为tests/serial与tests/parallel/set{1,2,3}调用make generate-accounts)。若机器资源充裕,可以同时运行多个 set——DMS 的 CI 正是将各 set 分发到多个测试执行器上并行跑。
Makefile 中的测试目标
从仓库根目录的 Makefile 可以完整还原make命令背后的行为:
| 目标 | 实际执行 |
|---|---|
make build | docker build --tag mailserver-testing:ci .,构建本地测试镜像 |
make generate-accounts | 从test/config/templates/拷贝postfix-accounts.cf与dovecot-masters.cf到test/config/ |
make clean | 移除所有名字匹配^(dms-test|mail)_.*的容器,并按.gitignore清理生成的测试产物 |
make tests | 依次对tests/serial、tests/parallel/set{1,2,3}执行测试 |
make tests/serial | 运行test/tests/serial/*.bats(串行) |
make tests/parallel/setX | 运行test/tests/parallel/setX/**/*.bats,使用--jobs $(BATS_PARALLEL_JOBS)并发 |
make test/<NAME> | 通过 globstar 匹配test/tests/**/<NAME>.bats并运行 |
make run-local-instance | 以最小化配置启动一个本地测试实例供手动调试 |
test/%目标(Makefile)支持逗号分隔多个测试名,例如make test/rspamd_full,clamav会依次运行两个测试文件。BATS_PARALLEL_JOBS默认值为 2(见 Makefile)。
运行测试:前置条件与常用命令
前置条件
运行测试套件需要准备:
- 安装 Docker(建议参考 Docker 官方安装文档);
- 安装
jq、GNUparallel与file。Ubuntu 下执行:$ sudo apt-get -y install jq parallel file - 若尚未初始化 git 子模块,先执行:
$ git submodule update --init --recursive
标准执行流程
文档给出的完整命令序列如下(均通过make驱动):
构建/更新测试镜像:
$ make build该命令基于项目 Dockerfile 构建本地
mailserver-testing:ci镜像。注意 Makefile 中all目标为lint build generate-accounts tests clean的完整流水线。运行全部测试:
$ make clean tests运行单个测试(
<TEST NAME>不含.bats后缀):$ make clean generate-accounts test/<TEST NAME>运行多个不相关测试(用
,直接拼接,无空格):$ make clean generate-accounts test/<TEST NAME>,<TEST NAME>运行某个并行 set 或全部串行测试:
$ make clean generate-accounts tests/parallel/setX # X 为 set 编号 $ make clean generate-accounts tests/serial
调整并行度
如果你的机器资源充足,可通过环境变量提高并发数以加速整个测试流程:
$ BATS_PARALLEL_JOBS=X make clean all其中BATS_PARALLEL_JOBS默认值为 2;设为1则完全串行运行。它最终传递给 BATS 的--jobs参数(见 Makefile)。
并行运行时的输出行为
⚠️重要提示:使用make clean generate-accounts tests/parallel/setX并行运行时,BATS 会延迟输出——直到某个测试文件内所有用例跑完才统一打印结果(可参考 BATS 官方关于 parallel execution 的文档)。这意味着失败的用例也会被延迟报告。因此在排查并行 set 中的问题时,建议先串行运行你正在开发的测试(即用make test/<NAME>的方式)。
同时,编写测试时务必考虑并行环境:并行 set 中的测试必须在并发运行时依然通过,你需要考虑其他并行测试可能对你的测试逻辑造成的干扰。
本地调试实例
你还可以使用make run-local-instance(见 Makefile)运行一个基于本地镜像的实例,在真实运行的 DMS 容器中测试和验证你的改动。该命令会启动名为dms-test_example的容器,禁用 ClamAV、Amavis、Rspamd、OpenDKIM、OpenDMARC、policyd-spf、SpamAssassin 等重负载服务,设置LOG_LEVEL=trace,并自动在后台为postmaster@example.test添加一个邮箱账号,便于交互式调试。
实战示例:从单测到全套件
修改 Rspamd 后的回归验证
假设你修改了 Rspamd 的功能支持(或调整了它的测试),第一步是运行对应测试文件确认没有引入回归。文档中的示例输出如下:
$ make clean generate-accounts test/rspamd rspamd.bats ✓ [Rspamd] Postfix's main.cf was adjusted [12] ✓ [Rspamd] normal mail passes fine [44] ✓ [Rspamd] detects and rejects spam [122] ✓ [Rspamd] detects and rejects virus [189]注意:随着套件重构,当前仓库中 Rspamd 测试已拆分为三个文件——rspamd_full.bats、rspamd_partly.bats 与 rspamd_dkim.bats,分别覆盖"全部功能开启"、"部分功能"与"DKIM 签名"场景。运行时请按实际文件名执行,例如
make clean generate-accounts test/rspamd_full。
涉及多个组件时串行运行多个测试
如果你的改动同时影响 ClamAV(例如 Rspamd 与 ClamAV 的联动),可以一次性串行运行多个测试文件:
$ make clean generate-accounts test/rspamd_full,clamav rspamd_full.bats ✓ [Rspamd] (full) Postfix's main.cf was adjusted ... clamav.bats ✓ [ClamAV] log files exist at /var/log/mail directory [68] ✓ [ClamAV] should be identified by Amavis [67] ✓ [ClamAV] freshclam cron is enabled [76] ✓ [ClamAV] env CLAMAV_MESSAGE_SIZE_LIMIT is set correctly [63] ✓ [ClamAV] rejects virus [60]以 rspamd_full.bats 为参照,可以看到真实测试的完整形态:setup_file()中通过_init_with_defaults初始化,用CUSTOM_SETUP_ARGUMENTS数组注入ENABLE_RSPAMD=1、ENABLE_CLAMAV=1、RSPAMD_LEARN=1、MOVE_SPAM_TO_JUNK=1等环境变量,随后_common_container_setup启动容器,并用_wait_for_service、_wait_for_rspamd_port_in_container、_wait_for_smtp_port_in_container等待各服务就绪;接着借助_send_email_with_msgid、_send_spam --expect-rejection发送正常邮件、GTUBE 垃圾邮件与 EICAR 病毒样本(X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*),最后通过日志断言验证行为。
提交 PR 前运行完整套件
在提交 Pull Request 之前,务必运行一次完整测试套件确认全局无回归:
$ make clean tests从模板开始编写新测试
对于新接触 DMS 测试的贡献者,test/tests/parallel/set2/template.bats 是官方推荐的最小可运行模板,其结构如下:
# 加载 BATS helper load "${REPOSITORY_ROOT}/test/helper/setup" load "${REPOSITORY_ROOT}/test/helper/common" # 全局变量初始化:便于识别测试,且必须唯一 BATS_TEST_NAME_PREFIX='[no-op template] ' CONTAINER_NAME='dms-test_template' function setup_file() { # 容器启动前的可选准备 _init_with_defaults # 在此追加 docker run 的自定义参数 local CUSTOM_SETUP_ARGUMENTS=( --env LOG_LEVEL=trace ) # 用 helper 正确创建并启动容器 _common_container_setup 'CUSTOM_SETUP_ARGUMENTS' } function teardown_file() { _default_teardown ; } # 实际测试用例 @test "default check" { _run_in_container_bash "true" assert_success }编写测试时的要点:
- 加载 helper:至少加载
setup与common;如需发信、日志、TLS 能力,common会自动加载sending与log_and_filtering; - 唯一容器名:
CONTAINER_NAME必须遵循dms-test_前缀且全局唯一,避免并行测试冲突; - 三段式结构:
setup_file()负责初始化与启动容器,teardown_file()用_default_teardown清理,中间是@test用例; - 善用等待型 helper:不要对服务启动、邮件投递等异步事件做固定
sleep,而应使用_wait_for_service、_wait_for_smtp_port_in_container、_wait_for_empty_mail_queue_in_container等轮询函数,它们内置了超时与容器存活检查; - 断言与日志结合:使用
assert_success/assert_output --partial/assert_line --regexp等断言,并通过_service_log_should_contain_string等函数验证服务日志中的具体行为。
排查与自检清单
在提交前请对照以下清单自查:
- 是否运行了
make build更新本地测试镜像(镜像标签为mailserver-testing:ci)? - 是否运行
make clean generate-accounts test/<NAME>单独验证了改动涉及的测试? - 改动跨多个模块时,是否用逗号拼接串行运行了相关测试文件?
- 是否运行了改动所属的并行 set(
tests/parallel/setX)以排除并行干扰? - 是否在最终提交前跑过
make clean tests全量回归? - 新测试是否使用了 helper 函数来规避竞态条件(等待服务就绪、等待队列清空、等待变更检测完成)?
- 测试文件名是否遵循
*.bats后缀、容器名是否遵循dms-test_前缀且全局唯一?
遵循以上流程,你的测试将能与其他并行测试共存、稳定复现、且易于在 CI 与本地重现——这正是 DMS 这套 BATS 测试体系设计的目标。相关测试文件与 helper 均可直接在仓库 test/tests/ 与 test/helper/ 目录下继续深入学习。
【免费下载链接】docker-mailserverProduction-ready fullstack but simple mail server (SMTP, IMAP, LDAP, Antispam, Antivirus, etc.) running inside a container.项目地址: https://gitcode.com/gh_mirrors/do/docker-mailserver
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考