Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Build a fast, deterministic local test loop for LangChain 1.0 / LangGraph 1.0 — FakeListChatModel fixtures, pytest config, VCR cassettes with key redaction, warning-filter policy. Use when adding tests to a new chain, fixing a flaky test, or making integration tests reproducible. Trigger with "langchain pytest", "FakeListChatModel", "VCR langchain", "langchain test fixtures", "langchain integration test".
.claude/skills/jeremylongshore-langchain-local-dev-loop/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 44% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 24% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 171% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 158% | 0% |
| case-19 | ✗→✓ | ▲ Improved | 312% | 0% |
An engineer writes the most natural assertion possible:
pythondef test_summarize(): out = chain.invoke({"text": "..."}) assert out.content == "expected summary"
It passes locally against Claude at temperature=0. It fails in CI on the third run with a one-token delta in the output. That is P05: Anthropic's temperature=0 is not greedy — it still samples. Tests against live Claude are not deterministic, period.
So the engineer swaps in FakeListChatModel(responses=["expected summary"]) and the assertion passes. Then the downstream callback that logs cost blows up in CI with KeyError: 'token_usage' — because FakeListChatModel does not emit response_metadata["token_usage"] (P43). Production code reads that key, so either the fake has to synthesize it or the test has to skip the callback.
Meanwhile, the first integration test under VCR records a cassette that ships Authorization: Bearer sk-ant-api03-... in the repo (P44). PR review catches it; the reviewer revokes the key; the dev loop is hosed for an afternoon.
And none of this matters if pytest cannot even collect the suite because import langchain_community emits a DeprecationWarning that -W error promotes to failure (P45).
This skill installs the four layers that make the whole loop fast and safe: FakeListChatModel / FakeListLLM with a metadata-emitting subclass (fixes P43); VCR with filter_headers plus a pre-commit hook (fixes P44); pytest filterwarnings policy in pyproject.toml (fixes P45); and an env-var-gated integration marker so the default pytest run never touches live APIs.
Speed targets: unit tests with FakeListChatModel run in < 100ms per test; VCR-replayed integration tests run in 500ms – 2s per test; live integration tests (the RUN_INTEGRATION=1 gate) run only in nightly or manual workflows.
Pin: langchain-core 1.0.x, langgraph 1.0.x, pytest current, vcrpy current. Pain-catalog anchors: P05, P43, P44, P45.
pip install langchain-core>=1.0,<2.0 langgraph>=1.0,<2.0 pytest vcrpy pytest-recordingANTHROPIC_API_KEY, etc.)pyproject.toml (PEP 621) for pytest configFakeListChatModelUse FakeListChatModel from langchain_core.language_models.fake for chat chains and FakeListLLM for legacy completion LLMs. Responses cycle through the list.
pythonfrom langchain_core.language_models.fake import FakeListChatModel from langchain_core.prompts import ChatPromptTemplate def test_classifier_picks_positive(): fake = FakeListChatModel(responses=["positive"]) prompt = ChatPromptTemplate.from_messages([("user", "Classify: {text}")]) chain = prompt | fake out = chain.invoke({"text": "I love it"}) assert out.content == "positive"
This is deterministic, runs in single-digit milliseconds, and has zero provider dependency. Use it for every chain assertion that does not specifically require real model behavior.
FakeListChatModel to emit response_metadata (P43 fix)The stock fake emits no response_metadata["token_usage"]. If your chain has a callback that records cost, the callback crashes under the fake. Subclass and synthesize the metadata instead of mocking around the callback:
pythonfrom langchain_core.language_models.fake import FakeListChatModel from langchain_core.outputs import ChatGeneration, ChatResult from langchain_core.messages import AIMessage class FakeChatWithUsage(FakeListChatModel): """FakeListChatModel that emits response_metadata['token_usage'] so downstream callbacks reading token usage do not crash under test.""" def _generate(self, messages, stop=None, run_manager=None, **kwargs): response = self.responses[self.i % len(self.responses)] self.i += 1 message = AIMessage( content=response, response_metadata={ "token_usage": { "input_tokens": 10, "output_tokens": len(response.split()), "total_tokens": 10 + len(response.split()), }, "model_name": "fake-chat", }, usage_metadata={ "input_tokens": 10, "output_tokens": len(response.split()), "total_tokens": 10 + len(response.split()), }, ) return ChatResult(generations=[ChatGeneration(message=message)])
Use FakeChatWithUsage whenever a chain's observability / cost path is in the assertion surface. See Fake Model Fixtures for agent, retriever, and embedder fakes.
Put fixtures in tests/conftest.py so they are shared across the suite:
python# tests/conftest.py import pytest from langchain_core.prompts import ChatPromptTemplate from tests.fakes import FakeChatWithUsage @pytest.fixture def fake_chat(): """Reusable fake chat model. Override responses per-test via monkeypatch.setattr(fake_chat, 'responses', [...]).""" return FakeChatWithUsage(responses=["ok"]) @pytest.fixture def summarize_chain(fake_chat): prompt = ChatPromptTemplate.from_messages([ ("system", "Summarize the user's text in one line."), ("user", "{text}"), ]) return prompt | fake_chat
Per-test response override:
pythondef test_summary_shape(summarize_chain, fake_chat): fake_chat.responses = ["short summary"] out = summarize_chain.invoke({"text": "long input"}) assert out.content == "short summary"
Unit tests should never touch the network. Integration tests do, exactly once — to record a cassette — and every subsequent run replays from the cassette file. vcrpy records headers by default, which means Authorization: Bearer sk-... lands in the fixture unless you filter it.
Configure VCR in tests/conftest.py:
python# tests/conftest.py (continued) import pytest @pytest.fixture(scope="module") def vcr_config(): return { "filter_headers": [ "authorization", "x-api-key", "anthropic-version", "openai-organization", "cookie", ], "filter_query_parameters": ["api_key"], # Block accidental re-recording in CI: "record_mode": "none", }
Use pytest-recording:
pythonimport pytest @pytest.mark.vcr # cassette at tests/cassettes/<test_name>.yaml @pytest.mark.integration def test_live_claude_short_answer(): from langchain_anthropic import ChatAnthropic chat = ChatAnthropic(model="claude-sonnet-4-6", temperature=0, timeout=30) out = chat.invoke("Say 'ok' and nothing else.") assert "ok" in out.content.lower()
To record (once, locally, with a real key): pytest --record-mode=once tests/. Every other run replays — cassettes are committed, real API is never hit again.
Pre-commit hook to block key leaks:
bash# .git/hooks/pre-commit or .pre-commit-config.yaml entry #!/usr/bin/env bash set -e if git diff --cached --name-only | grep -q '^tests/cassettes/'; then if git diff --cached -U0 -- 'tests/cassettes/' | \ grep -E '(sk-ant-[a-zA-Z0-9_-]+|sk-[a-zA-Z0-9]{20,}|Bearer\s+[a-zA-Z0-9_-]{20,})'; then echo "ERROR: API key pattern found in staged cassette." >&2 exit 1 fi fi
See VCR Cassette Hygiene for the full pre-commit config, record-new-episodes flow, shared-cassette patterns, and the PR review checklist.
pyproject.toml (P45 fix)langchain_community and some provider SDKs emit DeprecationWarning at import time. If the suite runs -W error, collection fails before any test does. Set the policy once in pyproject.toml:
toml[tool.pytest.ini_options] minversion = "8.0" testpaths = ["tests"] addopts = [ "-ra", "--strict-markers", "--strict-config", "-W", "error", ] markers = [ "integration: hits real APIs or replays VCR cassettes (set RUN_INTEGRATION=1)", "slow: takes > 1s per test", "smoke: minimal healthcheck run in CI", ] filterwarnings = [ "error", "ignore::DeprecationWarning:langchain_community.*", "ignore::DeprecationWarning:pydantic.*", "ignore::PendingDeprecationWarning:langchain_core.*", ]
See Pytest Config for the full skeleton including coverage config and parallel execution notes.
Default pytest must never hit real APIs. Gate on RUN_INTEGRATION=1:
python# tests/conftest.py (continued) import os import pytest def pytest_collection_modifyitems(config, items): if os.getenv("RUN_INTEGRATION") == "1": return skip_integration = pytest.mark.skip(reason="set RUN_INTEGRATION=1 to run") for item in items: if "integration" in item.keywords: item.add_marker(skip_integration)
CI default: pytest (unit only). Nightly / manual: RUN_INTEGRATION=1 pytest -m integration.
thread_id + state assertionsLangGraph state is scoped to a thread_id. Tests that share a thread_id leak state between each other. Give every test a fresh thread_id and a fresh MemorySaver:
pythonfrom langgraph.checkpoint.memory import MemorySaver import uuid, pytest @pytest.fixture def graph_config(): return {"configurable": {"thread_id": str(uuid.uuid4())}} @pytest.fixture def checkpointed_graph(fake_chat): from my_app.graphs import build_graph return build_graph(fake_chat).compile(checkpointer=MemorySaver()) def test_node_emits_plan(checkpointed_graph, graph_config, fake_chat): fake_chat.responses = ["step 1\nstep 2\nstep 3"] result = checkpointed_graph.invoke({"goal": "deploy"}, graph_config) # Assert state shape per node, not just the final output: assert result["plan"] == ["step 1", "step 2", "step 3"] # Time-travel: inspect every checkpoint for debugging history = list(checkpointed_graph.get_state_history(graph_config)) assert history[-1].values == {"goal": "deploy"} # initial state
Subgraph isolation testing cross-references langchain-langgraph-subgraphs (pain P21 — parent cannot read child state unless the key is in the parent schema). See LangGraph Test Patterns for the subgraph-shared-state test recipe.
tests/fakes.py with FakeChatWithUsage subclass that emits response_metadatatests/conftest.py with fake-model fixtures, VCR config, and RUN_INTEGRATION gatepyproject.toml [tool.pytest.ini_options] block with markers and filterwarningstests/cassettes/ committed with filtered headers (no Authorization / x-api-key)sk- / sk-ant- / Bearer patternsthread_id and MemorySaver — no cross-test leakage| Type | Model | Network | Target speed | Determinism | Use case | |------|-------|---------|--------------|-------------|----------| | Unit | FakeListChatModel / FakeChatWithUsage | none | < 100ms | total | Chain shape, parser, routing logic | | Integration (VCR) | real model, replayed cassette | replay only | 500ms – 2s | total (once recorded) | End-to-end chain behavior, provider-specific edge cases | | Integration (live) | real model | live API | 2s – 30s | probabilistic (P05) | Nightly smoke, recording new cassettes, provider regression | | Smoke | real model, minimal prompt | live API | < 5s | probabilistic | CI healthcheck — 1 test per provider, gated on RUN_INTEGRATION=1 | | Load | real model | live API | minutes | probabilistic | Throughput / retry-storm reproduction, never in PR CI |
| Error | Cause | Fix | |-------|-------|-----| | AssertionError on content despite temperature=0 | Anthropic temperature=0 still samples (P05) | Switch to FakeListChatModel or VCR replay | | KeyError: 'token_usage' under fake model | FakeListChatModel emits no response_metadata (P43) | Use FakeChatWithUsage subclass from Step 2 | | PR review flags Authorization: Bearer sk-... in cassette | VCR recorded headers by default (P44) | Set filter_headers before recording; re-record; add pre-commit grep hook | | pytest fails at collection with DeprecationWarning | -W error + SDK import warnings (P45) | Add filterwarnings = ["ignore::DeprecationWarning:langchain_community.*"] | | vcr.errors.CannotOverwriteExistingCassetteException | Test changed request shape but cassette is stale | pytest --record-mode=new_episodes locally, inspect diff, commit | | LangGraph test pollutes next test's state | Shared thread_id + shared MemorySaver | Per-test thread_id=uuid.uuid4(), per-test MemorySaver() |
ChatAnthropic, passes locally, fails1-in-5 in CI at temperature=0 (P05).
FakeListChatModel, passesdeterministically, but the cost-logging callback crashes (P43).
FakeChatWithUsage, the callbackreads response_metadata["token_usage"] cleanly, the test is green and runs in 40ms.
See Fake Model Fixtures for the full worked example including agent and retriever fakes.
bash# 1. Ensure conftest.py has filter_headers configured FIRST # 2. Record with real key present in the environment ANTHROPIC_API_KEY=sk-ant-... pytest --record-mode=once tests/integration/test_summarize.py # 3. Verify no leak grep -E 'sk-|Bearer' tests/cassettes/*.yaml && echo "LEAK" || echo "clean" # 4. Commit cassettes/ — pre-commit hook runs the same grep as a hard gate git add tests/cassettes/ && git commit -m "test: record summarize cassette"
See VCR Cassette Hygiene for record-new-episodes mode, rerecord-on-mismatch, and the PR review checklist.
When a graph test fails mid-graph, get_state_history(config) returns every checkpoint — you can replay from any point by passing its config.checkpoint_id back into graph.invoke. See LangGraph Test Patterns for the full time-travel debugging recipe and the subgraph-shared-state test pattern (cross-ref langchain-langgraph-subgraphs / pain L30).
FakeListChatModel APIvcrpy documentationpytest-recordingMemorySaver + get_state_historyfilterwarningsdocs/pain-catalog.md (entries P05, P43, P44, P45)| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 28,863 | 19,801 | -31% | 1 | 1 | 0% | 5,227 | 7,536 | +44% | 0 | 0 | — |
case-02 | fail→pass | 42,249 | 26,764 | -37% | 1 | 1 | 0% | 7,382 | 9,134 | +24% | 0 | 0 | — |
case-03 | pass→pass | 16,980 | 15,614 | -8% | 1 | 1 | 0% | 2,439 | 6,252 | +156% | 0 | 0 | — |
case-04 | pass→pass | 23,313 | 11,767 | -50% | 1 | 1 | 0% | 2,718 | 6,516 | +140% | 0 | 0 | — |
case-05 | fail→pass | 13,309 | 17,211 | +29% | 1 | 1 | 0% | 2,249 | 6,094 | +171% | 0 | 0 | — |
case-06 | pass→pass | 16,690 | 17,791 | +7% | 1 | 1 | 0% | 2,086 | 6,359 | +205% | 0 | 0 | — |
case-07 | pass→pass | 17,859 | 30,473 | +71% | 1 | 1 | 0% | 3,856 | 7,505 | +95% | 0 | 0 | — |
case-08 | pass→pass | 16,092 | 9,242 | -43% | 1 | 1 | 0% | 2,055 | 6,108 | +197% | 0 | 0 | — |
case-09 | pass→pass | 17,961 | 10,161 | -43% | 1 | 1 | 0% | 2,321 | 5,880 | +153% | 0 | 0 | — |
case-10 | pass→pass | 8,665 | 10,466 | +21% | 1 | 1 | 0% | 1,246 | 5,412 | +334% | 0 | 0 | — |
case-11 | pass→pass | 15,376 | 11,106 | -28% | 1 | 1 | 0% | 1,544 | 5,533 | +258% | 0 | 0 | — |
case-12 | pass→pass | 18,392 | 15,304 | -17% | 1 | 1 | 0% | 2,799 | 6,342 | +127% | 0 | 0 | — |
case-13 | pass→pass | 14,651 | 5,322 | -64% | 1 | 1 | 0% | 1,915 | 5,404 | +182% | 0 | 0 | — |
case-14 | pass→pass | 17,146 | 7,508 | -56% | 1 | 1 | 0% | 1,937 | 4,646 | +140% | 0 | 0 | — |
case-15 | fail→pass | 12,161 | 7,239 | -40% | 1 | 1 | 0% | 1,849 | 4,775 | +158% | 0 | 0 | — |
case-16 | pass→pass | 9,225 | 5,502 | -40% | 1 | 1 | 0% | 1,765 | 5,397 | +206% | 0 | 0 | — |
case-17 | pass→pass | 3,586 | 9,364 | +161% | 1 | 1 | 0% | 646 | 5,110 | +691% | 0 | 0 | — |
case-22 | pass→pass | 17,023 | 13,254 | -22% | 1 | 1 | 0% | 2,520 | 7,035 | +179% | 0 | 0 | — |
case-18 | pass→pass | 10,761 | 14,839 | +38% | 1 | 1 | 0% | 1,816 | 6,040 | +233% | 0 | 0 | — |
case-19 | fail→pass | 13,845 | 5,077 | -63% | 1 | 1 | 0% | 1,282 | 5,277 | +312% | 0 | 0 | — |
case-20 | pass→pass | 8,192 | 10,410 | +27% | 1 | 1 | 0% | 1,584 | 5,236 | +231% | 0 | 0 | — |
case-21 | pass→pass | 13,774 | 13,522 | -2% | 1 | 1 | 0% | 1,800 | 6,089 | +238% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +23 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.