Skip to content
EZGraph Get started

No EZGraph key required. Free production use. No runtime fee. Bring your own model provider. Support Jev. Run in your own infrastructure. Optional support is available.

10. Testing the whole quote

Because QuoteGraph keeps prices, rules, and state transitions in code, most of what matters can be tested without calling a model. ezgraph-demo does that in quote-graph.spec.ts, and keeps one provider-backed evaluation for what code cannot check: that the real model actually runs the conversation the fakes assume. This lesson reads both, and shows how a new graph test could use EZGraph’s turn harness.

The goal

  • Unit-test pure business modules directly.
  • Drive a multi-stage conversation with a scripted chat model and assert state after every turn.
  • Test a graph-owned session policy with a hand-built document.
  • Know what the live evaluation checks, and how it tolerates harmless variation.

The two tiers

Script Runs Needs
npm run test:quote-graph quote-graph.spec.ts (seven tests); the E2E file is skipped nothing
npm run test2:quote-graph quote-graph.e2e.spec.ts with USE_ENV=1 KEEP_SESSION=1 working model-provider credentials

test:quote-graph matches test/quote-graph/*.spec.ts, which includes the E2E file. It skips itself unless USE_ENV=1 is set, so the first script is always provider-free.

Unit tests for the rating engine

describe("RatingEngine", () => {
  it("computes a deterministic risk factor and premium", () => {
    const factor = RatingEngine.riskFactor(subject, QUOTE_DATE);
    // 1.0 (age 34) × 1.06 (risk group 2) × 1.15 (one violation) × 0.95 (insured)
    assert.ok(Math.abs(factor - 1.15805) < 1e-9);
    assert.equal(RatingEngine.monthlyPremium(subject, selectedCoverage, QUOTE_DATE), 140.43);
  });

  it("orders the tiers saver < selected < shield", () => { /* ... */ });

  it("rejects liability-only coverage on a financed vehicle", () => {
    const error = validateCoverageSelection(
      { ...selectedCoverage, collisionDeductible: null, comprehensiveDeductible: null },
      "finance",
      QUOTE_DATE,
    );
    assert.match(String(error), /lender/i);
  });
});

The subject is the replay’s customer, so these tests pin the exact numbers shown in the live transcript. No graph, model, or session is involved; the engine is a plain module. That is the payoff of keeping it free of EZGraph imports.

The scripted quote

The graph test plays the whole quote in eight turns, with a hand-written gateway standing in for the model:

async function invoke(
  graph: QuoteGraph,
  state: QuoteGraphStateType | undefined,
  input: string,
): Promise<QuoteGraphStateType> {
  return graph.graph.invoke({
    ...graph.prepareInput(state, new HumanMessage(input)),
    inputConsumed: false,
    response: "",
  });
}

QuoteScriptedGateway implements the five-method LlmGateway interface; four methods throw because the graph never uses them. Its agent() method decides what to do from the tools it was offered, which identifies the stage, and from the newest human message and tool result:

if (names.has("resolve_vehicle")) {
  if (lastTool?.needsTrim) {
    return result(new AIMessage("That Camry comes in LE, SE, and XSE trims — which one is yours?"), activeConfig);
  }
  if (lastTool?.accepted === true && lastTool.vehicle) {
    return result(toolCall("use", "capture_vehicle_use", {
      vehicleId: (lastTool.vehicle as { id: string }).id,
      ownership: "finance", annualMileage: 12000, parking: "driveway",
    }), activeConfig);
  }
  // ...
}

Reading the previous tool result is what lets one scripted gateway exercise the real handlers. needsTrim comes from resolve_vehicle itself, so if the handler stopped reporting candidates, the test would fail.

Turn Customer Asserts
1 name, birth date, licence VehicleNode is current; the driver is saved; the reply asks about the vehicle
2 “a 2019 Toyota Camry” still VehicleNode; the reply lists LE, SE, and XSE; nothing resolved yet
3 SE, financed, 12,000 miles, driveway HistoryNode; the saved vehicle use equals the expected record
4 insured, one ticket in March 2026 CoverageNode; the saved history equals the expected record
5 liability only still CoverageNode; the reply mentions the lender; coverage is not saved
6 $500 deductibles and rental QuoteNode; coverage saved; tiers equal RatingEngine.quoteTiers() for that coverage
7 “What if I raise both deductibles to $1000?” coverage and tiers updated; the reply contains the new selected premium
8 “Let’s accept the selected option.” completed; currentNode is end; accepted tier and a QT- reference saved; the reply shows the adjusted premium

Turn 5 tests the lender rule through the real handler: the fake model submits liability-only, the handler refuses, and the fake relays the refusal. Turns 6 and 7 compare saved tiers with a fresh call to the engine, so the graph test and the unit tests cannot disagree about prices.

A second test sends a suspended licence and asserts the turn ends in DriverNode with no driver saved and “suspended” in the reply.

This test bypasses the engine

invoke() calls the compiled LangGraph directly. That tests nodes, tools, state, and transitions, but skips everything GraphEngine.run() adds: the session lease, the restore hook, persistence, per-step checkpoints, and the saved document. For new tests, EZGraph's createTurnHarness() runs real engine turns against an in-memory store. The DecisionHotelGraph tests use it.

Here is the first turn written with the harness. It is not part of the demo, but it passes against it:

import "reflect-metadata";
import assert from "node:assert/strict";
import { test } from "node:test";
import { createTurnHarness, scriptedGateway } from "@picoflow/ezgraph/testing";
import { QuoteGraph } from "../../src/graphs/quote-graph/quote-graph.js";
import type { QuoteGraphStateType } from "../../src/graphs/quote-graph/quote-graph.state.js";

