凌晨 2:07,我被一连串"用户上下文全乱了"的报警电话锤醒。群里消息刷屏,说聊天机器人突然开始把张三的对话接进了李四的 session,活像当场失忆加精分。我顶着起床气查了半天,最后发现是记忆存储服务在并发写入时存在数据竞争------一个隐藏了 3 周的 bug,终于在生产环境炸了。如果你也在维护任何带"记忆"的组件,比如聊天上下文、用户状态缓存、购物车暂存,那你大概率也踩过"觉睡不踏实,全靠手工点点点测试"的坑。这篇文章复盘我们如何用 Pytest + Docker 搭了一套记忆存储的自动化测试框架,把 bug 提前摁死在凌晨触发之前。
问题拆解:为什么手工测试护不住"记忆"
这个记忆存储服务做的事很纯粹:接受 session_id 和 messages,把对话历史存起来,再按需返回。听起来就是个 CRUD,但真正让人头皮发麻的是状态:多条 session 并发写入、同一个 session 跨请求追加消息、服务重启后磁盘持久化恢复、TTL 过期淘汰......随便拎出一个组合场景,手工构造都要几分钟,还容易漏。更别提上线前只想验证"Redis 挂了之后请求会不会丢",手忙脚乱搭半天环境,最后心态炸裂。
常规单元测试的解法是 mock 掉存储层,只测业务逻辑。这能验证代码分支,但一碰到"进程真的崩了,WAL 文件还能不能完整回放"这种问题,mock 约等于心理安慰。集成测试又严重依赖一个共享的测试环境,经常是"你插入的脏数据搞垮了我的用例,我想打人"。你需要一个每次测试都能拿到干净、隔离、可复现的记忆存储实例,用完就扔------这就是 Docker 的出场时机。
方案设计:为什么是 Pytest + Docker,而不是别的
我们有三个硬需求:
- 隔离性:每个测试用例跑在自己的记忆存储实例上,不串味。
- 可复现:CI 上跑和本地跑行为一致,不能出现"我机器上好好的"。
- 轻量:不能因为测个几百条 case 就等 20 分钟喝咖啡。
技术选型时我们排除了几个方案:
- 共享测试服务器:绝路,数据互相污染,连"清库"这个动作都不可靠。
- Docker Compose 在 setUp/tearDown 里启停:能用,但启动/停止编排太重,每条 case 等十几秒,累加起来跑完全量要将近半小时。
- 直接用内存存储 mock 掉:满足不了持久化和崩溃恢复场景。
最终选了 Pytest 的 fixture 直接管理 Docker 容器 ,基于 docker-py 客户端库启动临时容器。每个测试 module 甚至 function 都可以获取一个全新、随机端口的记忆存储容器,跑完自动销毁。为什么不每个 case 起一个新容器?那是终极隔离但慢。我们做了折中:对修改状态密集的 case 用一个 session 级别的容器,对需要破坏性测试(如杀进程)的 case 用 function 级别。这套架构的精髓就是 fixture 决定生命周期,docker-py 保证环境一致性。
核心实现:一步步搭出"即抛型"记忆存储测试
第一步:用 fixture 启动一个干净的 Redis 作为记忆存储后端。
这段代码解决的是"如何给测试用例一个绝对干净、用完即丢的 Redis 实例"。
python
# conftest.py
import pytest
import docker
import time
import random
@pytest.fixture(scope="session")
def docker_client():
"""会话级 docker client,复用连接"""
return docker.from_env()
@pytest.fixture
def memory_store(docker_client, request):
"""
每个测试 function 获得一个独立的 Redis 容器,
用随机端口避免并行冲突。
"""
host_port = random.randint(10000, 20000) # 避开常用端口
container = docker_client.containers.run(
"redis:7-alpine",
detach=True,
ports={"6379/tcp": host_port}, # 宿主机随机端口映射
auto_remove=True,
)
# 等待 Redis 就绪
for _ in range(10):
try:
container.exec_run("redis-cli ping")
break
except Exception:
time.sleep(0.5)
else:
raise RuntimeError("Redis container not ready")
store_url = f"redis://localhost:{host_port}"
# 通过 request 可以创建记忆存储客户端,这里为了示例直接用 redis client
# 实际项目中我们会初始化自己的 MemoryStore(store_url)
from redis import Redis
client = Redis.from_url(store_url)
client.flushdb() # 确保彻底干净
yield client # 测试执行
client.close()
container.stop() # 用完就停,auto_remove 保证容器删除
第二步:写一个真实场景的测试------并发追加消息,检查记忆完整性。
这段代码验证"记忆存储在高并发下会不会丢失或乱序"。
python
# test_memory.py
import threading
import pytest
from memory_sdk import MemoryStore # 假设这是你封装的记忆客户端
def test_concurrent_message_append(memory_store):
"""并发对同一个 session 追加 100 条消息,验证最终长度和顺序"""
store = MemoryStore(memory_store.connection_pool) # 直接用fixture生成的实际连接
session_id = "sess_concurrent"
msgs_per_thread = 50
results = [] # 收集每个线程最后一条 id 用于顺序检查
def append_messages(thread_id):
for i in range(msgs_per_thread):
store.append(session_id, f"msg_{thread_id}_{i}")
last_msgs = store.get_history(session_id)["messages"]
results.extend(last_msgs[-5:]) # 只取尾部队列
threads = [threading.Thread(target=append_messages, args=(i,)) for i in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
history = store.get_history(session_id)["messages"]
assert len(history) == 100, f"消息总数不对,得到 {len(history)}"
# 顺序检查:消息 id 格式是 msg_线程id_序号,不能出现交叉乱序
ordered = all(
history[i] < history[i+1] for i in range(len(history)-1)
if isinstance(history[i], str) and isinstance(history[i+1], str)
)
assert ordered, "消息存储顺序异常,并发追加出了问题"
第三步:模拟记忆存储崩溃重启,测试持久化能力。
这段代码解决"服务重启后历史消息是否完整恢复"的测试盲区。
python
def test_persistence_after_crash(docker_client):
"""
启动容器→写入数据→暴力 kill 容器→重新启动→验证数据还在
这个用例直接管理自己的容器,不依赖自动清理的 fixture
"""
host_port = random.randint(20001, 30000)
container = docker_client.containers.run(
"redis:7-alpine",
detach=True,
ports={"6379/tcp": host_port},
auto_remove=True,
)
# 等待就绪
# ...省略同样的 ready 逻辑
store = MemoryStore.from_url(f"redis://localhost:{host_port}")
store.append("crash_sess", "before_crash")
# 模拟 crash:强制 kill
container.kill()
container.wait() # 等容器退出
# 重新启动同一个数据卷?这里简单起见直接用同一镜像新容器,
# 实际项目中会用 docker volume 挂载 RDB 文件。这里演示逻辑。
# 此处仅为示意,生产需挂载持久化目录
new_container = docker_client.containers.run(
"redis:7-alpine",
detach=True,
ports={"6379/tcp": host_port},
auto_remove=True,
)
# 就绪等待
# ...
store = MemoryStore.from_url(f"redis://localhost:{host_port}")
history = store.get_history("crash_sess")
# 期望至少能恢复出之前的数据(需依赖持久化配置)
# assert "before_crash" in history["messages"] # 实际根据持久化策略判断
print("崩溃恢复验证通过" if history else "数据丢失") # 简化演示
new_container.stop()
踩坑记录:官方文档不会告诉你的 3 件事
坑 1:容器启动了,但服务还没 ready,测试直接撞墙。 现象是偶尔 ConnectionRefusedError,但 CI 上概率更高。原因很简单:docker run 返回了 container 对象,但容器内部的 Redis 进程可能还在加载 RDB 文件。解决方案是写一个显式的 readiness 检查(上文代码中的 exec_run("redis-cli ping") 循环)。这里有个小技巧:别用 docker-py 的 client.containers.run 返回后直接睡几秒,因为宿主机负载不同,固定 sleep 非常不可靠,必须用轮询。
坑 2:pytest-xdist 并行跑测试时端口冲突如车祸现场。 多进程并行时多个 worker 可能分配到同一个随机端口。我用 random.randint 加自认为的种子都没用,偶尔还是会撞。真正的解决方法是:让 Docker 自己分配端口,然后从容器信息里反查 。把 ports={"6379/tcp": None} 设为 None,docker 会自动分配一个宿主机空闲端口,然后通过 container.attrs["NetworkSettings"]["Ports"]["6379/tcp"][0]["HostPort"] 获取真实端口,这就永远没有冲突。改动虽小,但省去了一下午抓狂。
坑 3:容器没挂 volume,重启测试是自欺欺人。 在测试持久化时,很多人用同一个镜像直接 run 新容器,结果数据丢了------因为你根本没有把数据写到宿主机啊。必须显式创建 Docker volume 并挂载到 Redis 的数据目录,杀容器后再用相同的 volume 启动新容器,才能真正测试重启恢复。这一点被大部分博客忽略,直接套用 demo 就会漏掉持久化 bug。
效果验证:从熬夜手工到 5 分钟稳睡
统计了一周的数据对比:
| 维度 | 之前(手工+共享环境) | 之后(Pytest+Docker) |
|---|---|---|
| 回归测试耗时 | 约 2 小时(含修脏数据) | 5 分钟全量跑完 |
| 并行冲突 bug 漏出率 | 平均每版本 2.5 个 | 0 |
| 崩溃恢复场景覆盖 | 0 个用例 | 全自动化 12 个用例 |
| 凌晨报警次数 | 每周 2~3 次 | 连续 3 周为 0 |
最解气的是,上线前自动跑出了一条"并发追加导致消息乱序"的 case,直接拦住了一个 P0 事故。那一刻觉得花两天搭这条测试框架太值了。
可以直接用的代码
拿走这个 conftest.py 模板,把 redis 换成你自己的记忆存储镜像,改下 ready 命令即可:
python
import socket, docker, pytest, time
@pytest.fixture(scope="session")
def free_port():
with socket.socket() as s:
s.bind(("", 0))
return s.getsockname()[1]
@pytest.fixture
def service_container(docker_client, free_port):
container = docker_client.containers.run(
"your-memory-image",
detach=True,
ports={"8080/tcp": free_port},
auto_remove=True,
)
# readiness check 循环...
yield f"http://localhost:{free_port}"
container.stop()
把它放到 tests/conftest.py,你的记忆存储测试就拥有了"即抛环境"。
#Python #测试 #Docker #自动化测试 #踩坑
关于作者 我是阿福,一个曾经在凌晨三点改 bug 改到怀疑人生的后端/架构哥,专注用工程化手段减少业务系统的惊吓时刻。 GitHub: github.com/baofugege --- 上面有一些自动化测试工具和脚手架项目。 Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你省下几个通宵,请我喝杯咖啡。 提供服务:Python 后端性能优化 / 工程工具定制 / 技术咨询,联系 Telegram @baofugege