I Wrote a Playwright Script to Test LLM Long-Term Memory - and Found 3 Critical Bugs
Deconstructing the problem: why manual testing fails for LLM long-term memory
We were building an LLM-powered customer support assistant, with LangChain’s ConversationSummaryBufferMemory handling long-term storage on the backend. The dream was simple: the user’s name, requests, and preferences should be remembered across sessions. The reality? Memory is probabilistic - the model forgets sometimes, the framework messes up sometimes, and edge cases (like concurrent sessions) can cause memory to leak from one user to another.
Manual testing feels impossible because:
- Repetition burns you out: Every regression means holding dozens of conversations again. No human can keep up.
- No precise assertions: Scanning a response and thinking “hmm, seems like it remembers” is way too subjective. Get one order number wrong and it explodes.
- Cross-session state is a nightmare to simulate: Cookie,
localStorage, and session ID isolation - you just can’t reliably reconstruct that by hand.
Regular unit tests can verify the memory component in isolation, but the true end-to-end memory flow - from user input to memory summarization to cross-session retrieval - only reveals itself through real browser interaction. That’s when I turned to Playwright: it can click pages, log in, operate multiple windows just like a real user, and reliably extract the LLM’s reply text from the DOM for assertions.
Architecture decisions: why not Selenium or call the API directly?
A lot of people asked me: “Why do you need a browser to verify memory? Can’t you just call the /chat endpoint?” If we were only checking model outputs, sure. But the full long-term memory chain involves frontend session management and browser-side state retention. For example:
- The session ID might be generated by the frontend and stored in
sessionStorage. The backend only recognizes that ID. - Some memory cleanup logic is tightly coupled with the creation of a new chat session, so you have to simulate a “click new session” action.
So browser testing is unavoidable.
Next came tooling:
- Selenium is too heavy, its async wait mechanism feels dated, and it forces you to write piles of explicit waits for modern SPAs - every time I used it I wanted to smash my keyboard.
- Puppeteer is great, but my team works primarily in Python. Maintaining a cross-language test suite is a net loss.
- Playwright for Python offers native async/await (with a synchronous API too), auto-waiting for elements, network interception, and the ability to open multiple browser contexts in a single script to simulate different users. Perfect.
For architecture, we used pytest to drive Playwright. Each test case simulates a full memory flow:
- Create a user (log in, initialize a session).
- Inject a memory in session A (e.g., “My name is Wang Xiaoming, employee ID 9812”).
- Open an entirely new session B (simulating a visit the next day).
- Ask a memory-related question, extract the LLM response, and assert that key information is present.
This approach keeps every test case independent and parallelizable.
Core implementation: building a reusable memory verification script step by step
1. Shared fixtures: browser instance and page
This code solves the “start/stop browser for every test” problem. We use a session-scoped fixture to launch Chromium only once.
# conftest.py
import pytest
from playwright.sync_api import sync_playwright
@pytest.fixture(scope="session")
def browser():
with sync_playwright() as p:
# headless=False 可以在调试时看到浏览器操作,CI 里设 True
browser = p.chromium.launch(headless=False, slow_mo=100)
yield browser
browser.close()
@pytest.fixture
def page(browser):
context = browser.new_context() # 隔离的浏览器上下文,相当于一个全新用户
page = context.new_page()
yield page
context.close()
2. The simplest memory test: store in one session, retrieve in another
This test validates the most basic long-term memory correctness - that something you put in can be pulled out on a different day.
# test_memory.py
import re
import pytest
def test_basic_long_term_memory(page):
page.goto("http://localhost:3000/login")
# 登录(假设一个简单的表单)
page.fill("input[name='username']", "alice")
page.fill("input[name='password']", "secret")
page.click("button:has-text('登录')")
page.wait_for_selector(".chat-container") # 等待主聊天界面出现
# --- 会话 A:注入记忆 ---
page.fill("textarea.chat-input", "我叫王小明,工号 9812,负责华东区售后。")
page.click("button:has-text('发送')")
# 等待 LLM 回复出现在最后一条消息中(必须确定有文字,不能只是 DOM 挂载)
page.wait_for_selector(".message.assistant:last-child :text-is('好的')", timeout=10000)
# 新建会话,模拟跨天
page.click("button:has-text('新会话')")
page.wait_for_selector(".chat-container")
# --- 会话 B:验证记忆 ---
page.fill("textarea.chat-input", "请问我的工号是多少?")
page.click("button:has-text('发送')")
# 关键点:不用 sleep,用文本断言等待
page.wait_for_selector(".message.assistant:last-child", timeout=10000)
Comments
No comments yet. Start the discussion.