test("a valid driver is saved and the vehicle stage replies in the same turn", async () => {
  process.env.QUOTE_GRAPH_CURRENT_DATE = "2027-06-01T00:00:00.000Z";
  const gateway = scriptedGateway()
    .callsTool("capture_driver", {
      fullName: "Jamie Rivera",
      dateOfBirth: "1993-04-12",
      licenseState: "or",
      licenseStatus: "valid",
      yearsLicensed: 10,
    })
    .text("Thanks, Jamie. What's the vehicle's year, make, and model?");
  const harness = createTurnHarness<QuoteGraphStateType>({ graph: QuoteGraph, gateway });

  const turn = await harness.send("Jamie Rivera, born 1993-04-12, valid Oregon license, 10 years.");

  assert.equal(turn.status, 200);
  assert.equal(turn.currentNode, "VehicleNode");
  assert.equal(
    gateway.calls[0]!.history[0]!.content,
    "Jamie Rivera, born 1993-04-12, valid Oregon license, 10 years.",
  );
  assert.equal(turn.state?.histories.default, undefined);
  assert.equal(turn.state?.nodes.DriverNode?.driver?.licenseState, "OR");
  assert.match(gateway.calls[1]!.systemPrompt, /This stage has just started/);
  assert.ok(gateway.drained);
  await harness.close();
});

It checks things the direct-invoke test cannot. The first model call received the original request in the initial node’s mapped history, with no accidental default history. The driver is in the persisted document, with the state code uppercased by the handler. The second model call, VehicleNode’s, received the one-time entry cue from lesson 4. And gateway.drained proves no unexpected model call was made.

The session policy

it("keeps a quote session inside the idle window", async () => {
  const graph = new QuoteGraph(new QuoteScriptedGateway());
  const restored = await graph.restoreSessionDoc(quoteSession(60_000));
  assert.ok(restored);
  assert.equal(restored.graph.currentNode, "CoverageNode");
});

it("starts a new run after the idle window", async () => {
  const graph = new QuoteGraph(new QuoteScriptedGateway());
  assert.equal(await graph.restoreSessionDoc(quoteSession(45 * 60_000)), null);
});

quoteSession(idleMs) builds a SessionDocument whose modifiedAt is idleMs in the past. Calling the public restoreSessionDoc() runs the same graph-ID check, migration path, and onRestoreSessionDoc() the engine uses, without an engine or a store. The document’s other fields are placeholders; only modifiedAt and the graph ID matter to this policy.

The live evaluation

quote-graph.e2e.spec.ts boots the real Nest application with Fastify and posts each of the 15 scenario turns to POST /ai/run, carrying the session ID between turns. The session store is in memory unless USE_ENV=1 lets .env choose it, and the test2 script always sets USE_ENV=1. It pins QUOTE_GRAPH_CURRENT_DATE to 2027-06-01 unless you set it yourself.

Each turn must return HTTP 200, keep the same session ID, and match the scenario’s completed flag. A judge model then grades the reply against the turn’s expectedResponse in quote-graph.scenario.json, for example:

{
  "label": "lender rule blocks liability-only",
  "input": "Can I skip collision and comprehensive entirely?",
  "expectedResponse": "The assistant must explain that a financed vehicle cannot be liability-only because the lender requires both collision and comprehensive coverage…"
}

The judge is gpt-4o through OPENAI_API_KEY, or openai-auth:gpt-5.4 when no key is set. A turn passes at a score of 0.75. The judge’s instructions are explicit about harmless variation, which is what keeps a live test from failing on wording:

The assistant may batch its questions differently than expected: accept responses that ask for only
part of the expected next details, and accept responses that ask for several related details of the
current stage in a single message, as long as they ask for a correct next missing item and nothing
wrong.

The judge also accepts quote presentations “even if tier naming or ordering differs slightly”, which is why the “Selected” / “Your selection” mismatch from lesson 9 does not fail the run.

After the last turn the test checks the completed session: status completed, currentNode end, the saved driver, vehicle use, history, coverage with $1000 deductibles, the accepted tier, and a QT- reference. It writes the transcript to test/.tmp/quote-graph/live.json, which is where the live replay comes from, or semantic-failure.json if a judgment fails. With KEEP_SESSION=1 the session is kept in the store; otherwise it is deleted.

What each tier cannot prove

Tier Proves Cannot prove
unit the engine’s numbers and rules, on every run anything about the conversation
scripted graph every tool, rule, transition, and state write the script reaches that a real model calls the tools the way the script does
live that a real model completes a realistic quote and the replies read correctly general reliability; one run is one sample, and a skipped run is not evidence

Why it is written this way

The deterministic tier is fast and exact, so it runs on every change and pins every number and state write. The live tier is slow, costs money, and varies, so it is opt-in and judged semantically, with its tolerances written down. Keeping prices in a pure module means the most important assertions, the premiums, need neither tier’s machinery at all.

Common mistakes

  • Testing prices through the conversation. Unit-test the engine, and compare graph results with it.
  • A scripted model that ignores tool results. Branch on the last tool result, so the real handlers’ outputs drive the script.
  • Unpinned dates. Ages and windows drift; set the clock in every tier.
  • Calling the compiled graph directly for engine behavior. Leases, restore, and persistence need createTurnHarness().
  • A judge with no stated tolerances. Write down what variation is acceptable, or the live test will fail on wording.

Next

Return to the QuoteGraph overview, continue with the DecisionHotelGraph track, or read the QuoteGraph case study comparing this graph with the plain LangGraph version.