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 进程可能仍在:

  1. 初始化数据目录;
  2. 生成配置文件;
  3. 启动后台进程;
  4. 监听 Unix socket 或 TCP 端口;
  5. 接受认证和 SQL 请求。

因此,测试依赖的正确条件不是:

Cdb=runningC_{\text{db}} = \text{running}

而是:

Rdb=CdbPlistenAprotocolIinitializedR_{\text{db}} = C_{\text{db}} \land P_{\text{listen}} \land A_{\text{protocol}} \land I_{\text{initialized}}

其中:

  • CdbC_{\text{db}}:数据库容器仍在运行;
  • PlistenP_{\text{listen}}:数据库监听了目标端口;
  • AprotocolA_{\text{protocol}}:客户端协议探测成功;
  • IinitializedI_{\text{initialized}}:数据库已经完成必要的初始化。

仅检查 TCP 端口只能证明 PlistenP_{\text{listen}},不能证明后两个条件。

2. 一次性依赖与长期依赖不同

测试拓扑中至少有三类容器:

类型 示例 预期生命周期
长期服务 PostgreSQL、Redis 在测试期间持续运行
一次性任务 migrate、seed、生成代码 执行成功后退出
测试进程 pytest、JUnit、Go test 测试结束后退出,并提供退出码

一次性任务不是“启动后一直运行的服务”。它的成功条件是:

Ejob=exit code 0E_{\text{job}} = \text{exit code } 0

例如,数据库迁移容器退出码为 0,才允许测试启动:

Rtests=RdbEmigrate=0R_{\text{tests}} = R_{\text{db}} \land E_{\text{migrate}} = 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

这里有两个独立的判断:

  1. 数据库的健康状态
  2. 迁移任务的退出状态

健康检查通过并不代表迁移成功;迁移成功也不意味着所有应用接口都已经可用。


二、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

它把等待条件近似成:

Tready5sT_{\text{ready}} \leq 5\text{s}

但真实启动时间可能受以下因素影响:

  • 首次初始化数据目录;
  • 主机磁盘速度;
  • 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"]

这样,测试启动条件被明确表示为:

Rtests=RdbEmigrate=0Eseed=0R_{\text{tests}} = R_{\text{db}} \land E_{\text{migrate}}=0 \land E_{\text{seed}}=0

如果把这些步骤全部塞进测试命令:

./bin/migrate && ./bin/seed && pytest

功能上也可能成立,但状态边界不再清晰,失败时不容易区分是迁移失败、数据准备失败还是测试失败。

3. docker compose runup 的区别

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 创建临时容器的测试库。它通常负责:

  1. 拉取或使用指定镜像;
  2. 创建容器;
  3. 映射端口;
  4. 等待容器满足等待策略;
  5. 将连接信息提供给测试;
  6. 在测试结束时停止和删除容器。

下面是 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

这形成了更强的就绪条件:

Rfixture=PportAauthQqueryR_{\text{fixture}} = P_{\text{port}} \land A_{\text{auth}} \land Q_{\text{query}}

等待循环必须有截止时间。没有截止时间的重试会把真实故障变成永不结束的 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 仍应:

  1. 使用唯一项目名或唯一资源前缀;
  2. 在作业结束时执行明确清理;
  3. 对超时作业设置外部回收策略;
  4. 定期检查带有测试标签的残留容器和卷。

不要用:

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

更可靠的隔离目标是:

资源名=项目名前缀+作业唯一标识\text{资源名} = \text{项目名前缀} + \text{作业唯一标识}

对 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 -cpg_isready、文件权限和信号行为依赖 Linux 镜像;
  • 容器网络和宿主机网络的关系取决于 Docker Desktop、原生 Linux Engine 或其他运行时;
  • Bind mount 的权限表现与宿主机文件系统有关。

在原生 Linux 上,容器通常直接由 Docker Engine 创建;在 Docker Desktop 上,Linux 容器运行在一个 Linux 虚拟机中,宿主机文件共享和网络转发具有额外边界。测试代码应尽量通过容器服务名、动态端口和库提供的连接信息工作,而不是依赖宿主机实现细节。


十一、一个可验证的完成条件

一次测试环境可以用下面的条件判断是否设计完整:

TestSuccess=DependencyReadyOneShotJobsSucceededAssertionsPassedCleanupCompleted\text{TestSuccess} = \text{DependencyReady} \land \text{OneShotJobsSucceeded} \land \text{AssertionsPassed} \land \text{CleanupCompleted}

逐项对应:

  1. DependencyReady
    依赖的健康检查或应用层探测通过。

  2. OneShotJobsSucceeded
    迁移、种子数据、代码生成等任务退出码均为 0。

  3. AssertionsPassed
    测试进程退出码为 0。

  4. CleanupCompleted
    受测试管理的容器、网络和临时卷已经按策略处理。

前三项决定测试结果,第四项决定下一次测试是否仍然可信。只有把服务健康、一次性任务、Fixture 生命周期和资源清理分别建模,容器化测试才不会退化成“启动几个容器,然后碰运气执行测试”。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。