The Testing Academy · AI for QA

LangGraph for QA: stateful, self-healing test agents

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.

Playable state graph~10,000 wordsCycles + retriesSelf-healing testsMulti-agent crews

Watch a state graph loop until green

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.

Flow
Step 0 / 0
GOALSHARED STATEHEAL LOOPRequirementthe goalPlan nodedecide nextGeneratewrite a specRunexecutePass?route on resultDonereport / PRShared statetyped blackboardHealpatch the locator
LangGraph for QA: nodes update a shared state; a conditional edge routes to Done or loops through Heal and re-runs.

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.

The roadmap

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.

1A linear graphentry -> node -> END2Shared statetyped, accumulating3Conditional routingbranch on a check4A cycleretry until green5Multi-agentplanner plus workers6Persist + humancheckpoints, approval
The LangGraph-for-QA roadmap: from a linear graph to a persisted, human-gated crew.
The full guide
  1. What LangGraph is, and why QA cares
  2. The LangGraph architecture: state, nodes, edges
  3. The LangGraph-for-QA roadmap
  4. Your first test-generation graph
  5. State, conditional edges, and cycles
  6. Multi-agent QA crews with LangGraph
  7. Building a self-healing test graph
  8. Guardrails, cost, and safety
  9. FAQ and roadmap recap

1. What LangGraph is, and why QA should care

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.

Chains go forward. Graphs go in circles.

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.

The three loops QA actually wants

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.

State is the shared test context

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.

Persistence turns a loop into a review checkpoint

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.

When a graph beats 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 taskReach forWhy
Summarize a failure, draft a bug title, classify one logLangChain chainOne pass, no branching, no retry
Retry a step until an oracle passes or a cap is hitLangGraph cycleNeeds an edge that loops back
Self-heal a drifted locator across attemptsLangGraph cycleState carries the evolving locator
Route a failure through assertion / timeout / infra branchesLangGraph conditional edgesBranching plus optional loops for evidence
Pause for human approval mid-runLangGraph + checkpointerInterrupt 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")]})
Cap every cycle. A backward edge with no stopping condition is an infinite loop that quietly burns model tokens and CI time. Every graph you ship for QA needs an explicit guard: an attempt counter in state, a budget check in the routing function, or LangGraph's own recursion limit, so a stubborn selector fails the run instead of hanging it.
Start small. Reaching for LangGraph for QA on a job a chain handles fine just adds moving parts to trace and debug. Prototype the linear version first, and promote it to a graph the moment you catch yourself writing a loop or a branch around it. That is the signal the acyclic shape has run out, and the graph is about to earn its keep.

2. The LangGraph architecture: state, nodes, edges

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.

The typed state: your shared blackboard

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.

Nodes: plain functions that update state

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: static wiring and conditional routing

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.

ConstructWhat it doesQA use
add_edge("a", "b")Always go a -> bsetup -> action, fixed steps
add_conditional_edges(src, fn, map)Router returns a key -> next nodepass vs fail vs retry branching
add_edge(START, "n") / set_entry_point("n")Where execution beginsfirst node of the run
route to ENDTerminates that pathtest 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.

Compile, invoke, and stream

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.

The reason-act-observe loop as a cycle

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.

Guard the cycle. A backward edge with a bad oracle loops forever. Two seatbelts: bound it in state (the 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.

One more piece. Everything above is stateless per run. Pass a checkpointer at compile time, 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.

3. The LangGraph-for-QA adoption roadmap

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.

StageLangGraph primitiveQA payoff
1. Linear graphadd_node, add_edge, ENDA readable pipeline you can trace step by step
2. Shared typed stateTypedDict + reducersOne audited object every node reads and writes
3. Conditional routingadd_conditional_edgesThe agent branches on what it actually found
4. Cycle (retry loop)a back-edge to an earlier nodeSelf-healing locators and bounded retries
5. Multi-agentcreate_react_agent as nodesA planner fans work out to specialist workers
6. Persistence + HITLMemorySaver, interrupt_beforePause for human approval, resume where you stopped

Stage 1: A linear graph

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.

Stage 2: Shared typed state

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.

Stage 3: Conditional routing

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.

Stage 4: A cycle (retry loop)

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.

Cap every loop. A cycle with no exit condition will spin until it hits an API rate limit or your budget alarm. Always gate the back-edge on a counter in state and set a hard maximum, the same discipline you would use to bound retries in a flaky-test harness.

Stage 5: Multi-agent (planner plus workers)

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.

Stage 6: Persistence and human-in-the-loop

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.

Where this lands you. Six small steps take you from a linear graph to a persistent, supervised, self-healing test agent, and every rung was shippable on its own. If you followed the LangChain-family sibling guides on chains and agents, this is the same material with the control flow pulled out into the open where you can inspect it, which is exactly what a test system needs to stay trustworthy.

4. Your first test-generation graph

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.

Model the state as a TypedDict

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.

ChannelTypeWritten byRead by
requirementstrinvoke() inputplan node
planstrplan nodegenerate node
specstrgenerate nodeyour 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.

Node one: plan the test

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.

Node two: generate the Playwright spec

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.

Wire entry -> plan -> generate -> END

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.

Compile once, invoke many. The compiled graph is a reusable Runnable. Build it at module import, then call invoke per requirement in a loop or per case in a parameterized suite. Recompiling inside the loop just wastes work.

Compile, invoke, and read the spec

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.

Make it a fixture. Wrap 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.
Generated is not verified. This graph writes a plausible test, not a correct one. It has no idea whether the login route is really /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.

5. State, conditional edges, and cycles

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 that accumulates instead of clobbering

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]}

