A LangChain chain runs once, top to bottom. LangGraph adds state and cycles, so an agent can loop: generate a test, run it, and if it fails, heal the locator and try again. This guide builds that graph step by step, with a playable diagram and a six-stage roadmap.
Pick a flow and press Play. Each step lights up one node or edge, so you can see how a shared state moves through the graph and how a conditional edge turns a failure into a heal-and-retry loop.
A LangGraph is nodes that update one shared state, wired by edges. Conditional edges route on a check, and a cycle lets the graph retry, which is exactly what self-healing and retry-until-green need.
LangGraph rewards small steps. Build a linear graph first, then add shared state, routing, a cycle, more agents, and finally persistence with a human in the loop.
If you have worked through the sibling guides in this series, you already picture a LangChain chain as a pipe: a prompt flows into a model, the model's output flows into a parser, and the parser hands off to the next step. It runs start to finish and stops. That shape is perfect for a one-shot task like "summarize this bug report," but it is the wrong shape for most real test automation, where the interesting work is a loop: run, observe, decide, adjust, run again. LangGraph is the tool that lets you build that loop as a first-class object instead of faking it with a while loop wrapped around a chain.
LangGraph is a library from the team behind LangChain for building stateful, multi-step agent workflows as a directed graph. Instead of a straight pipe you declare nodes (units of work), edges (what runs next), and a single shared state object that every node reads from and writes to. The headline class is StateGraph. You define the state as a Python TypedDict, register functions with add_node, wire them with add_edge and add_conditional_edges, mark the start with set_entry_point, mark the finish with the END sentinel, and call compile() to get a runnable you drive with invoke or stream. The one difference from a chain that matters most: edges are allowed to point backward. A graph can re-enter a node it already visited, which means it can loop.
A plain LangChain Expression Language chain is a directed acyclic graph. Acyclic means no cycles: data moves in one direction and never returns to a node it has already run. That constraint is exactly what makes a chain easy to reason about, and exactly what makes it unable to express "try that again." LangGraph drops the acyclic rule. When you add an edge from a healing node back to the node that ran the test, you have created a cycle, and the compiled graph will walk that cycle repeatedly until a routing function decides to stop. For QA, that single capability changes what you can automate, because almost every durable QA workflow is a loop with a stopping condition rather than a one-way pipe.
Think about the patterns you already build by hand in your framework. Each one is a cycle, and each maps cleanly onto a LangGraph graph:
Retry-until-green. A step runs, an oracle checks the result, and if the check fails the graph loops back to retry (optionally after mutating some input) until it passes or hits an attempt cap. This is the anti-flake pattern, expressed as a graph edge instead of a nested try/except. Self-healing. A test fails because a locator drifted; a node asks a model for a better selector, writes it into state, and routes back to re-run the same step. The loop continues until the element is found or the budget runs out. Multi-step triage with branching. A failure arrives, one node reads the stack trace, a conditional edge routes assertion failures to one handler, timeouts to another, and infrastructure errors to a third, with each branch able to loop back for more evidence before it files a ticket. That is the pitch for LangGraph for QA: the retry, the heal, and the triage tree stop being framework glue and become an inspectable, testable graph.
from typing import TypedDict from langgraph.graph import StateGraph, END class TestState(TypedDict): locator: str attempts: int passed: bool def run_test(state: TestState) -> dict: result = execute(state["locator"]) # hit the real runner return {"passed": result.ok, "attempts": state["attempts"] + 1} def heal_locator(state: TestState) -> dict: return {"locator": suggest_locator(state["locator"])} def route(state: TestState) -> str: if state["passed"]: return "done" if state["attempts"] >= 3: return "done" # stop after 3 tries, no infinite loop return "retry" graph = StateGraph(TestState) graph.add_node("run_test", run_test) graph.add_node("heal_locator", heal_locator) graph.set_entry_point("run_test") graph.add_conditional_edges("run_test", route, {"retry": "heal_locator", "done": END}) graph.add_edge("heal_locator", "run_test") # the cycle: heal, then run again app = graph.compile() app.invoke({"locator": "#submit", "attempts": 0, "passed": False})
Read that graph as a triage flow you can draw on a whiteboard. The conditional edge is the oracle: route inspects state and returns a key, and the third argument to add_conditional_edges maps that key to the next node or to END. The backward edge from heal_locator to run_test is the cycle. The attempt cap inside route is your loop guard, the same discipline you already apply to any retry so a stuck element cannot spin forever and drain your CI minutes.
The TypedDict is not decoration, it is the contract for what flows between nodes. Every node receives the current state and returns a partial dict of the keys it wants to change; LangGraph merges that back in. By default a returned key overwrites the old value, which is what you want for locator or passed. When you want a field to accumulate instead, such as a running list of every error message the triage collected, you annotate that field with a reducer (LangGraph ships add_messages for conversation history, and operator.add works for plain lists) so writes append rather than clobber. For QA this state object becomes the case file: the locator under test, the attempt count, the captured logs, the model's proposed fix, and the final verdict, all in one typed structure you can assert against in a unit test.
The real value of LangGraph for QA shows up when a run needs to pause for a human. Pass a checkpointer at compile time and the graph snapshots its state after every node, keyed by a thread id. The in-memory option, MemorySaver, is enough for local runs and demos. Combine it with interrupt_before and the graph will stop before a node you flag, hand control back to you, and resume from the exact same state when you call invoke again. That is a human-in-the-loop gate built into the workflow: the agent proposes a healed locator, a person approves or edits it, and only then does the run continue.
from langgraph.checkpoint.memory import MemorySaver memory = MemorySaver() app = graph.compile(checkpointer=memory, interrupt_before=["heal_locator"]) config = {"configurable": {"thread_id": "flaky-login-42"}} app.invoke({"locator": "#login", "attempts": 0, "passed": False}, config) # graph pauses before healing; a human inspects state, then resumes app.invoke(None, config)
The same checkpointer gives you durability and time travel: because the thread id addresses a saved history, a crashed or timed-out run resumes where it left off instead of starting over, and you can replay a past state to reproduce a failure. That is persistence you would otherwise hand-roll around a chain.
None of this means you should reach for a graph every time. A chain is simpler to write, cheaper to trace in LangSmith, and easier to hand to a teammate. The honest test is whether your task has a decision or a loop in it. If the answer is no, stay with a chain and the LangChain for QA patterns from the earlier guide.
| Your task | Reach for | Why |
|---|---|---|
| Summarize a failure, draft a bug title, classify one log | LangChain chain | One pass, no branching, no retry |
| Retry a step until an oracle passes or a cap is hit | LangGraph cycle | Needs an edge that loops back |
| Self-heal a drifted locator across attempts | LangGraph cycle | State carries the evolving locator |
| Route a failure through assertion / timeout / infra branches | LangGraph conditional edges | Branching plus optional loops for evidence |
| Pause for human approval mid-run | LangGraph + checkpointer | Interrupt and resume from saved state |
When the task is genuinely agentic, letting a model choose tools in a loop, you often do not need to hand-draw the graph at all. LangGraph ships a prebuilt in langgraph.prebuilt that assembles the standard reason-act cycle for you, so a triage agent that calls your runner, reads logs, and opens a ticket is a few lines.
from langgraph.prebuilt import create_react_agent agent = create_react_agent(model, tools=[run_suite, read_logs, open_ticket]) agent.invoke({"messages": [("user", "Triage the failing checkout test")]})
Every LangChain chain you have built so far runs in a straight line: an input arrives, a prompt renders, the model answers, an output parser hands you a result. That linearity is exactly what falls apart when you use langgraph for qa work, because real testing is not a straight line. A locator misses, so you retry. An assertion fails, so you triage. A flaky step needs three attempts before the DOM settles. LangGraph models this the way you would sketch it on a whiteboard: as a graph of states and transitions. The central object is the StateGraph, and once you understand its four moving parts (a typed state, nodes, edges, and a compile step) the rest of this guide is just variations on the same shape.
Everything in a LangGraph program flows through one shared state object. You declare its shape with a TypedDict, and every node reads from it and writes back into it. Think of it as the blackboard your whole test harness scribbles on: the target under test, the current locator, how many attempts you have burned, the running log, and the final verdict. For a QA engineer that state is not incidental, it is the evidence trail you will read during triage.
from typing import TypedDict, Annotated from operator import add from langgraph.graph import StateGraph, START, END class TestState(TypedDict): target: str # the page or endpoint under test locator: str attempts: int logs: Annotated[list, add] # reducer: appends instead of overwriting verdict: str
That Annotated[list, add] is worth pausing on. By default, when a node returns a key, the new value replaces the old one. Wrap a key with a reducer function (here operator.add for lists, or add_messages from langgraph.graph.message for chat histories) and LangGraph combines the writes instead. So logs accumulates across every node and every retry, giving you a full, replayable record of what the agent tried. That is the difference between a green checkmark and an artifact you can actually debug.
A node is nothing exotic. It is a function that takes the current state and returns a dictionary of the keys it wants to change. It can call an LLM, drive Playwright, hit an API, or run pure Python. Crucially, a node returns only the delta, not the whole state, and LangGraph merges that delta for you.
def generate_locator(state: TestState) -> dict: # ask an LLM for a selector; return ONLY the keys you changed return {"locator": "role=button[name=Sign in]", "logs": ["proposed a locator"]} def run_step(state: TestState) -> dict: ok = try_click(state["locator"]) return {"verdict": "pass" if ok else "fail", "attempts": state["attempts"] + 1}
Because a node is an ordinary function with a typed input and a typed output, it is a first-class unit test target. You can call run_step({"locator": "...", "attempts": 0}) in pytest and assert on the returned dict, no graph required. That testability is a quiet superpower: your automation logic stays inspectable even as the orchestration around it grows into a full agent.
Edges decide what runs next. There are two kinds, and the split is the heart of the whole model. A plain add_edge(a, b) is an unconditional hop: after node a, always go to b. An add_conditional_edges(source, router, path_map) is dynamic: after the source node, LangGraph calls your router function, which reads the state and returns a key, and the path_map looks that key up to find the destination. The router is where you encode your test oracle.
| Construct | What it does | QA use |
|---|---|---|
add_edge("a", "b") | Always go a -> b | setup -> action, fixed steps |
add_conditional_edges(src, fn, map) | Router returns a key -> next node | pass vs fail vs retry branching |
add_edge(START, "n") / set_entry_point("n") | Where execution begins | first node of the run |
route to END | Terminates that path | test done, emit verdict |
START and END are sentinels imported straight from langgraph.graph. START marks the entry (you can also call set_entry_point), and routing a path to END stops it and returns the final state. A router that returns END is how a passing test cleanly exits the loop.
Once the nodes and edges are wired, you call compile(). This validates the graph (no dangling edges, a reachable entry, at least one path to END) and returns a runnable object with the same invoke and stream interface you already know from LangChain.
graph = StateGraph(TestState) graph.add_node("generate", generate_locator) graph.add_node("run", run_step) graph.add_edge(START, "generate") graph.add_edge("generate", "run") def route(state: TestState) -> str: if state["verdict"] == "pass": return "done" if state["attempts"] >= 3: return "done" return "heal" graph.add_conditional_edges("run", route, {"heal": "generate", "done": END}) app = graph.compile() result = app.invoke({"target": "/login", "attempts": 0})
Use invoke when you want the final state and nothing else. Use stream when you want to watch it think: app.stream(inputs) yields the state update after each node fires, which is your live trace of every locator proposal, every click, every verdict. In CI that stream is gold for diagnosing why a run went sideways without re-running the whole suite.
Notice the edge "heal" -> "generate" in the map above. It points backward. That single line is what a linear chain can never express: a cycle. The router sends a failed step back to the locator generator, which proposes a fresh selector, which runs again, which routes again. That is the reason-act-observe loop drawn as a graph. The agent acts (runs the step), observes (reads the verdict off state), reasons (the router), and either retries or exits. The reason a langgraph for qa harness can self-heal a broken locator or ride out a flaky element is that the retry is a first-class edge, not a hidden while loop buried in your code.
attempts >= 3 check above), and lean on LangGraph's built-in recursion_limit (default 25). Pass app.invoke(inputs, {"recursion_limit": 50}) to raise or lower the hard ceiling. A heal loop with no cap is just a flaky test that also burns tokens.If you do not want to hand-wire the loop, the prebuilt create_react_agent assembles a canonical reason-act-observe graph for you. You hand it a model and a list of tools, and it returns an already-compiled graph with the agent node, tool node, and conditional edge wired up.
from langgraph.prebuilt import create_react_agent agent = create_react_agent(model, tools=[click, read_dom, assert_text]) agent.invoke({"messages": [("user", "log in and verify the dashboard title")]})
Start with the prebuilt to prototype fast, then graduate to a hand-built StateGraph the moment you need custom oracles, coverage tracking, or a heal loop shaped like your app. This is the whole architecture behind langgraph for qa: a typed state that carries your evidence, nodes you can unit test, edges that encode your pass/fail logic, and cycles that make retries explicit.
app.compile(checkpointer=MemorySaver()) from langgraph.checkpoint.memory, and the graph persists state between invocations. That unlocks pausing a run for a human to approve a risky action and resuming later, which the next section builds on. The sibling LangChain and LangSmith guides in this series cover the model and tracing layers this graph sits on top of.The fastest way to fail with langgraph for qa is to open the docs, see a diagram of six agents talking to each other, and try to build that on day one. You end up with a graph nobody can trace and a test suite that is now less reliable than the plain Playwright script it replaced. The healthier path is a staircase. Each stage adds exactly one LangGraph primitive, ships something a QA team can actually use in CI, and leaves you with a graph you still understand at 2am when a run goes red. The visual roadmap above walks these same six steps, so treat this section as the annotated version of that picture.
Every stage reuses one running example: an agent that opens a page, runs a batch of checks, triages what it finds, and files a bug. We grow that single graph from a straight line into a self-healing, human-supervised system without ever rewriting it from scratch. That is the whole point of the framework: the graph is data, so you extend it by adding nodes and edges, not by refactoring tangled control flow. Every rung is a real deliverable, not a stepping stone you throw away.
| Stage | LangGraph primitive | QA payoff |
|---|---|---|
| 1. Linear graph | add_node, add_edge, END | A readable pipeline you can trace step by step |
| 2. Shared typed state | TypedDict + reducers | One audited object every node reads and writes |
| 3. Conditional routing | add_conditional_edges | The agent branches on what it actually found |
| 4. Cycle (retry loop) | a back-edge to an earlier node | Self-healing locators and bounded retries |
| 5. Multi-agent | create_react_agent as nodes | A planner fans work out to specialist workers |
| 6. Persistence + HITL | MemorySaver, interrupt_before | Pause for human approval, resume where you stopped |
Start with a straight line. A StateGraph with three nodes wired in sequence is barely more than a function chain, and that is exactly why it is the right first rung. You absorb the whole mental model (build a graph, compile it, invoke it) without any branching to reason about. If your team has never shipped an agent, this stage alone replaces a pile of imperative glue code with something you can point at and explain to a reviewer in one breath.
from langgraph.graph import StateGraph, END builder = StateGraph(QAState) builder.add_node("load", load_page) builder.add_node("check", run_checks) builder.set_entry_point("load") builder.add_edge("load", "check") builder.add_edge("check", END) app = builder.compile() app.invoke({"url": "https://app.thetestingacademy.com"})
Read it top to bottom: set_entry_point marks where execution starts, the edges wire the flow, and the END sentinel closes the run. compile() turns the builder into a runnable and invoke() executes it with an initial state dictionary. If you would rather watch each node land in real time, swap invoke() for stream() and you get a generator that yields after every step, which is perfect for surfacing live progress in CI logs. There is no magic here, and that is the feature.
The moment two nodes need to share data, you need a state schema. LangGraph state is a plain TypedDict, and every node returns a partial dictionary that gets merged into it. By default a returned key overwrites the old value, which is fine for a url but wrong for a growing list of findings, since each node would erase the last one's work. That is what reducers fix: annotate a field with a merge function and returns get combined instead of clobbered.
from typing import TypedDict, Annotated import operator class QAState(TypedDict): url: str findings: Annotated[list, operator.add] # reducer: append, do not overwrite attempts: int
Now run_checks can return a single-item findings list and LangGraph concatenates it onto what is already there, because operator.add on two lists joins them. Every node reads the same audited object, so state becomes your test oracle: at the end of a run you inspect one dictionary to see the url, the accumulated findings, and how many attempts it took. For langgraph for qa work this typed state is the single most valuable habit, because a flaky agent is almost always a state-tracking bug, and a schema makes those bugs visible instead of silent.
A real check run does not always end the same way. If the page had zero findings you want to jump straight to a report; if it found something you want to triage first. add_conditional_edges attaches a routing function to a node. That function reads the current state and returns a key, and the path map turns the key into the name of the next node to run.
def route(state) -> str: if state["findings"]: return "triage" return "report" builder.add_conditional_edges("check", route, {"triage": "triage", "report": "report"})
This is where the agent stops being a fancy script and starts making decisions on the evidence in front of it. The router is an ordinary Python function, so it is trivially unit testable: hand it a state with findings, assert it returns "triage"; hand it an empty one, assert "report". That testability matters, because routing logic is exactly the kind of code that silently rots as a graph grows. Keep the router pure and side-effect free, and your branch coverage stays honest.
Straight lines and branches are still a directed acyclic graph. The primitive that makes LangGraph genuinely worth adopting for test automation is the cycle, an edge that points back to an earlier node. This is how you build a self-healing loop. If a check missed because a locator went stale, do not fail the whole run; route back, let the agent try a different selector, and only give up after a bounded number of attempts.
def should_retry(state) -> str: if state["findings"] and state["attempts"] < 3: return "heal" # loop back and re-run with a healed locator return END builder.add_conditional_edges("triage", should_retry, {"heal": "load", END: END})
The attempts counter in state is the guardrail, and the "heal" key maps straight back to the load node to close the loop. Without that counter a cycle is an infinite loop that burns tokens and CI minutes, which is the number one way agent runs quietly get expensive. Here should_retry runs after triage, confirms there is still something worth re-running and that we are under the cap, then either loops back or exits to END. That bounded retry is the line between a demo and something you trust in a pipeline.
Only now, with typed state, routing, and cycles solid, does multi-agent pay off. The pattern is a supervisor: a planner node decides what kind of testing a task needs and dispatches it to specialist workers. Each worker can be its own prebuilt agent. create_react_agent gives you a ready-made reason-and-act loop bound to a set of tools, and because a compiled graph is itself just a runnable, you drop each worker straight in as a node with add_node, no special wiring required.
from langgraph.prebuilt import create_react_agent ui_worker = create_react_agent(model, tools=[run_playwright, grab_dom]) api_worker = create_react_agent(model, tools=[call_endpoint, assert_schema]) builder.add_node("plan", planner) builder.add_node("ui", ui_worker) builder.add_node("api", api_worker) builder.add_conditional_edges("plan", dispatch)
The planner routes with a conditional edge whose dispatch function returns the target node name directly, sending UI work to the Playwright-tool worker and contract checks to the API worker. The visual roadmap draws this as a hub with spokes, and that is the honest shape: a coordinator with independent specialists underneath it. Resist the urge to start here. A planner over two workers that each do the wrong thing is far harder to debug than one linear graph, and most langgraph for qa use cases are well served by stages one through four alone.
The final rung makes the graph durable and supervised. Pass a checkpointer to compile() and LangGraph saves state after every step, keyed by a thread id you choose. That one change unlocks two things QA teams care about deeply: a run that crashes mid-flight can resume from its last good checkpoint instead of starting over, and you can deliberately pause the graph to put a human in the loop before anything irreversible happens.
from langgraph.checkpoint.memory import MemorySaver memory = MemorySaver() app = builder.compile(checkpointer=memory, interrupt_before=["file_bug"]) config = {"configurable": {"thread_id": "run-42"}} app.invoke({"url": "..."}, config) # stops before file_bug app.invoke(None, config) # human approved -> resume from checkpoint
interrupt_before tells compile() to halt the graph just before a named node, here the one that actually files a bug in your tracker. The first invoke runs up to that point and returns the pending state. A human reviews the proposed ticket, and resuming is simply a second invoke with None as the input and the same thread id, which picks up from the saved checkpoint. That is real human-in-the-loop: the agent does the tedious detection and triage, a person signs off on the one irreversible action, and nothing gets filed without approval.
Everything up to now has been vocabulary: a StateGraph, a handful of nodes, a couple of edges. In this section we spend that vocabulary on a real deliverable that any QA engineer can defend in code review. We build a graph that takes one acceptance criterion and returns a runnable Playwright spec. This is where LangGraph for QA stops being an architecture diagram and starts producing artifacts you can drop into a repo and run in CI.
The shape is deliberately small. Two nodes, a straight line, no branching yet. A plan node reads the requirement and drafts numbered UI steps. A generate node turns those steps into a Playwright test. We wire entry -> plan -> generate -> END, compile once, and invoke with a plain dictionary. Keeping the first graph linear means the only moving parts you have to trust are the state schema and the two node functions, nothing else. Cycles, retries, and checkpointers arrive in later sections; here we earn the baseline that all of them build on.
LangGraph state is a typed dictionary. Every top level key is a channel, and each node reads the whole state and returns a partial dict of just the channels it wants to write. Think of it as the shared fixture that every step in your pipeline can see, the same way a Playwright test threads page through its steps. For test generation we need three channels: the incoming requirement, the intermediate plan, and the final spec.
from typing import TypedDict from langgraph.graph import StateGraph, END class TestGenState(TypedDict): requirement: str # the raw acceptance criterion plan: str # numbered steps the generator will follow spec: str # final Playwright test code
By default each channel uses last-write-wins semantics: whatever a node returns for plan replaces the previous value. That is exactly what we want in a linear pipeline, because no two nodes contend for the same key. Later, when you accumulate a message history or collect a growing list of failing selectors across a retry loop, you attach a reducer with Annotated so writes append instead of overwrite. LangGraph also rejects writes to channels you never declared, which is a quiet blessing in QA work: a typo like returning {"spce": code} fails loudly at the graph boundary instead of silently dropping your generated test on the floor.
| Channel | Type | Written by | Read by |
|---|---|---|---|
| requirement | str | invoke() input | plan node |
| plan | str | plan node | generate node |
| spec | str | generate node | your CI writer |
Reading the table top to bottom tells you the whole data flow before you write a single edge. The requirement enters from the outside, the plan is produced once and consumed once, and the spec is the only thing a caller actually cares about. When a graph misbehaves, this channel map is the first oracle you check: which node was supposed to fill the key that came back empty.
A node is just a function. It takes the current state and returns a dict. No base class, no decorator, no registration ritual. That plainness is why LangGraph for QA is easy to unit test: each node is a pure-ish function you can call with a hand-built state and assert on the output, no graph required. Our plan node asks the model to convert one requirement into numbered, locator-friendly steps.
from langchain_openai import ChatOpenAI llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) def plan_node(state: TestGenState) -> dict: prompt = ( "Turn this requirement into numbered UI test steps.\n" f"Requirement: {state['requirement']}" ) steps = llm.invoke(prompt).content return {"plan": steps}
Notice the node returns only {"plan": steps}, not the whole state. LangGraph merges that partial update back into the shared dictionary for you, so requirement survives untouched into the next node. We set temperature=0 deliberately. Generated tests are content that lands under version control, and a non-deterministic generator produces a different spec on every run, which turns your diff review into noise. Determinism is the closest thing to a stable oracle you get when a model is the one writing your tests.
The second node never needs the raw requirement. It reads state["plan"], the structured steps from the previous node, and asks for Playwright code. Splitting plan from generate is not ceremony. A plan is cheap to eyeball and cheap to correct, while a forty line spec is neither. If the plan is wrong, you catch it before you have burned tokens generating a convincing but incorrect test.
def generate_node(state: TestGenState) -> dict: prompt = ( "Write a Playwright test (TypeScript, @playwright/test) for these steps.\n" "Prefer getByRole and getByLabel locators. Return only code.\n" f"Steps:\n{state['plan']}" ) code = llm.invoke(prompt).content return {"spec": code}
We pin the locator strategy in the prompt, asking for getByRole and getByLabel rather than brittle CSS or XPath. That single instruction is where a lot of flakiness is won or lost. Role and label locators track the accessibility tree, so they survive markup churn far better than .btn-primary:nth-child(3). The generator will not always obey, which is precisely why later sections add a validation node and a heal loop instead of trusting the first draft.
With the state and nodes defined, the graph itself is four lines of plumbing plus a compile. We register each node under a string name, declare the entry point, connect plan to generate, and route generate into the built-in END sentinel that tells LangGraph the run is finished.
builder = StateGraph(TestGenState) builder.add_node("plan", plan_node) builder.add_node("generate", generate_node) builder.set_entry_point("plan") builder.add_edge("plan", "generate") builder.add_edge("generate", END) graph = builder.compile()
The names you pass to add_node are the same strings you use in add_edge, so a mismatch is caught at compile time, not at 2 a.m. in a CI log. set_entry_point("plan") is shorthand for an edge from the special start marker into your first node. compile() validates the topology, confirms every node is reachable and that edges point at real nodes, and hands back a runnable object. If you want that topology in your CI docs, graph.get_graph().draw_mermaid() emits a Mermaid diagram of the exact pipeline you just built.
invoke per requirement in a loop or per case in a parameterized suite. Recompiling inside the loop just wastes work.Invocation is one call. You pass a dict that satisfies the entry channel of the state, LangGraph runs plan then generate in order, and returns the final state with every channel filled in. You only supplied requirement; the graph populated plan and spec as it went.
result = graph.invoke({"requirement": "A user can log in with valid credentials"}) print(result["spec"])
Run that against a real login story and the spec channel comes back holding something close to this, ready to save as login.spec.ts:
import { test, expect } from '@playwright/test'; test('valid login', async ({ page }) => { await page.goto('/login'); await page.getByLabel('Email').fill('[email protected]'); await page.getByLabel('Password').fill('correct-horse'); await page.getByRole('button', { name: 'Log in' }).click(); await expect(page.getByText('Welcome')).toBeVisible(); });
That artifact is the whole point of building this graph in the first place: a requirement in plain English went in, and a review-ready Playwright test came out, with every intermediate step captured in typed state you can log, assert on, or replay. Swap invoke for stream and you get the same run as a sequence of per-node updates, which is handy when you want a live CI log showing the plan land before the spec does.
graph.invoke in a small helper and feed it a list of acceptance criteria pulled from your ticket tracker. One graph, many requirements, and each output diffs cleanly because temperature is zero./login or whether the button actually reads "Log in". Treat the output as a first draft that a human or a downstream validation node must run against the live app before it earns a place in the suite. The sections ahead close that gap with cycles that execute the spec, read the failure, and heal the locators.You now have a working, if naive, test generator: a linear LangGraph for QA pipeline that is fully typed, deterministic, and inspectable end to end. It is also the scaffold every later pattern hangs off. Add a conditional edge and the graph can branch on whether the plan even looks testable. Add a cycle and it can regenerate a spec that failed to compile. Add a MemorySaver checkpointer and a reviewer can approve the plan before any code is written. The two-node line you just compiled is the smallest honest version of all of them.
Every LangGraph node reads from and writes to one shared object: the state. If nodes are the workers, state is the case file they pass around, each one adding a note before handing it on. For a QA workflow that matters more than it sounds, because the difference between a test agent that heals itself and one that quietly overwrites its own evidence comes down to how you declare a single field. This section is the mechanical heart of langgraph for qa: how state accumulates, how add_conditional_edges turns a pass/fail oracle into a branch, and how a cycle lets a graph retry until a test goes green without spinning forever.
Recall the shape from the earlier sections and the sibling LangChain guide: you hand StateGraph a schema, register nodes with add_node, wire them with add_edge, mark an entry with set_entry_point, and call compile(). What that walk-through glosses over is what happens when two nodes both write the same key. By default the last write wins and the channel is overwritten. That is fine for a scalar like the current test source, and quietly destructive for a running log you meant to keep.
State is a TypedDict. Each key is a channel, and each channel has an update rule called a reducer. When you leave a key as a plain type, LangGraph uses the default reducer, which replaces the old value with whatever the node returned. To make a channel accumulate, you annotate it with a reducer function using typing.Annotated. The classic choice for a list is operator.add, because adding two lists concatenates them.
from typing import Annotated, TypedDict import operator class HealState(TypedDict): task: str code: str logs: Annotated[list, operator.add] # reducer -> concatenates every write passed: bool attempts: int # no reducer -> overwritten each write
Now when a node returns {"logs": [stderr]}, LangGraph does not throw the previous logs away. It calls the reducer with the existing list and the new one, so the field grows across every loop iteration. That accumulated log is your triage artifact: at the end you can read the whole failure history in order, oldest attempt first. The attempts field, by contrast, has no reducer, so each write overwrites it, which is exactly what a counter wants. LangGraph also ships a purpose-built reducer for chat history, add_messages from langgraph.graph.message, which appends messages and reconciles their ids; reach for it when a node is a model call rather than a plain tool.
One rule saves hours of confusion: a node returns only the keys it wants to change, never the whole state. LangGraph merges that partial dict into the shared state channel by channel, applying each channel's reducer. Return {"passed": True} and every other field is untouched. This is why you never mutate the incoming state object in place; you return a small dict and let the graph fold it in.
def generate(state: HealState) -> dict: # draft or repair a test, informed by past failure logs code = draft_test(state["task"], state["logs"]) return {"code": code, "attempts": state["attempts"] + 1} def run_test(state: HealState) -> dict: result = pytest_run(state["code"]) # your real runner / subprocess return {"passed": result.ok, "logs": [result.stderr]}
A static add_edge("generate", "run_test") is an unconditional arrow: after generate, always go to run_test. Branching needs add_conditional_edges. You give it a source node, a routing function, and a path map. After the source node runs, LangGraph calls your routing function with the current state, takes the string it returns, looks that string up in the path map, and jumps to the node named there. The routing function is pure Python reading pure state, which makes it trivial to unit test in isolation, the same way you would test any oracle.
In a test loop the routing function is the oracle in a very literal sense. It inspects the pass/fail result the runner wrote into state and decides the next hop: success ends the run, failure sends control back to regenerate, and a blown attempt budget bails out cleanly. Returning to a node you already visited is not an error in LangGraph; it is the whole point. That backward edge is what forms a cycle.
from langgraph.graph import StateGraph, END def route(state: HealState) -> str: if state["passed"]: return "done" if state["attempts"] >= 3: return "giveup" # step-counter guard fires here return "retry" builder = StateGraph(HealState) builder.add_node("generate", generate) builder.add_node("run_test", run_test) builder.set_entry_point("generate") builder.add_edge("generate", "run_test") builder.add_conditional_edges( "run_test", route, {"retry": "generate", "done": END, "giveup": END}, # "retry" -> back to generate = the cycle ) app = builder.compile()
The wiring above builds a cycle: run_test can route back to generate, which flows forward to run_test again. Traditional DAG-only orchestrators forbid this; LangGraph is built for it, and that is a large part of why the framework fits self-healing test automation so well. Each trip around the loop, generate sees the accumulated logs and drafts a repair, run_test executes it and records a fresh pass/fail, and the conditional edge decides whether to spin again. A flaky locator that fails once and passes on retry, a selector the model fixes after reading the stderr, an assertion that needed an explicit wait: the loop absorbs all of them without you hand-coding a retry ladder.
This is where langgraph for qa stops being a diagram and starts paying rent. The same cycle shape drives a heal loop for broken Playwright selectors, a re-ask loop for an MCQ generator whose output failed schema validation, or a refine loop that widens coverage until a report is satisfied. Swap the node bodies, keep the topology. The state carries the growing context between iterations, so attempt three is smarter than attempt one instead of a blind repeat.
A cycle with no exit is an infinite loop, and an infinite loop that calls a paid model is a budget incident. A production langgraph for qa loop needs two guards, and the belt-and-braces pairing is deliberate. The first is a step counter that lives in state. Every generate increments attempts, and the routing function checks it: once attempts crosses your budget the router returns the giveup branch and the graph exits through END with its logs intact, so CI gets a real triage report instead of a hang. This guard is domain aware, you pick the number, and it fails gracefully.
The second guard is LangGraph's own backstop. Every compiled graph enforces a recursion_limit on the number of supersteps a single invoke may run, defaulting to 25. Exceed it and the graph raises GraphRecursionError rather than looping forever. Because each pass through this two-node cycle spends roughly two supersteps, a counter capped at 3 trips long before the limit does. That ordering is the point: the counter is your clean, expected exit, and recursion_limit is the dumb circuit breaker that catches a routing bug where the counter logic itself is broken. You can raise or lower the limit per call by passing it in the config dict.
from langgraph.errors import GraphRecursionError seed = {"task": "validate login form", "code": "", "logs": [], "passed": False, "attempts": 0} try: final = app.invoke(seed, {"recursion_limit": 10}) # hard backstop, default is 25 except GraphRecursionError: # the counter never tripped: a routing bug. Fail the CI job loudly. raise
| Primitive | Role in the loop | When it acts |
|---|---|---|
add_edge | Unconditional next hop (generate -> run_test) | Always, after the source node |
add_conditional_edges | Branch on the oracle in state | After run_test, per the router |
| Cycle (retry edge) | Routes run_test back to generate | While the test still fails |
Step counter + END | Graceful exit with logs kept | attempts reaches your budget |
recursion_limit | Hard backstop, raises GraphRecursionError | Supersteps exceed the limit |
app.stream(seed) to get one update per superstep. In CI that stream becomes a live trace of generate, run_test, generate, run_test, which turns "why did it loop five times" into a log you can actually read during triage. Pausing this same loop so a human can approve a risky repair is a checkpointer's job, using MemorySaver, which the next section covers.Earlier sections wired a single agent: one create_react_agent, a toolbox, and a loop that runs until the model stops calling tools. That is enough for a surprising amount of QA work. But some jobs have parts that pull in opposite directions. Exploring a fresh build wants breadth and curiosity. Generating a suite wants discipline and coverage. Healing a broken locator wants caution and a very short leash. Cram all three appetites into one system prompt and the agent averages them into mush: it explores timidly, generates thin tests, and rewrites selectors it should have left alone. This is where a crew earns its keep.
A multi-agent crew in langgraph for qa is not a new API. It is the same StateGraph you already know, arranged so that one planner node routes work to several specialist nodes, all of them reading and writing a single shared state. That shared state is the blackboard: the one place every agent posts what it found and reads what the others left behind. Nothing is passed hand to hand; everything flows through the graph state, which is exactly why the pattern stays debuggable as it grows.
Start with the schema. In langgraph for qa, the state is a TypedDict that names every artifact the crew produces: the target under test, the route map the explorer builds, the growing suite, the failures from the last run, and a routing label the supervisor sets. Because more than one node appends to the same field, you annotate those fields with a reducer so LangGraph merges writes instead of overwriting them. operator.add on a list means each node that returns a findings value has it concatenated, not clobbered. Fields without a reducer, like failures, are replaced wholesale on every write, which is exactly what you want for the latest run.
from typing import TypedDict, Annotated import operator from langgraph.graph import StateGraph, END class QAState(TypedDict): target: str findings: Annotated[list, operator.add] # explorer appends here suite: Annotated[list, operator.add] # generator appends tests failures: list # last run, replaced each pass heal_tries: int next: str # supervisor routing label
The reducer choice is a real design decision, not boilerplate. Use operator.add (or the built-in add_messages for chat history) wherever many nodes or parallel branches contribute. Leave a field plain when the newest value should win. Getting this wrong is a quiet bug: an explorer that fans out into three parallel crawls will silently lose two thirds of its findings if the field has no reducer, and you will chase phantom coverage gaps for an afternoon before you notice the state was overwriting itself.
The supervisor is one node whose only job is to decide what happens next. It reads the blackboard, asks an LLM (or plain rules) which specialist should act, and writes a single label into state["next"]. It does not do the work itself. The routing happens through add_conditional_edges: you give it the source node, a function that reads state and returns a key, and a dict mapping each key to a destination node or END. The workers each add_edge straight back to the supervisor, so control returns to the planner after every step instead of running off in a straight line.
MAX_HEAL = 3 def supervisor(state: QAState) -> dict: if state["heal_tries"] >= MAX_HEAL and state["failures"]: return {"next": "done"} # bounded heal loop -> human label = planner.invoke(state) # else LLM picks the next worker return {"next": label} builder = StateGraph(QAState) builder.add_node("supervisor", supervisor) builder.add_node("explorer", explorer) builder.add_node("generator", generator) builder.add_node("healer", healer) builder.set_entry_point("supervisor") builder.add_conditional_edges("supervisor", lambda s: s["next"], { "explorer": "explorer", "generator": "generator", "healer": "healer", "done": END, }) builder.add_edge("explorer", "supervisor") builder.add_edge("generator", "supervisor") builder.add_edge("healer", "supervisor")
Those three edges back to the supervisor are what create the cycle, and cycles are the whole reason LangGraph exists rather than a plain DAG. The loop runs supervisor -> worker -> supervisor -> worker until the planner emits "done", which the conditional edge maps to END. A retry or heal loop is just this cycle with a counter: the supervisor keeps routing to the healer while failures remain, and stops once heal_tries hits a cap. set_entry_point tells the compiled graph to start at the supervisor, so you never wonder which node fires first.
Each worker is an ordinary function: state in, a partial state dict out. The explorer takes the target, crawls it, and returns findings; you can even register several explorers over different sections and let the reducer merge their maps. The generator reads findings and returns tests appended to the suite. The healer reads the last failures, proposes new locators or waits, and returns a patch plus an incremented heal_tries. None of them import anything exotic from LangGraph; the graph wiring, not the node body, is what makes them a crew.
def explorer(state: QAState) -> dict: routes = crawl(state["target"]) # your crawler tool return {"findings": routes} # reducer appends to the blackboard def healer(state: QAState) -> dict: patch = repair(state["failures"]) # propose new locators / waits return {"suite": patch, "heal_tries": state["heal_tries"] + 1}
Keeping the healer honest matters most. It is the one specialist allowed to change existing tests, so its blast radius is the largest. Bounding its loop in the supervisor, rather than trusting the node to stop itself, is the QA-safe default: an oracle that grades its own homework is no oracle at all.
| Node | Reads | Writes | Stops when |
|---|---|---|---|
| explorer | target | findings | route map covered |
| generator | findings | suite | every finding has a test |
| healer | failures | suite, heal_tries | patch applied or cap hit |
| supervisor | all | next | next == "done" -> END |
The checkpointer does more than survive a crash. Because MemorySaver snapshots state after every node, you can compile the graph with an interrupt before the healer runs and let a person approve the patch before it touches the suite. You invoke with a config that carries a thread_id, LangGraph stops at the interrupt and persists the pending state, and a later invoke on the same thread_id resumes exactly where it paused. For a crew that rewrites locators in a shared repo, that pause is not a nicety; it is the line between a healer that suggests fixes and one that quietly commits them. It is the same human-in-the-loop pattern the persistence section introduced, now guarding the riskiest node in the graph.
from langgraph.checkpoint.memory import MemorySaver graph = builder.compile( checkpointer=MemorySaver(), interrupt_before=["healer"], # pause so a human can approve the patch ) cfg = {"configurable": {"thread_id": "run-42"}} graph.invoke({"target": url}, cfg) # runs, then stops before healer graph.invoke(None, cfg) # approved -> resume from the checkpoint
The honest answer for most teams: start with one agent. A single create_react_agent with a good toolset and a clear system prompt will crawl, generate, and even self-correct in one loop, and it will do so with less latency, fewer tokens, and far fewer coordination bugs than a crew. Splitting is not free. Every handoff is another chance for state to drift, another prompt to maintain, another place a routing label can be wrong and send the run to the healer when it needed the explorer. Reach for a crew only when the parts genuinely conflict, not because the diagram looks more impressive.
Split when the specialists need different oracles or stop conditions that cannot share one prompt: an explorer that should roam versus a healer that must stay conservative. Split when you need bounded, independently tunable loops, like capping heal attempts while letting exploration run wide. Split when you want parallelism, fanning several explorers across a large app. And split when triage demands an audit trail: with a supervisor crew, every handoff is a labeled edge and every artifact lands on the blackboard, so a flaky run is far easier to reconstruct than one opaque agent transcript that just says it "tried some things."
Do not split when your agents differ only by prompt and not by control flow, or when a single tool-calling loop already passes CI. A crew that just renames one model three times adds cost and no capability. The sibling guides in this series cover the single-agent building blocks and tool design; treat langgraph for qa crews as the step you take once one agent visibly strains under conflicting jobs, not the place you begin.
The self-healing test graph is where cycles finally earn their keep. A linear chain cannot re-run a spec after it patches one, but a graph can loop patch -> run -> classify until the test goes green or a repair budget runs out. This is the pattern that makes langgraph for qa more than a demo: a stateful machine that reads its own test output, decides whether the failure is a drifted locator or a genuine regression, fixes what it is allowed to fix, and hands the rest to a human. Everything you built in the earlier sections (the StateGraph, typed state, conditional edges, and checkpointers) composes into one small, auditable repair loop.
Self-healing has a deservedly bad reputation because naive versions cheat. If you let an LLM rewrite whatever it wants to turn red into green, it will eventually weaken the assertion instead of fixing the selector, and you ship a test that passes on a broken app. We will build the loop first, then spend real effort on the one guardrail that keeps it honest.
State is a plain TypedDict. The two fields that make healing safe are attempts (a bounded counter, so the cycle cannot spin forever) and passed (the oracle result from the last run, which is the only signal allowed to end the loop). Because a bare TypedDict channel overwrites on each write, incrementing attempts is just returning a new value from a node.
from typing import TypedDict from langgraph.graph import StateGraph, END class HealState(TypedDict): spec_path: str # the failing test file error_text: str # stderr / stack from the run failure_class: str # locator_drift | real_bug | unknown candidate_locator: str # proposed replacement selector attempts: int # bounded retry counter passed: bool # did the re-run go green
Notice what is not in the state: no field for a rewritten assertion, no field for an "expected value" the model can adjust. The schema itself is a boundary. If the healer has no channel to write an oracle change into, it cannot smuggle one through.
Each node does exactly one thing and returns a partial state update. run_spec shells out to your CI runner (pytest, Playwright, whatever) and records pass or fail plus the captured error. classify_failure reads only error_text and labels it. patch_locator proposes a new selector and increments the attempt counter. escalate_to_human packages the evidence for a person. Keep the model calls narrow: a NoSuchElementException or a Playwright TimeoutError on a find is almost always drift, while an AssertionError on a value is almost always a real bug.
def run_spec(state: HealState) -> dict: r = run_ci(state["spec_path"]) # your runner shell-out return {"passed": r.ok, "error_text": r.stderr} def classify_failure(state: HealState) -> dict: label = llm_classify(state["error_text"]) # drift vs real bug return {"failure_class": label} def patch_locator(state: HealState) -> dict: sel = propose_selector(state["error_text"]) # LOCATOR ONLY apply_locator(state["spec_path"], sel) return {"candidate_locator": sel, "attempts": state["attempts"] + 1}
The classify node is a good place to reuse the prebuilt create_react_agent from langgraph.prebuilt if you want it to inspect the live DOM or the page snapshot before labeling, exactly the ReAct pattern covered in the sibling LangGraph agents guide. For a first version, a single structured-output call is cheaper and easier to triage.
The loop is built from one back edge and two routers. set_entry_point starts at run. After a run, add_conditional_edges sends green straight to END and red to classify. After classification, the router sends drift to patch and everything else to escalate. The single line add_edge("patch", "run") is what turns a chain into a cycle: patch the locator, then run again.
graph = StateGraph(HealState) graph.add_node("run", run_spec) graph.add_node("classify", classify_failure) graph.add_node("patch", patch_locator) graph.add_node("escalate", escalate_to_human) graph.set_entry_point("run") graph.add_conditional_edges("run", after_run, {"green": END, "red": "classify"}) graph.add_conditional_edges("classify", route_class, {"locator_drift": "patch", "real_bug": "escalate", "unknown": "escalate"}) graph.add_edge("patch", "run") # the heal cycle graph.add_edge("escalate", END)
The routers are ordinary Python. after_run returns "green" when state["passed"] is true, else "red". route_class is where the retry budget lives: if attempts has hit the ceiling, it stops healing and returns "real_bug" so a human sees a locator that would not stabilize after three tries.
def route_class(state: HealState) -> str: if state["attempts"] >= 3: return "real_bug" # give up healing, escalate return state["failure_class"]
toHaveText string, a status-code check, or a wait threshold, the cheapest way to make a red test green is to break the oracle. A self-healer with write access to assertions does not fix flakiness, it manufactures false confidence. Enforce this in code, not in the prompt: give propose_selector a diff allowlist that only permits selector strings, reject any patch that changes a line matching your assertion patterns, and keep the run node reading the original, untouched oracle every cycle.| Signal in error_text | failure_class | Node action | Oracle touched? |
|---|---|---|---|
| NoSuchElement / find TimeoutError | locator_drift | patch, re-run | never |
| Stale element after DOM swap | locator_drift | patch, re-run | never |
| AssertionError on expected value | real_bug | escalate | never |
| 500 / network / app exception | real_bug | escalate | never |
| New / unparseable pattern | unknown | escalate | never |
Real bugs should never be auto-closed, so the escalate path is a pause, not an email. Compile the graph with a MemorySaver checkpointer and interrupt_before on the escalate node. The graph runs the heal loop, and the instant it decides a failure is a real bug, it snapshots state and stops before escalating. A reviewer inspects the pending state, decides whether it is a bug or a test that still needs work, then resumes.
from langgraph.checkpoint.memory import MemorySaver memory = MemorySaver() app = graph.compile(checkpointer=memory, interrupt_before=["escalate"]) config = {"configurable": {"thread_id": "login-spec-42"}} app.invoke({"spec_path": "tests/login.spec.py", "attempts": 0}, config) snap = app.get_state(config) # what a human reviews # after triage, resume from the checkpoint: app.invoke(None, config) # continues into escalate
The thread_id is the anchor: give every spec its own thread and the checkpointer keeps each repair attempt independent and replayable. get_state(config).values returns the exact error_text, attempts, and any candidate_locator the loop tried, which is precisely the triage packet a QA engineer wants. MemorySaver is fine for local runs and CI jobs that live inside one process; for a long-lived service that must survive a restart between the pause and the human decision, swap in a durable checkpointer (the Postgres or SQLite saver from the LangGraph checkpoint packages) with the same interface.
In practice you trigger this graph from a CI failure hook: a spec goes red, the pipeline invokes the graph with that spec path, and one of three things happens. The locator was patched and the re-run is green, in which case you open a pull request with the selector diff for review rather than committing silently. Or the budget was exhausted and the loop escalates. Or classification found a real bug and paused for a human immediately. Every run should log the classification, the attempt count, and the candidate locators so your flaky-test dashboard can tell genuine app regressions apart from selector churn.
Every earlier section built capability: a triage agent that reads failures, proposes a locator fix, reruns the suite, and can even open a pull request. This section is the counterweight. The same autonomy that makes LangGraph for QA useful is exactly what makes it dangerous the moment it runs unattended in CI. An agent that can edit a locator can also delete a passing test. An agent that can call your bug tracker can file forty duplicates before you finish your coffee. Guardrails are not optional polish. They are the difference between a tool you trust on the main branch and a science project you have to babysit.
Treat the agent like any other production service that spends money and mutates state. It needs bounded loops, a spend cap, an audit trail, a human in the loop for irreversible actions, and the narrowest set of permissions that still lets it do its job. LangGraph gives you a real primitive for each of these, and none of them require anything you have not already met in this guide.
Cycles are the heart of a heal-and-retry agent, and cycles are also how an agent hangs forever. If your routing function keeps sending state back to the fix node because the fix never actually works, the graph will spin until something stops it. That something is recursion_limit, a per-invocation cap on how many super-steps the graph may take. It defaults to 25 and lives in the config you pass to invoke or stream.
from langgraph.errors import GraphRecursionError config = {"recursion_limit": 10} # default is 25 try: result = graph.invoke(inputs, config) except GraphRecursionError: # the loop never converged: fail loud, do not silently pass escalate_stuck_run(inputs)
A raised error is a feature. It converts an invisible infinite loop into a loud, catchable failure that your CI job can surface as a red build. Pair the hard cap with a soft one you control: keep an attempt counter in state, and let your routing function give up before the recursion limit is ever reached, so you emit a clean diagnostic instead of a stack trace.
class TriageState(TypedDict): failures: list attempts: int verdict: str def route_after_verify(state: TriageState) -> str: if state["verdict"] == "green": return "done" if state["attempts"] >= 3: return "give_up" return "retry" builder.add_conditional_edges( "verify", route_after_verify, {"done": END, "give_up": "escalate", "retry": "fix"}, )
LangGraph does not ship a global token meter, and you should not pretend it does. Budgets are something you enforce yourself, using the same state and conditional-edge machinery. Accumulate token or call counts in the state as each model node runs, then route to END the moment you cross a ceiling. This keeps a runaway loop from turning into a runaway invoice. Recent chat models report usage on the returned message, so read it straight off the reply when the provider supplies it.
def call_model(state): reply = llm.invoke(state["messages"]) used = reply.usage_metadata["total_tokens"] # langchain-core AIMessage return {"messages": [reply], "tokens": state["tokens"] + used} def budget_gate(state) -> str: return "stop" if state["tokens"] > 50000 else "continue"
The messages key here relies on the add_messages reducer introduced earlier, so returning a single reply appends instead of overwriting the history. Wire budget_gate into add_conditional_edges with "stop" mapped to END, and the loop can never outspend its allowance.
Two more levers cost nothing to add. First, set max_tokens on the chat model so no single call can produce a runaway response. Second, do not send every step to your most expensive model. A cheap model is plenty for the routing and classification nodes; reserve the strong model for the node that actually writes a patch. Because each LangGraph node is an ordinary function, you can bind a different model per node and cut the bill without changing the graph shape at all.
When an agent modifies your test suite, "it worked on my machine" is not an acceptable audit trail. Compile the graph with a checkpointer and every super-step is persisted, keyed by a thread id you choose. MemorySaver keeps that history in process for local runs and tests; swap in a database-backed saver for anything that outlives a single job.
from langgraph.checkpoint.memory import MemorySaver graph = builder.compile(checkpointer=MemorySaver()) config = {"configurable": {"thread_id": "pr-4821"}} graph.invoke(inputs, config) for snap in graph.get_state_history(config): print(snap.next, snap.values.get("verdict")) # replay every decision
Now every decision the agent made is replayable. You can point a teammate at thread pr-4821 and walk the exact sequence of nodes, the state at each hop, and the moment it chose to escalate. For LangGraph for QA work that touches shared branches, this replayable history is what turns a vague "the bot changed something" into a reviewable trace of reasoning that stands up in a postmortem.
The single most important guardrail is a human gate in front of any irreversible action. LangGraph supports this natively: compile with interrupt_before (or interrupt_after) naming the nodes that must pause. A checkpointer is required, because the interrupt works by saving state and returning control to you. Inspect what the agent wants to do, then resume by invoking again with None.
graph = builder.compile( checkpointer=MemorySaver(), interrupt_before=["open_pull_request", "delete_test"], ) graph.invoke(inputs, config) # runs, then pauses at the gated node pending = graph.get_state(config) # review the proposed write if approved: graph.invoke(None, config) # resume from the checkpoint
If a reviewer wants to correct the plan rather than reject it outright, graph.update_state(config, {...}) edits the persisted state before you resume, so the agent proceeds from the human-approved version instead of its own draft. This is how you let an agent propose a locator fix while keeping a person firmly on the trigger for the commit that ships it.
Capability is granted through tools, so grant as little as the task requires. When you build an agent with the prebuilt create_react_agent, the second argument is the list of tools it may call, and that list is your permission boundary. An agent that triages flaky tests needs to read logs, read source, and run the suite; it has no business holding a tool that force-pushes to main.
from langgraph.prebuilt import create_react_agent agent = create_react_agent( llm, tools=[read_logs, read_source, run_pytest], # no deploy, no delete, no prod )
Point every one of those tools at staging. The rerun tool should hit a throwaway CI runner, the bug-filing tool should post to a sandbox project, and any database the agent can reach should be a disposable copy. Give the credentials the same treatment you would give an intern on day one: read-only where possible, scoped to non-production, and revocable in one click. The blast radius of a confused agent is exactly the set of tools you handed it, so keep that set small and keep it pointed away from anything students can see.
| Anti-pattern | Why it bites | The guardrail |
|---|---|---|
| Trusting the default recursion limit | A loop that never converges burns many steps of tokens before it fails | Set a tight recursion_limit plus an attempts counter that gives up sooner |
| Swallowing GraphRecursionError | A stuck agent looks like a pass; flaky tests get "fixed" invisibly | Catch it, mark the build red, escalate to a human |
| Autonomous writes with no interrupt | The agent opens PRs or deletes tests with nobody watching | interrupt_before the write node and require approval |
| Full tool belt "to be safe" | Deploy and delete tools become the agent's mistakes | Least-privilege tools list, read-only by default |
| Pointing tools at production | A hallucinated fix mutates real data or a real pipeline | Staging-only credentials and disposable resources |
| Compiling with no checkpointer | No audit trail; you cannot explain what the agent did | Compile with a checkpointer and keep a thread id per run |
Guardrails are the tax you pay for letting an agent near your test suite, and it is a cheap tax. A recursion limit is one config key, a budget is a state counter and a conditional edge, an audit trail is one compile argument, and human approval is a list of node names. Wire these in from the first commit rather than after the first incident, and the difference between a demo and a dependable teammate becomes a handful of lines you were going to write anyway.
You have walked the whole graph. This guide took one idea, that a test run is a stateful process and not a straight line, and grew it into a full runtime. Before you wire langgraph for qa into a real suite, here is the six-stage arc compressed into one checklist you can keep next to your editor. Each stage adds exactly one capability, and each capability maps to a QA problem you already fight with plain scripts. Read the recap first, then the six questions that come up most often when a team moves a heal loop or a triage agent from a notebook into CI.
| Stage | What you built | Key API | QA payoff |
|---|---|---|---|
| 1 | Modeled the run as state | TypedDict state | One typed object carries locators, verdicts, attempts |
| 2 | Wired a linear pipeline | add_node, add_edge, set_entry_point, END | Deterministic setup -> act -> assert flow |
| 3 | Branched on a verdict | add_conditional_edges | Route pass, fail, and flaky down different paths |
| 4 | Looped until green | cycles (edge back to a node) | Self-heal a locator, then re-assert until a budget hits |
| 5 | Persisted and paused | compile(checkpointer=...), MemorySaver | Resume after a crash, gate destructive steps on a human |
| 6 | Delegated to agents | create_react_agent | Hand open-ended exploration to a bounded sub-agent |
Read top to bottom, the arc is simple. You start with a StateGraph over a TypedDict, add nodes and straight edges, then reach for add_conditional_edges the first time your workflow needs to react to its own output. Cycles turn that reaction into a loop. A checkpointer turns the loop into something you can pause, resume, and audit. The prebuilt ReAct agent is the last resort, reached only when a single node needs genuine open-ended judgment. If you internalize one thing about langgraph for qa, make it this order: do not jump to multi-agent graphs until stages one through five feel boring.
No, and treating it as one causes confusion. LangChain and its expression language are excellent for linear chains: prompt -> model -> parser, a directed acyclic pipeline that flows one way and finishes. Our sibling LangChain for QA guide covers exactly that shape. LangGraph adds a graph runtime on top: explicit shared state, conditional edges, and cycles, so a run can loop back on itself instead of ending. You still call the same models, the same tools, the same output parsers inside your nodes. The difference shows up the moment a workflow has to respond to its own result, retry a flaky step, heal a broken selector, escalate a triage. If your logic is truly one-directional, stay on a plain chain; it is simpler and you should not pay for a graph you never cycle.
You need a cycle when the exit condition is data-dependent rather than count-dependent. A fixed retry, "always try three times", is a plain for loop and needs no graph. A cycle earns its place when the loop body itself decides whether to continue: heal the locator, re-run the step, and stop only when the assertion passes or an attempt budget is spent. You express that by pointing a conditional edge back at an earlier node.
from langgraph.graph import StateGraph, END g.add_conditional_edges("heal", route, {"retry": "run_step", "done": END})
If your steps are "do X, then Y, then done", you do not need cycles at all. Reach for them when the shape is "keep trying, in a smarter way each time, until an oracle is satisfied". That is the exact shape of a self-healing locator loop or an iterative test-data generator.
No, keep them as two separate layers. CI-level retries (Playwright's retries, pytest-rerunfailures, a re-run button in your pipeline) are a blunt outer net that reruns the whole test process without understanding why it failed. A graph heal loop is a fine-grained inner loop that reasons about the failure and changes its approach before retrying. Use the inner loop to cut the number of failures that ever reach CI; keep CI retries for the truly nondeterministic residue such as a network blip or a cold cache.
A checkpointer snapshots state after every node, keyed by a thread_id you pass in the config. That single mechanism unlocks three QA wins. First, resume: a long suite that crashes at test 400 restarts from 400, not from zero. Second, human-in-the-loop: pause before a destructive or expensive action and continue only after a reviewer approves, using the interrupt flag at compile time. Third, time-travel debugging: inspect the exact state that produced a bad triage decision.
from langgraph.checkpoint.memory import MemorySaver app = g.compile(checkpointer=MemorySaver(), interrupt_before=["delete_data"])
MemorySaver lives in process memory and dies with it, which is perfect for tests and demos. For runs that must survive a restart, swap in a database-backed checkpointer (a SQLite or Postgres saver) behind the same interface. Your graph code does not change.LangGraph itself is a free, open-source orchestration library and adds no token cost. Every rupee or cent you spend is the model and tool calls inside your nodes, exactly as it would be without a graph. The one cost trap unique to graphs is the cycle: an unbounded heal loop can hammer the model dozens of times on a single stuck test. Apply the same double-cap discipline good QA platforms use for any metered dependency: a per-run iteration cap you check inside the conditional edge, plus a global budget that stops the batch when a daily quota is reached. If you already meter a code runner or an AI endpoint this way, you know the pattern; a graph just makes the cap a first-class field in state instead of an afterthought.
Start with one graph and zero sub-agents. Reach for create_react_agent from langgraph.prebuilt only when a node genuinely needs open-ended tool use: explore an unfamiliar page, choose among candidate locators, decide the next action from what it sees. Most QA workflows top out at two to four specialized roles: a planner, an executor, a triager, and maybe a reporter. More agents means more nondeterminism, more tokens, and a harder debugging story when a run goes sideways. Count agents the way you count test doubles: the fewer the better, and every one has to justify its presence. A disciplined, mostly-deterministic graph with a single ReAct node where judgment is unavoidable will out-test a sprawling multi-agent swarm almost every time, and that restraint is what makes langgraph for qa maintainable past the first demo.
Series siblings: LangChain for QA and LangSmith for QA. See also AI Agents for QA, MCP for QA, and the AI for QA hub.
Explore the AI for QA hub →