At 2 a.m. I got woken up by an alert call – our online AI assistant suddenly “lost its memory.” A user asked, “Where did we leave off last time?” and it replied, “How can I help you?” Checking the logs, I found that a migration script for the vector database had changed the write path: all old memories were written into a new collection, but retrieval was still reading from the old one. Manually regressing every memory scenario would take at least two hours, and even then I couldn’t guarantee full coverage. That experience pushed me to scrap the manual tests entirely and build an automated verification pipeline with pytest + Docker. Now, any memory storage change runs 15 cases in 3 minutes – zero missed regressions.
Why AI memory consistency is so hard to test
An AI app’s “memory” isn’t just a simple SQL row. It spans the full chain: text summarization → embedding vector → vector DB write → similarity retrieval → context concatenation. A slip at any node can make the assistant forget or mix up conversations. My team uses Chroma as a high-performance vector store, together with a custom MemoryManager for adding, deleting, and fuzzy-retrieving memories. In daily iteration, we frequently change embedding models, tweak chunking strategies, or even upgrade Chroma itself.
The original testing approach: after changing code, manually spin up a local Chroma instance, use curl or throwaway scripts to insert a few memories, then eyeball the retrieval results. That had three fatal flaws:
- Severe state pollution – leftover data from the previous case would affect the next one. You’d constantly have to manually wipe the collection; if you forgot, you’d wonder, “Why did this passing test suddenly break?”
- Coverage relying on your brain – with 15 scenarios, you’d lose track of whether you ran number 9, tracking everything with a paper checklist.
- Huge regression cost – re-running everything before each release took at least 2 hours, making CI integration impossible.
Worse, unit tests that mock out the Chroma client completely avoid real network I/O, embedding computation, and vector comparison – that’s self-deception. What we need is to run assertions against the real environment, not test logic against fake data.
Why pytest + Docker, not something else
I needed a solution that meets three requirements:
- Disposable environment: every test gets a brand-new Chroma with no leftover data.
- Real end-to-end path: truly call the embedding model, write to disk/in-memory indexes, and compute cosine distance.
- CI‑ready: runs as a single command on a developer’s machine and in CI, finishing in under 5 minutes.
Why not mock unit tests? As explained, mocked I/O won’t reveal that an embedding model’s dimension doesn’t match Chroma’s, nor will it expose retrieval differences after index rebuilds.
Why not full-stack E2E? Spinning up the whole AI app plus an LLM service is too heavy (10+ minutes), making it unsuitable for frequent regression.
So I settled on pytest + testcontainers + chromadb:
-
testcontainerslets you manage Docker containers in code – no extra docker-compose needed. The container lifecycle is tied to a fixture, and when pytest exits, the container is destroyed automatically. -
chromadb.Clientconnects directly to the container’s HTTP port, giving a real client experience. - Before each test, a fixture creates an isolated collection; after the test, it’s deleted. Pollution eliminated.
The architecture is dead simple: a pytest fixture starts a Chroma Docker container → returns a client → test functions perform memory storage/retrieval → assert consistency → auto-cleanup. No third-party mocks, no middleware.
Core implementation: tests as living documentation
1. Manage the Chroma container lifecycle with a fixture
This code solves “how to make the database come alive on its own, and die after testing.” It uses testcontainers.GenericContainer to pull the Chroma image and wait for the service to be ready.
# conftest.py
import pytest
from testcontainers.core.container import GenericContainer
from testcontainers.core.waiting_utils import wait_for_logs
import chromadb
from chromadb.config import Settings
@pytest.fixture(scope="session")
def chroma_container():
"""启动 Chroma 容器,返回容器对象,session 级复用"""
container = (
GenericContainer("chromadb/chroma:0.4.22")
.with_exposed_ports(8000)
)
container.start()
# 等待日志确认服务就绪,避免客户端握手失败
wait_for_logs(container, "Uvicorn running on http://0.0.0.0:8000")
yield container
container.stop()
2. Fixture provides an isolated client and collection
This fixture automatically destroys the previous collection and creates a new one before each test function, guaranteeing zero interference.
@pytest.fixture
def chroma_client(chroma_container):
"""返回连接容器内 Chroma 的 Client"""
host = chroma_container.get_container_host_ip()
port = chroma_container.get_exposed_port(8000)
return chromadb.Client(Settings(
chroma_api_impl="rest",
chroma_server_host=host,
chroma_server_http_port=port
))
@pytest.fixture
def memory_collection(chroma_client, request):
"""
为每个测试函数创建独立 collection,测试结束直接删除。
collection 名使用测试函数名,方便问题回溯。
"""
collection_name = f"test_{request.node.name}"
collection = chroma_client.create_collection(collection_name)
yield collection
chro