Conditional edges: branching on the oracle

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()

Cycles: the generate, run, heal loop

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.

Stopping the loop before it runs away

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
PrimitiveRole in the loopWhen it acts
add_edgeUnconditional next hop (generate -> run_test)Always, after the source node
add_conditional_edgesBranch on the oracle in stateAfter run_test, per the router
Cycle (retry edge)Routes run_test back to generateWhile the test still fails
Step counter + ENDGraceful exit with logs keptattempts reaches your budget
recursion_limitHard backstop, raises GraphRecursionErrorSupersteps exceed the limit
An unmapped route key crashes the graph. If your routing function returns a string that is not a key in the path map, LangGraph raises at runtime, not at compile time. Keep the router's return values and the path map in lockstep, and unit test the router against every branch, including the giveup path, exactly as you would test any pass/fail oracle.
Watch the loop iterate. Swap invoke for 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.

6. Multi-agent QA crews with LangGraph

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.

The blackboard: one state, many writers

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.

Tip. Keep the blackboard flat and typed. A state with ten well-named scalar and list fields is easier to triage than one nested "context" blob, and LangGraph's reducers only attach to top-level keys anyway, so nesting hides them.

The supervisor: a router, not a boss

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.

The specialists: explorer, generator, healer

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.

NodeReadsWritesStops when
explorertargetfindingsroute map covered
generatorfindingssuiteevery finding has a test
healerfailuressuite, heal_triespatch applied or cap hit
supervisorallnextnext == "done" -> END

Pausing the crew for a human

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

When to split, and when one agent is enough

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.

Rule of thumb. One node, one oracle. If a specialist needs its own definition of done, give it its own node and its own edge back to the supervisor; if it shares the supervisor's definition, it is a tool, not an agent.

7. Building a self-healing test graph

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.

The state that flows through the loop

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.

Four nodes, one job each

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.

Wiring the cycle with conditional edges

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"]
The Healer trap. The patch node must be allowed to rewrite locators and nothing else. The moment it can touch an assertion, an expected value, a 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_textfailure_classNode actionOracle touched?
NoSuchElement / find TimeoutErrorlocator_driftpatch, re-runnever
Stale element after DOM swaplocator_driftpatch, re-runnever
AssertionError on expected valuereal_bugescalatenever
500 / network / app exceptionreal_bugescalatenever
New / unparseable patternunknownescalatenever

Escalation as a human-in-the-loop pause

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.

Wiring it into CI

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.

Ship it dark first. Run the graph in report-only mode for a sprint: let it classify and propose locator patches, but open PRs instead of auto-merging, and keep escalations as review comments. Once you trust the classifier's precision on real bug versus drift, you can let green re-runs land automatically. Used this way, langgraph for qa turns a wall of red into a triaged queue: locator drift healed and reviewed, real regressions escalated with evidence, and nothing in between quietly weakening your oracles.

8. Guardrails, cost, and safety

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.

Bound the loop with recursion_limit

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"},
)

Cap the spend: token and call budgets

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.

Checkpoints for auditability

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.

Human approval before writes

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.

Least privilege and staging only

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-patterns to avoid

Anti-patternWhy it bitesThe guardrail
Trusting the default recursion limitA loop that never converges burns many steps of tokens before it failsSet a tight recursion_limit plus an attempts counter that gives up sooner
Swallowing GraphRecursionErrorA stuck agent looks like a pass; flaky tests get "fixed" invisiblyCatch it, mark the build red, escalate to a human
Autonomous writes with no interruptThe agent opens PRs or deletes tests with nobody watchinginterrupt_before the write node and require approval
Full tool belt "to be safe"Deploy and delete tools become the agent's mistakesLeast-privilege tools list, read-only by default
Pointing tools at productionA hallucinated fix mutates real data or a real pipelineStaging-only credentials and disposable resources
Compiling with no checkpointerNo audit trail; you cannot explain what the agent didCompile with a checkpointer and keep a thread id per run
Start read-only. The safest way to introduce LangGraph for QA into a real pipeline is to run the whole agent in observe mode first: every write node is interrupted and simply logs what it would have done. Watch a week of proposed diffs, confirm the agent's judgment matches yours, then remove the interrupt on the actions you trust one at a time. Autonomy is earned per action, not granted all at once.

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.

9. FAQ and roadmap recap

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.

StageWhat you builtKey APIQA payoff
1Modeled the run as stateTypedDict stateOne typed object carries locators, verdicts, attempts
2Wired a linear pipelineadd_node, add_edge, set_entry_point, ENDDeterministic setup -> act -> assert flow
3Branched on a verdictadd_conditional_edgesRoute pass, fail, and flaky down different paths
4Looped until greencycles (edge back to a node)Self-heal a locator, then re-assert until a budget hits
5Persisted and pausedcompile(checkpointer=...), MemorySaverResume after a crash, gate destructive steps on a human
6Delegated to agentscreate_react_agentHand 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.

Is LangGraph a replacement for LangChain?

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.

When do I actually need cycles?

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.

Does a LangGraph heal loop replace CI retries?

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.

Do not let a heal loop hide regressions. An agentic loop that silently turns red into green can paper over a real bug. Cap the iterations with a counter in state, and log every heal action, so any flaky-to-green transition is auditable in triage rather than invisible.

What does persistence buy me?

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"])
Dev vs durable. 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.

How much does this cost to run?

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.

How many agents should a QA graph have?

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 →