AI agents

Mocking a Memory Store for Fast Policy Tests

Antonio Evans6 min read

A split scene: on the left, a production memory store drawn as a stack of labeled disks with a clock and network cables; on the right.
Key points

Key points

  1. 1Replace the network-backed memory store with an in-memory class that implements the identical interface, so policy logic runs without I/O.
  2. 2Fake behavior that shapes policy decisions: reads, writes, TTL expiry, versioning and conflict errors.
  3. 3Leave serialization, authentication and data validation real, because faking them hides bugs the policy must survive.
  4. 4Add contract tests that run the same operations against fake and real store to catch drift before it reaches production.

Why policy tests crawl, and where the seconds go

A policy layer in an agent system makes decisions: what to remember, when to write it, what to retrieve, when to evict. In production every one of those decisions crosses a network boundary into a memory store, whether that is a database, a key-value service or a vector index. The policy itself is a few branches and comparisons. The store is the slow part.

The delay is not one thing. Connection setup and authentication handshakes dominate the first call. Every read and write pays a round-trip. Test isolation demands cleanup between cases, which is usually another round-trip. Parallel runners contend for the same backing service and either serialize or fail with interference, a problem Microsoft documents as a common reason tests against external resources get flaky choosing a testing strategy.

The fix is the standard test double: an object with the same interface that keeps state in a dictionary. It works because the policy's job is to decide, not to prove the network functions. When a double replaces an external resource, each test owns its own in-memory state and is isolated from every other test by construction choosing a testing strategy. That removes both the cleanup step and the contention.

Numbers make the scale concrete. Say a local store round-trip costs 4 ms and your policy suite makes 12 store operations per test across 400 tests. That is 4,800 operations and roughly 19 seconds of pure waiting, before any remote latency multiplies it. The same suite against a dictionary finishes the same 4,800 operations well under a tenth of a second. The policy logic did not change. You deleted the I/O.

One design point matters more than the rest: you are not stubbing methods to return canned values. You are writing a fake that owns real state. A write followed by a read returns what was written, TTLs actually expire, versions actually advance. That is what separates a meaningful policy test from a test that asserts your mocks agree with themselves.

What to fake: the behaviors your policy branches on

Fake exactly the semantics the policy reads. If the policy asks whether a key exists, whether a version is stale, or whether a write conflicted, the fake must answer as the real store would.

BehaviorFake it?WhyBug it prevents
Reads and writesYesEvery policy branch needs themWrites that never persist
TTL and expiryYesEviction policy depends on itUsing expired memories
Versioning and concurrencyYesConflict handling is policyLost updates
Conflict and retry errorsYes, injectableRetry logic lives in policyUnbounded retry loops
Serialization and encodingNoReal path, cheap to runPayloads that fail to round-trip
AuthenticationNoSecurity boundaryPolicies that skip access checks
Schema validationNoData correctnessInvalid records entering memory

Memory architecture guides split memory into structured and unstructured data with different lifetimes, and into semantic, episodic and procedural forms agent memory systems guide. A fake holding keys, values, versions and expiry timestamps serves all three forms, because the differences live in the policy, not the store. Long-term memory is defined as a persistence layer outside the context window that the agent queries selectively long-term memory guide. That layer is what you swap.

A minimal fake covering the five behaviors:

import time

class ConflictError(Exception):
    pass

class InMemoryMemoryStore:
    def __init__(self, clock=time.monotonic):
        self._data = {}          # key -> (value, version, expires_at)
        self._clock = clock
        self.fail_next_write = False

    def get(self, key):
        entry = self._data.get(key)
        if entry is None:
            return None
        value, version, expires_at = entry
        if expires_at is not None and self._clock() >= expires_at:
            del self._data[key]
            return None
        return value, version

    def put(self, key, value, expected_version=None, ttl=None):
        if self.fail_next_write:
            self.fail_next_write = False
            raise ConflictError(key)
        current = self.get(key)
        if expected_version is not None:
            if current is None or current[1] != expected_version:
                raise ConflictError(key)
        version = 1 if current is None else current[1] + 1
        expires_at = None if ttl is None else self._clock() + ttl
        self._data[key] = (value, version, expires_at)
        return version

    def delete(self, key):
        self._data.pop(key, None)

The injectable clock is the single highest-value decision here. It turns a TTL test from a multi-second sleep into a function call. It echoes write-back caching in hardware, where a changed line is updated on eviction rather than written through immediately cache write policies. The fake gives you a handle on when a write becomes visible, which is exactly what expiry logic depends on.

Conflict errors get their own switch because they are nearly impossible to trigger against a real store on demand. Testing that a policy retries once and then gives up needs an injected failure, not a race condition you hope for.

What to leave real: serialization, authentication, validation

The temptation is to fake everything and land a suite in single-digit milliseconds. Resist it. Tests against a double do not prove the program works against the real resource, and a classic drift example is a double that compares strings case-sensitively while the production store compares them case-insensitively choosing a testing strategy.

Keep these real even inside fast policy tests:

  • Serialization. Encoding a record to bytes and back is fast. Leave it inside the fake's path so a policy that stores an unserializable object fails in the unit suite, not in production.
  • Authentication and authorization. A policy that writes to another user's memory must be caught. If the fake skips auth, the policy can ship with no access check and still pass green.
  • Data validation. Schema checks, required fields, size limits. A validation rule the fake ignores is a rule that will reject real writes at the worst moment.

Jimmy Bogard's team tried swapping an in-memory provider for the real database to speed integration tests and abandoned it, reporting that the in-memory API did not match the real query provider and that they still had to write the real tests anyway avoid in-memory databases for tests. The lesson transfers directly: don't fake the parts whose entire job is to produce behavior the policy must handle. Fake transport and storage, keep the boundaries real.

