Docker 基础体系 · 第 29/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。
Docker 容器化测试:一次性依赖、健康等待、Fixture 和资源清理
容器化测试的难点通常不在于“把测试命令放进容器”,而在于协调一组有生命周期的进程:
- 数据库、消息队列等依赖需要先启动;
- 依赖启动后还要真正可用,而不只是容器进程存在;
- 数据库迁移、测试数据准备等任务只应执行一次;
- 测试失败、启动失败或被中断时,容器、网络和卷都必须按预期处理;
- 并行测试不能互相污染;
- CI 中不能因为上一次运行残留的资源而产生假成功或假失败。
本文以现代 Docker Engine、BuildKit 和 Compose 规范为背景,分别说明 Docker Compose 和 Testcontainers 中的实现方式。示例默认使用 Linux 容器;容器内的网络、文件系统和进程模型不能直接等同于 Windows 容器。
一、先建立生命周期模型
1. 容器“运行”不等于服务“就绪”
Docker Engine 对容器有一个运行状态,例如:
created -> running -> exited
running 只表示容器的主进程仍然存在。例如:
docker run -d --name example-db postgres:16
这个命令返回成功,只能说明 Docker 已创建并启动了容器。PostgreSQL 进程可能仍在:
- 初始化数据目录;
- 生成配置文件;
- 启动后台进程;
- 监听 Unix socket 或 TCP 端口;
- 接受认证和 SQL 请求。
因此,测试依赖的正确条件不是:
而是:
其中:
- :数据库容器仍在运行;
- :数据库监听了目标端口;
- :客户端协议探测成功;
- :数据库已经完成必要的初始化。
仅检查 TCP 端口只能证明 ,不能证明后两个条件。
2. 一次性依赖与长期依赖不同
测试拓扑中至少有三类容器:
| 类型 | 示例 | 预期生命周期 |
|---|---|---|
| 长期服务 | PostgreSQL、Redis | 在测试期间持续运行 |
| 一次性任务 | migrate、seed、生成代码 | 执行成功后退出 |
| 测试进程 | pytest、JUnit、Go test | 测试结束后退出,并提供退出码 |
一次性任务不是“启动后一直运行的服务”。它的成功条件是:
例如,数据库迁移容器退出码为 0,才允许测试启动:
如果迁移进程退出码为 1,测试不应继续运行,因为测试针对的数据库状态已经不确定。
3. 典型数据流和故障路径
一个常见的测试启动顺序如下:
flowchart TD
A[启动数据库容器] --> B{健康检查通过?}
B -- 否 --> X[等待或超时,测试不启动]
B -- 是 --> C[运行一次性迁移]
C --> D{迁移退出码为 0?}
D -- 否 --> Y[测试失败,执行清理]
D -- 是 --> E[启动测试进程]
E --> F{测试退出码}
F -- 0 --> G[回收容器、网络和临时资源]
F -- 非 0 --> G
X --> G
Y --> G
这里有两个独立的判断:
- 数据库的健康状态;
- 迁移任务的退出状态。
健康检查通过并不代表迁移成功;迁移成功也不意味着所有应用接口都已经可用。
二、Compose 中的健康等待
1. healthcheck 检查什么
Compose 的 healthcheck 使用容器内部执行的命令判断服务健康状态。其状态通常为:
starting -> healthy
starting -> unhealthy
例如:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app_test"]
interval: 2s
timeout: 3s
retries: 15
start_period: 5s
各字段含义是:
test:执行健康探测的命令;interval:两次探测之间的间隔;timeout:单次探测允许的最长时间;retries:连续失败多少次后标记为unhealthy;start_period:启动初期的宽限期,适合数据库初始化等慢启动场景。
pg_isready 主要检查 PostgreSQL 是否接受连接请求。它比单纯的 nc -z db 5432 更接近协议层就绪,但仍然不等于应用迁移已完成。
2. depends_on 的条件语义
现代 Compose 规范支持为依赖声明条件:
services:
migrate:
image: myapp:test
depends_on:
db:
condition: service_healthy
command: ["./bin/migrate"]
tests:
image: myapp:test
depends_on:
migrate:
condition: service_completed_successfully
command: ["pytest", "-q"]
两种条件的语义不同:
service_healthy:依赖服务的健康检查必须为healthy;service_completed_successfully:依赖容器必须退出,且退出码为 0。
Compose 会按依赖关系启动服务,并等待条件成立后再创建或启动后继服务。它不会因为 depends_on 就自动理解应用协议;协议判断必须由 healthcheck 或一次性任务本身实现。
3. 一个完整的 Compose 测试文件
下面的示例包含数据库、迁移和测试三个阶段:
name: app-test
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app_test"]
interval: 2s
timeout: 3s
retries: 15
start_period: 5s
migrate:
build:
context: .
target: test
environment:
DATABASE_URL: postgresql://app:app@db:5432/app_test
depends_on:
db:
condition: service_healthy
command: ["./bin/migrate"]
tests:
build:
context: .
target: test
environment:
DATABASE_URL: postgresql://app:app@db:5432/app_test
depends_on:
migrate:
condition: service_completed_successfully
command: ["pytest", "-q"]
volumes:
db-data:
这里的 db 是 Compose 网络中的服务名,因此其他容器使用:
db:5432
而不是 localhost:5432。在 Linux 容器内,localhost 指向当前容器自身;它不会指向数据库容器。
4. 构建并运行
假设 Dockerfile 中存在名为 test 的构建阶段,可以执行:
docker compose -f compose.test.yml up \
--build \
--abort-on-container-exit \
--exit-code-from tests
参数作用:
--build:在运行前构建镜像;--abort-on-container-exit:任意容器退出后停止其他容器;--exit-code-from tests:Compose 命令最终返回tests容器的退出码。
正常输出的关键部分应类似:
db | database system is ready to accept connections
migrate | migrations applied
migrate exited with code 0
tests | 42 passed
tests exited with code 0
如果迁移失败:
migrate | migration failed: relation already exists
migrate exited with code 1
由于 service_completed_successfully 不成立,tests 不应被启动。最终 CI 必须返回非零退出码。
5. 清理资源
测试命令结束后,应显式执行:
docker compose -f compose.test.yml down \
--volumes \
--remove-orphans
其含义是:
down:停止并删除该 Compose 项目创建的容器和网络;--volumes:删除 Compose 声明的命名卷;--remove-orphans:删除同一项目中不再出现在当前配置里的孤儿容器。
可以在 Shell 中保证失败时也清理:
set -Eeuo pipefail
cleanup() {
docker compose -f compose.test.yml down \
--volumes \
--remove-orphans
}
trap cleanup EXIT
docker compose -f compose.test.yml up \
--build \
--abort-on-container-exit \
--exit-code-from tests
trap 保证正常退出、测试失败和大多数 Shell 错误路径都会执行清理。它不能保证机器断电、内核崩溃或 CI 强制终止进程时仍然运行,因此 CI 还应使用唯一的 Compose 项目名,并在作业开始时清理已知的过期项目。
三、为什么只用 sleep 是错误的
下面的写法看似简单:
docker compose up -d db
sleep 5
docker compose run --rm migrate
它把等待条件近似成:
但真实启动时间可能受以下因素影响:
- 首次初始化数据目录;
- 主机磁盘速度;
- CI 虚拟机负载;
- 镜像解压和挂载速度;
- 数据库版本或初始化脚本变化。
如果实际就绪时间为 7 秒,测试会过早连接;如果通常 1 秒就绪,固定等待又浪费时间。更合理的模型是轮询谓词:
while deadline 未到:
执行协议级探测
如果成功:
继续
等待 interval
失败:
返回诊断信息并清理
健康检查的优点是把“是否可用”的判断放入服务定义,使 Compose、开发环境和 CI 使用同一个条件。缺点是健康命令必须真实反映需求,否则会得到“错误的健康”。
例如:
healthcheck:
test:
[
"CMD-SHELL",
"curl --fail --silent http://localhost:8080/readyz"
]
这里的 /readyz 应该表示应用已经完成必要初始化。如果只检查 /livez,可能只能证明进程存在,不能证明数据库连接池、迁移或下游依赖已经准备好。
四、一次性依赖的设计
1. 迁移任务必须幂等或明确失败
一次性迁移容器通常执行:
启动 -> 读取数据库 -> 执行迁移 -> 退出
它不应该通过 tail -f /dev/null 保持运行。否则 Compose 只能看到:
migrate: running
却不知道迁移是否已经成功完成。
正确的成功信号是进程退出码:
./bin/migrate
status=$?
exit "$status"
迁移工具还应根据项目需求具备幂等性。例如重复执行已应用的迁移时返回 0,而不是重复修改表结构。幂等并不等于忽略所有错误;连接失败、SQL 语法错误和锁超时仍应返回非零退出码。
2. 数据准备任务与迁移任务要区分
迁移负责改变结构:
CREATE TABLE ...
ALTER TABLE ...
种子数据或测试夹具负责写入数据:
INSERT ...
两者可以拆成:
services:
migrate:
depends_on:
db:
condition: service_healthy
command: ["./bin/migrate"]
seed:
depends_on:
migrate:
condition: service_completed_successfully
command: ["./bin/seed"]
tests:
depends_on:
seed:
condition: service_completed_successfully
command: ["pytest", "-q"]
这样,测试启动条件被明确表示为:
如果把这些步骤全部塞进测试命令:
./bin/migrate && ./bin/seed && pytest
功能上也可能成立,但状态边界不再清晰,失败时不容易区分是迁移失败、数据准备失败还是测试失败。
3. docker compose run 与 up 的区别
docker compose run --rm tests 适合临时运行某个服务的命令:
docker compose -f compose.test.yml run --rm tests pytest -q
它默认会启动依赖服务,但不会像完整的 up 那样管理所有服务的联合生命周期。需要特别注意:
docker compose run --rm --no-deps tests
--no-deps 会跳过依赖启动。只有在数据库已经由外部环境提供并且测试明确使用该环境时,才应这样做。
对于包含迁移、测试和统一退出码的完整测试拓扑,up --abort-on-container-exit --exit-code-from tests 通常更容易表达整体生命周期。
五、Fixture:把资源生命周期绑定到测试生命周期
1. Fixture 的定义
Fixture 是测试框架管理的资源。它通常有两个阶段:
setup -> yield -> teardown
setup:创建容器、网络、数据库连接或测试数据;yield:把资源交给测试;teardown:关闭连接、删除数据、停止容器。
关键不在于使用了什么测试框架,而在于清理必须位于 finally 或等价的生命周期钩子中。
2. 使用 Testcontainers 启动 PostgreSQL
Testcontainers 是通过 Docker Engine API 创建临时容器的测试库。它通常负责:
- 拉取或使用指定镜像;
- 创建容器;
- 映射端口;
- 等待容器满足等待策略;
- 将连接信息提供给测试;
- 在测试结束时停止和删除容器。
下面是 Python 与 pytest 的典型结构:
# tests/conftest.py
import time
import pytest
import psycopg
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:16-alpine") as container:
url = container.get_connection_url()
# 根据所使用的 psycopg 驱动调整 URL 前缀。
# 某些 Testcontainers Python 版本返回 postgresql+psycopg2://。
url = url.replace("postgresql+psycopg2://", "postgresql://")
deadline = time.monotonic() + 30
while True:
try:
with psycopg.connect(url, connect_timeout=2) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1")
assert cur.fetchone() == (1,)
break
except Exception:
if time.monotonic() >= deadline:
raise
time.sleep(0.5)
yield url
测试使用这个 Fixture:
# tests/test_health.py
import psycopg
def test_database_is_available(postgres):
with psycopg.connect(postgres) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1")
assert cur.fetchone() == (1,)
安装依赖的示例:
python -m pip install pytest testcontainers[postgresql] psycopg[binary]
pytest -q
这里的 scope="session" 表示整个 pytest 会话共享一个 PostgreSQL 容器。测试结束后,with PostgresContainer(...) 的上下文管理器负责退出容器并执行库提供的清理逻辑。
3. 为什么仍然需要应用层等待
Testcontainers 的等待策略由具体模块和版本实现决定,常见策略包括:
- 等待映射端口可连接;
- 等待日志中出现特定文本;
- 等待 HTTP 响应;
- 使用自定义等待策略。
“端口已打开”并不总是等于“数据库可以完成客户端认证和查询”。因此示例又执行了:
SELECT 1
这形成了更强的就绪条件:
等待循环必须有截止时间。没有截止时间的重试会把真实故障变成永不结束的 CI 作业。
4. Fixture 作用域与隔离
Fixture 作用域影响速度和隔离:
| 作用域 | 容器数量 | 隔离性 | 适用场景 |
|---|---|---|---|
| 每个测试函数 | 多 | 强 | 测试会修改全局状态 |
| 每个测试类 | 中 | 中 | 类内共享数据准备 |
| 每个测试会话 | 少 | 弱 | 只读测试或可事务回滚测试 |
如果两个测试都执行:
INSERT INTO users ...
而数据库是 session 级共享,就必须采用至少一种隔离手段:
- 每个测试使用唯一业务标识;
- 每个测试在事务中运行并回滚;
- 每个测试清理自己创建的数据;
- 为测试类或测试函数创建独立数据库;
- 使用独立容器。
仅仅把数据库放进容器,不会自动隔离测试数据。
5. Java Testcontainers 的生命周期对照
Java、Go、.NET 等生态有各自的 Testcontainers 实现,API 名称不能跨语言直接照抄。以 Java JUnit 5 为例:
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.DriverManager;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("app_test")
.withUsername("app")
.withPassword("app");
@Test
void canQueryDatabase() throws Exception {
try (var connection = DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword());
var statement = connection.createStatement();
var result = statement.executeQuery("SELECT 1")) {
result.next();
assertEquals(1, result.getInt(1));
}
}
}
static 容器通常对应测试类级别生命周期;@Container 和 @Testcontainers 由 JUnit 集成负责启动与停止。若测试需要迁移,可以在测试前调用迁移工具,或者把迁移放入一个专用容器,而不是假设数据库镜像自动完成应用迁移。
六、资源清理:容器、网络、卷和临时数据
1. 要清理的不只是容器
一次测试可能创建:
容器
网络
命名卷
绑定挂载产生的宿主机文件
临时镜像或构建缓存
Testcontainers 的标签和资源记录
它们的生命周期并不完全相同:
docker compose down通常删除容器和 Compose 网络;docker compose down --volumes还删除 Compose 管理的命名卷;- Bind mount 对应的宿主机目录不会因为删除容器而自动删除;
- BuildKit 构建缓存不会因为
down被删除; - 外部创建且未被当前项目管理的资源不会自动清理。
因此,“容器已经退出”不等于“测试环境已经恢复”。
2. 卷删除的风险
测试数据库常使用命名卷:
volumes:
db-data:
如果不使用 --volumes,下次测试可能复用上一次运行的数据,导致:
- 迁移不再从零开始;
- 测试依赖的初始数据消失或残留;
- 测试顺序影响结果;
- 通过本地运行、CI 失败。
但生产或开发数据库不能随意执行:
docker compose down -v
因为这会删除 Compose 管理的数据卷。测试环境可以使用临时卷,开发和生产环境则必须把数据持久化策略与清理命令分开。
3. Testcontainers 的资源回收边界
Testcontainers 实现通常会给资源添加标签,并提供资源回收机制;某些实现还会运行独立的资源回收器。实际行为取决于语言库、版本和 Docker 环境。
不能把资源回收器视为绝对保证:
- 进程被强制终止时,测试库未必来得及记录清理;
- Docker daemon 重启可能改变清理时机;
- 远程 Docker daemon 上的网络延迟会放大清理问题;
- 调试时保留容器可能是有意行为。
因此 CI 仍应:
- 使用唯一项目名或唯一资源前缀;
- 在作业结束时执行明确清理;
- 对超时作业设置外部回收策略;
- 定期检查带有测试标签的残留容器和卷。
不要用:
docker system prune -a --volumes
作为普通测试清理命令。它可能删除同一主机上其他项目的镜像、卷、网络和构建缓存。
七、失败路径与诊断
1. 健康检查一直失败
先查看状态:
docker compose -f compose.test.yml ps
典型结果可能是:
NAME SERVICE STATUS
app-test-db-1 db Up (unhealthy)
然后查看健康检查日志:
docker inspect app-test-db-1 \
--format '{{json .State.Health}}'
再查看容器日志:
docker compose -f compose.test.yml logs db
常见原因包括:
pg_isready使用的用户名或数据库名与环境变量不一致;- 健康检查命令不存在;
start_period太短;- 数据库初始化脚本失败;
- 容器内存不足;
- 端口或卷权限配置错误。
2. 测试容器连接失败
在 Compose 网络中,连接地址应类似:
postgresql://app:app@db:5432/app_test
在宿主机运行的测试进程中,不能直接使用 db:5432,因为 db 是容器网络内的 DNS 名称。若数据库使用端口映射:
ports:
- "127.0.0.1:15432:5432"
宿主机测试才应使用:
localhost:15432
绑定到 127.0.0.1 可以避免测试数据库暴露到局域网;但对于 Linux 容器之间的通信,服务名和容器端口仍是更稳定的方式。
3. 迁移退出码为 0,但测试仍失败
这通常说明“结构已准备好”而不是“业务前置条件已满足”。排查顺序可以是:
docker compose -f compose.test.yml logs migrate
docker compose -f compose.test.yml logs tests
docker compose -f compose.test.yml config
config 用于查看 Compose 合并变量和最终配置,尤其适合检查:
DATABASE_URL是否指向正确服务;- 是否错误使用了
localhost; depends_on条件是否被覆盖;- override 文件是否替换了
command; - profile 是否导致服务未启用。
八、并行测试与资源命名
并行测试时,固定资源名会造成冲突。例如多个 CI 作业同时运行:
app-test-db-1
app-test_default
db-data
如果它们使用同一个 Compose 项目名,就可能连接到错误的网络或复用不应复用的卷。
可以为每个作业设置唯一项目名:
export COMPOSE_PROJECT_NAME="app-test-${CI_JOB_ID:-local}"
docker compose -f compose.test.yml up \
--abort-on-container-exit \
--exit-code-from tests
更可靠的隔离目标是:
对 Testcontainers 也应避免固定宿主机端口。让 Docker 分配随机宿主机端口,再通过库提供的连接信息访问,通常比硬编码 15432 更适合并行运行。
容器之间可以固定使用容器端口:
db:5432
因为每个 Compose 项目拥有独立网络;宿主机端口则不应假设全局唯一。
九、Compose 与 Testcontainers 如何取舍
Compose 更适合完整拓扑
当测试需要多个服务时,Compose 的优势是配置直接表达拓扑:
数据库 -> 迁移 -> API -> 测试客户端
它适合:
- 复用开发环境中的服务定义;
- 本地调试完整依赖;
- CI 中运行多个协作容器;
- 通过 override 或 profile 切换测试服务。
但 Compose 本身不是测试框架。它不会管理断言、Fixture 作用域、测试数据隔离或单个测试函数的生命周期。
Testcontainers 更适合测试代码控制依赖
Testcontainers 把容器生命周期放入测试代码,适合:
- 每个测试类或测试会话动态创建依赖;
- 根据测试参数启动不同版本的服务;
- 让测试自动获取随机端口;
- 需要和 JUnit、pytest 等 Fixture 紧密结合;
- 集成测试只依赖少量外部服务。
代价是:
- 测试启动会增加镜像拉取和容器创建时间;
- 不同语言库的等待策略和 API 不完全一致;
- 并行测试需要额外关注 CPU、内存和端口;
- 调试时需要理解测试框架和 Docker 资源的双重生命周期。
二者也可以组合:用 Compose 启动共享基础设施,用 Testcontainers 管理某个需要动态隔离的依赖。但必须明确谁负责创建、等待和销毁每个资源,不能让两个工具同时管理同一个容器。
十、Linux 容器边界
本文示例使用 Linux 容器,以下事实不能直接推广到 Windows 容器:
- 容器内命令默认使用 Linux 用户态工具;
/var/lib/postgresql/data是 Linux 镜像中的路径;sh -c、pg_isready、文件权限和信号行为依赖 Linux 镜像;- 容器网络和宿主机网络的关系取决于 Docker Desktop、原生 Linux Engine 或其他运行时;
- Bind mount 的权限表现与宿主机文件系统有关。
在原生 Linux 上,容器通常直接由 Docker Engine 创建;在 Docker Desktop 上,Linux 容器运行在一个 Linux 虚拟机中,宿主机文件共享和网络转发具有额外边界。测试代码应尽量通过容器服务名、动态端口和库提供的连接信息工作,而不是依赖宿主机实现细节。
十一、一个可验证的完成条件
一次测试环境可以用下面的条件判断是否设计完整:
逐项对应:
-
DependencyReady
依赖的健康检查或应用层探测通过。 -
OneShotJobsSucceeded
迁移、种子数据、代码生成等任务退出码均为 0。 -
AssertionsPassed
测试进程退出码为 0。 -
CleanupCompleted
受测试管理的容器、网络和临时卷已经按策略处理。
前三项决定测试结果,第四项决定下一次测试是否仍然可信。只有把服务健康、一次性任务、Fixture 生命周期和资源清理分别建模,容器化测试才不会退化成“启动几个容器,然后碰运气执行测试”。
系列导航与关联阅读
- 系列入口:Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:Docker Compose 开发工作流:Bind、Watch、Override、Profile 和调试
- 下一篇:Docker 构建上下文与 .dockerignore:传输边界、缓存和 Secret 泄漏
- 延伸:Docker CI 构建流水线:缓存、并行、扫描、签名、推送和晋级
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论