At 2:07 AM, I was jolted awake by a barrage of alerts screaming “user contexts are all messed up.” The chat bot had suddenly started mixing Zhang San’s conversation into Li Si’s session—acting like it had amnesia and a split personality. Still groggy and grumpy, I dug in and found a data race in our memory store under concurrent writes. A bug that had lurked for three weeks had finally detonated in production.
If you maintain any component that has “memory”—chat context, user state cache, a shopping cart stub—you’ve probably also stepped into the “sleep-deprived, manual-clicky-test” trap. This article walks through how we built an automated testing framework for a memory store using Pytest + Docker, catching these bugs before they could trigger another 3 AM page.
Breaking Down the Problem: Why Manual Testing Can’t Guard Your "Memory"
The memory store does something deceptively simple: accept a session_id and messages, persist the conversation history, and return it on demand. It sounds CRUD, but what really makes your hair stand on end is the state: multiple sessions being written concurrently, appending messages to the same session across requests, restoring from disk after a service restart, TTL expiration and eviction… Pick any combined scenario and it could take minutes to construct by hand, and you’re still likely to miss something. Then there’s that moment before a release when all you want to know is “will requests drop if Redis crashes?”, and you end up scrambling to set up an environment for twenty minutes, completely demoralized.
The typical unit test approach mocks the storage layer and tests only the business logic. That can verify code branches, but when you hit a problem like “after the process really crashes, can we replay the WAL file completely?”, mocks are little more than emotional support. Integration tests, on the other hand, often depend on a shared environment—“your dirty data nuked my test case” becomes the norm. You need a clean, isolated, and reproducible memory store instance for every test suite, used once and thrown away. Enter Docker.
Solution Design: Why Pytest + Docker, Not Something Else
We had three hard requirements:
- Isolation – each test case runs against its own memory store instance with zero crosstalk.
- Reproducibility – running in CI behaves exactly the same as on my local machine; no “works on my box” excuses.
- Lightweight – running a few hundred cases shouldn’t mean a 20‑minute coffee break.
We eliminated several alternatives early on:
- Shared test server – a dead end. Data contamination is inevitable, and even the “drop all tables” step becomes unreliable.
- Docker Compose in setUp/tearDown – workable, but start/stop orchestration is too heavy. Each case added dozens of seconds, ballooning a full run to nearly half an hour.
- Purely in‑memory mocks – they fail completely for persistence and crash‑recovery scenarios.
Eventually we settled on Pytest fixtures directly managing Docker containers via the docker-py client library. Each test module, or even each test function, can obtain a fresh, randomly ported memory‑store container that is automatically destroyed afterward. Why not spin up a new container for every single test? That’s ultimate isolation, but slow. We made a pragmatic trade‑off: use a session‑scoped container for tests that heavily mutate state, and a function‑scoped one for destructive tests (like killing the process). The essence of this architecture: fixtures define the lifecycle, docker-py guarantees environmental consistency.
Core Implementation: Building Ephemeral Memory Store Tests, Step by Step
Step 1: Launch a Clean Redis Backend with a Fixture
This fixture gives every test function its own absolutely clean, disposable Redis instance. It uses a random host port to avoid collisions during parallel execution.
# 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 保证容器删除
Step 2: Write a Real‑World Test – Concurrent Message Append
Now we verify that the memory store doesn’t lose or scramble messages under high concurrency. Multiple threads append messages to the same session; after they finish, we check the final length and order.
# 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 = [] # 收集每个线
... (the actual test continues with thread orchestration and assertions) ...
With this approach, the test always runs against a pristine Redis—no leftovers, no cross‑test pollution. Pair it with pytest-xdist for parallel execution and those 3 AM wake‑up calls become a fading memory.
Top comments (0)