The dividing line is the interface. If the interface is get, put, delete and search with typed objects on both sides, serialization and validation sit inside the implementation and run for whichever implementation is injected. Design the interface so the policy cannot tell which one it got.

Where the policy layer calls out to models rather than stores, the same swap logic applies. Quanta Inference offers text, image, video, audio and embedding models through one OpenAI-compatible API key, so a model call is as replaceable as a store call.

Wiring the fake in without touching the policy

The policy should receive the store through its constructor or a dependency-injection container. It should never import a global client or build a connection internally. When the store arrives as a parameter, the test injects the fake, production injects the real store, and the policy code is byte-identical in both.

class MemoryPolicy:
    def __init__(self, store, max_entries=100):
        self.store = store
        self.max_entries = max_entries

    def remember(self, key, value, ttl=None):
        current = self.store.get(key)
        if current is None and self._count() >= self.max_entries:
            self._evict_oldest()
        expected = current[1] if current else None
        return self.store.put(key, value, expected_version=expected, ttl=ttl)

A test then reads as intent, not plumbing:

def test_ttl_expiry_drops_memory():
    now = [1000.0]
    store = InMemoryMemoryStore(clock=lambda: now[0])
    policy = MemoryPolicy(store)
    policy.remember("pref:lang", "en", ttl=30)
    now[0] += 31
    assert store.get("pref:lang") is None

This follows the hierarchy practitioners converge on: fast fakes for the bulk of coverage, real integration tests for confidence, split by layer DAO testing discussion. Some teams go further and run real containers in CI, noting that a local database is often fast enough to test against directly choosing a testing strategy. The approaches are complementary: the fake carries the everyday policy suite, the container carries a smaller integration suite.

If the surrounding services do not exist yet and standing them up is the real cost, a build partner can scaffold the codebase and its test layers with the split already in place. Quanta takes a described app or service and builds it with you.

Contract tests: the guard against drift

The real risk of a hand-written fake is drift. The interface stays the same while behavior diverges, and the interface offers a false sense of security because it promises a shape, not semantics. The remedy is contract tests: one shared suite run against both implementations preventing API drift with contract tests.

Write the contract once as operations plus assertions, then parameterize it.

import pytest

@pytest.fixture(params=["fake", "real"])
def store(request):
    if request.param == "fake":
        yield InMemoryMemoryStore()
    else:
        yield connect_real_store()  # skipped unless INTEGRATION=1

def test_put_then_get_roundtrip(store):
    store.put("k", {"a": 1})
    assert store.get("k")[0] == {"a": 1}

def test_stale_version_raises(store):
    store.put("k", "v1")
    with pytest.raises(ConflictError):
        store.put("k", "v2", expected_version=99)

def test_expired_key_is_gone(store):
    store.put("k", "v", ttl=0)
    assert store.get("k") is None

Run the fake variant on every commit and the real variant in nightly CI. An assertion that passes on the fake and fails on the real store is a drift bug, and the test name tells you which behavior diverged.

Contract tests also settle which behaviors are part of the contract at all. If "get after expiry returns None" is in the contract, the fake must implement it. If the real store returns a tombstone instead, either encode that difference or fix the fake. The contract tests are the source of truth for the interface's meaning, not the type signatures.

The decision rule and a ship checklist

Use the fake when the test is about a decision the policy makes. Use the real store when the test is about integration: real serialization, real errors, real latency. Most tests fake, a small tagged subset goes real, and CI runs both. If a policy test takes more than a few milliseconds, something real leaked into the fast path.

Before you ship the fake, confirm:

  • Every method on the production interface exists on the fake with the same signature.
  • Reads, writes, TTL, versioning and conflict errors use real state, not stubs.
  • Serialization, auth and validation are exercised, inside the fake or in a wrapper that stays real.
  • A contract suite runs against both implementations.
  • The clock is injectable so TTL tests never sleep.
  • Conflict injection exists so retry logic is testable.
  • Real-store tests are tagged and run in CI.
FAQ

FAQ

Why not just mock the store's methods with a library?

Method-level mocks return canned values and cannot answer a read that follows a write, satisfy a version check or expire a key. A policy test needs real state to mean anything. Write a small stateful fake with a dictionary, or you will not be able to run contract tests at all.

How do I test TTL behavior without slowing the suite down?

Inject the clock. Give the fake a callable that returns the current time and advance it in the test rather than sleeping. The TTL test then runs in microseconds and can cover boundaries like exactly-at-expiry and one tick past.

Where do integration tests with the real store fit?

Keep a smaller tagged suite that runs against a container or real service, usually on every CI commit and optionally locally. It covers serialization, derived errors and latency. The fast fake suite runs constantly on developer machines.

How do I know when the fake has drifted from production?

The contract tests fail. When the real store changes semantics, a shared assertion that passes on the fake and fails on the real store points at the exact behavior. Add the case to the contract so the fake must be updated to match.

What goes wrong if I fake authentication?

The policy can ship without an access check and still pass every test. Leave auth real, or put it in a thin wrapper both implementations share, so an unauthorized write fails in the fast suite where you can fix it.

Sources

Sources

  1. Choosing a testing strategy - EF Core
  2. Agent Memory Systems: A Complete Engineering Guide
  3. What Is Long-Term Memory in AI Agents? A Practical Guide
  4. PDF Caches (Writing) - Cornell CS
  5. Avoid In-Memory Databases for Tests
  6. Testing DAO Methods in Java: Fake Implementations vs. ...
  7. Preventing API Drift with Contract Tests
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles