Online edition for BLOGE
0.9.8-RC1· facts verified 2026-09-15 · 中文
Chapter 18 — Testing Your Graphs
Promise: By the end of this chapter you will know how to verify a BLOGE graph's declared contract before it reaches production — by asserting on
GraphResult, isolating operators with test doubles, executing whole graphs withGraphTestRunner, catching DSL mistakes at compile time, and controlling time-dependent behavior with deterministic test clocks.
Learning Goals
- Use
GraphResultas the primary test surface: assert success, inspect typed outputs safely, and verify per-node status or timing when behavior changes. - Isolate operator behavior with
MockOperatorso tests stay focused on graph wiring instead of unrelated I/O. - Execute a graph in a purpose-built harness with
GraphTestRunnerand verify execution order, skips, and status transitions. - Compile DSL snippets with
DslTestHelperso parse or compile failures stop before runtime, and choose the test level that matches the failure. - Control retries, delays, and timer-driven behavior deterministically with
ManualTimeSource,DeterministicTimerService, andTestGraphEngine.
Prerequisites
- Chapter 2 — Your First Graph — you need to know how graphs are loaded and executed.
- Chapter 6 — Resilience by Design — retries, timeout, and fallback behavior influence what a successful assertion looks like.
- Chapter 7 — Designing Good Operators — you need the operator contract before you can isolate it in tests.
Source Examples
| File | What it shows |
|---|---|
GraphResultObservabilityTest.java | GraphResult execution identity, timings, and compatibility behavior |
GraphResultSafeAccessTest.java | Safe typed output access patterns for tests |
MockOperator.java | Test doubles that return, throw, delay, or record invocations |
GraphTestRunner.java | Graph harness with ordering, status, and execution-log assertions |
DslTestHelper.java | Parse/compile assertions for .bloge snippets |
TestGraphEngine.java | Deterministic test harness with ManualTimeSource and timer control |
TestGraphEngineTest.java | Minimal examples of advancing logical time instead of waiting on wall clock |
DeterministicRetryTest.java | Retry/backoff assertions driven by manual time advancement |
Why This Matters
A graph can look right in a code review and still be wrong in one of three costly ways:
- a branch change skips a node that used to run,
- a fallback hides a failure you meant to surface, or
- a DSL refactor compiles in your head but not in the actual compiler.
Good BLOGE tests make those failures visible at the cheapest possible layer. You do not need to boot Spring or hit real services just to verify a dependency edge, a fallback path, or a typed output contract.
The testing mindset for BLOGE is simple: assert the graph's contract, not an
accidental implementation detail. Start at GraphResult, then zoom in with
MockOperator, GraphTestRunner, or DslTestHelper when you need more focus.
One Green Test, Four Different Claims
The order test can be completely green while a wider claim is still unknown. Read every test result together with the boundary it actually exercised:
| Claim | Evidence needed | What this chapter contributes |
|---|---|---|
| The DSL is legal | Real parser/compiler result | DslTestHelper |
| The graph follows its declared contract | Status, order, output, time, and failure assertions | GraphTestRunner and GraphResult |
| The business story is approved and still satisfied | Owner-approved Scenario, Policy, Fixture, and Oracle | Not established here |
| The release claim is source-bound and governed | Sealed evidence, exact source identity, Requirement and ClaimCapability | Not established here |
A passing OrderGraphTest therefore supports a precise sentence: “under these
test inputs and doubles, the graph produced these observed node facts.” It does
not support “the order business is correct” or “this release is qualified.”
Mental Model
Think in three concentric safety nets:
Each layer answers a different question:
- DSL compile tests ask: "Is this graph definition legal?"
- Graph tests ask: "Does the workflow satisfy the contract declared by this test?"
- Operator unit tests ask: "Does this operator transform inputs correctly?"
When a test fails, the layer tells you where to look next.
First Working Example
This example shows the most common production need: run a graph, assert
it succeeded, inspect outputs, and verify per-node timings. Everything
below is plain JUnit 5 with no external dependencies beyond bloge-core.
The 55-line class is kept whole so imports, graph construction, execution, and
all six assertion surfaces remain a copyable test rather than disconnected
fragments.
import com.leanowtech.bloge.core.dsl.GraphBuilder;
import com.leanowtech.bloge.core.engine.GraphEngine;
import com.leanowtech.bloge.core.engine.GraphResult;
import com.leanowtech.bloge.core.model.NodeStatus;
import com.leanowtech.bloge.core.operator.Operator;
import com.leanowtech.bloge.core.spi.DefaultOperatorRegistry;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class OrderGraphTest {
@Test
void orderGraph_completesSuccessfully() {
Operator<Void, String> validate = (in, ctx) -> "valid";
Operator<String, String> price = (in, ctx) -> "$42.00";
Operator<String, String> confirm = (in, ctx) -> "ORD-001";
var gb = new GraphBuilder("order");
var graph = gb
.node("validate", validate)
.node("price", price).dependsOn("validate")
.input((results, ctx) -> results.get("validate", String.class))
.node("confirm", confirm).dependsOn("price")
.input((results, ctx) -> results.get("price", String.class))
.build();
var engine = GraphEngine.builder()
.registry(new DefaultOperatorRegistry())
.build();
GraphResult result = engine.executeWithOperators(graph, null, gb.operators());
// 1. Overall success
result.requireSuccess(); // throws if any error
// 2. Typed output extraction
assertEquals("ORD-001", result.getOutput("confirm", String.class));
// 3. Safe access when a node might not exist
assertTrue(result.findOutput("confirm", String.class).isPresent());
assertTrue(result.findOutput("missing", String.class).isEmpty());
// 4. Per-node status
assertEquals(NodeStatus.COMPLETED, result.getStatus("validate"));
assertTrue(result.nodeSucceeded("price"));
// 5. Per-node timing (always non-null after execution)
assertNotNull(result.nodeTimings().get("validate"));
assertFalse(result.nodeTimings().get("validate").isZero());
// 6. Execution identity
assertNotNull(result.executionId());
}
}
Source:
GraphResultObservabilityTest.java
and
GraphResultSafeAccessTest.java
demonstrate every access method shown above.
Break It Apart
GraphResult — your primary assertion target
GraphResult
is an immutable record with nine fields. The accessors you will use most in tests:
| Method | Returns | Use when |
|---|---|---|
isSuccess() | boolean | Quick pass/fail check |
requireSuccess() | this or throws GraphExecutionException | Fail-fast in tests — exception message lists all errors |
getOutput(nodeId, type) | T | You are certain the node completed |
findOutput(nodeId, type) | Optional<T> | The node may have been skipped or failed |
getOutputOrDefault(nodeId, type, default) | T | You need a fallback value |
getOutputIfSuccess(nodeId, type) | Optional<T> | Only care about the output when the whole graph succeeded |
nodeSucceeded(nodeId) | boolean | Check individual node without extracting output |
statusMap() | Map<String, NodeStatus> | Iterate all statuses |
nodeTimings() | Map<String, Duration> | Performance assertions |
errors() | List<NodeError> | Inspect specific failure details |
suspendedNodes() | Map<String, String> | nodeId → suspendKey for nodes that suspended |
compensationResults() | List<CompensationResult> | compensation outcomes after graph failure |
nodeSchemas() | Map<String, SchemaDescriptor> | per-node output schemas |
The bloge-test module
The bloge-test module provides three
tools purpose-built for graph testing:
MockOperator<I, O> — a test double that records every invocation.
var fetch = MockOperator.<String, String>returning("Alice");
// ... execute graph ...
assertEquals(1, fetch.callCount());
assertEquals("test-input", fetch.lastInput());
Factory methods: returning(value), throwing(exception),
delaying(duration, value), recording(), of(function).
Source: MockOperator.java
GraphTestRunner — wraps GraphEngine with an internal
ExecutionListener that captures START:, COMPLETE:, FAILED:, and
SKIPPED: log entries.
var runner = new GraphTestRunner(Map.of(
"validate", validate,
"price", price
));
runner.execute(graph);
runner.assertNodeExecuted("validate");
runner.assertNodeSkipped("unreachable");
runner.assertExecutionOrder("validate", "price");
List<String> log = runner.executionLog();
// ["START:validate", "COMPLETE:validate", "START:price", "COMPLETE:price"]
Source: GraphTestRunner.java
DslTestHelper — compiles DSL strings and asserts parse/compile
errors.
var helper = new DslTestHelper(registry);
Graph graph = helper.compile("""
graph test {
node a : MyOperator { input { key = ctx.value } }
}
""");
ParseException err = helper.expectParseError("graph { bad }");
assertTrue(err.getMessage().contains("Expected"));
Source: DslTestHelper.java
Asserting on suspended nodes, compensation, and schemas
GraphResult exposes three additional fields that open up assertion surfaces
for suspend/resume workflows, saga-style compensation, and schema contract
testing.
suspendedNodes() — a Map<String, String> where each key is a
nodeId and each value is the suspend key the node handed back. Use it to
verify that a node correctly suspended and that its key is what your
resume logic expects.
GraphResult result = engine.execute(graph, context);
// The "approval" node should have suspended
assertTrue(result.isSuspended());
assertEquals("pending-manager-approval",
result.suspendedNodes().get("approval"));
// Other nodes should not be suspended
assertFalse(result.suspendedNodes().containsKey("validate"));
compensationResults() — a List<CompensationResult> collected
after a graph failure when compensation operators are configured. Each
entry tells you whether the compensation ran and whether it succeeded.
GraphResult result = engine.execute(failingGraph, context);
assertFalse(result.isSuccess());
List<CompensationResult> compensations = result.compensationResults();
assertFalse(compensations.isEmpty());
// Check that the payment node was compensated successfully
CompensationResult paymentComp = compensations.stream()
.filter(c -> c.nodeId().equals("processPayment"))
.findFirst().orElseThrow();
assertTrue(paymentComp.isSuccess());
assertEquals("RefundOperator", paymentComp.operatorRef());
// Check that a failed compensation is surfaced, not swallowed
CompensationResult inventoryComp = compensations.stream()
.filter(c -> c.nodeId().equals("reserveInventory"))
.findFirst().orElseThrow();
assertFalse(inventoryComp.isSuccess());
assertNotNull(inventoryComp.error());
nodeSchemas() — a Map<String, SchemaDescriptor> containing
per-node output schemas. Use this for schema contract tests that
verify the shape of each node's output without running the full graph.
GraphResult result = engine.execute(graph, context);
// Verify the "fetchUser" node exposes the expected schema
SchemaDescriptor userSchema = result.nodeSchemas().get("fetchUser");
assertNotNull(userSchema);
assertInstanceOf(StructuredSchema.class, userSchema);
StructuredSchema structured = (StructuredSchema) userSchema;
assertTrue(structured.fieldNames().containsAll(
List.of("id", "name", "email", "vipLevel")));
These three fields default to empty collections when not populated, so existing tests continue to pass without changes.
Testing time-dependent workflows
Time is one of the easiest ways for a good test suite to become slow, flaky, or both.
BLOGE's testing module avoids that by giving you deterministic time control:
ManualTimeSourceadvances only when the test tells it toDeterministicTimerServicefires scheduled timers inline when time advancesTestGraphEnginewires those pieces into a ready-to-useGraphEngine
var registry = new DefaultOperatorRegistry();
var testEngine = TestGraphEngine.create(registry);
var op = MockOperator.<Object, String>delaying(Duration.ofMinutes(10), "done");
var builder = new GraphBuilder("delay-test");
Graph graph = builder.node("slow", op).build();
var resultRef = new AtomicReference<GraphResult>();
var done = new CountDownLatch(1);
Thread.startVirtualThread(() -> {
resultRef.set(testEngine.executeWithOperators(graph, new GraphContext(), builder.operators()));
done.countDown();
});
testEngine.awaitPendingSleepers(1); // wait until the delay is actually registered
testEngine.advanceTime(Duration.ofMinutes(10));
assertTrue(done.await(10, TimeUnit.SECONDS));
assertEquals("done", resultRef.get().getOutput("slow", String.class));
This pattern is the key detail many teams miss: advance logical time only after the sleeper or timer has been registered.
That is why awaitPendingSleepers(...) matters.
ManualTimeSource.advance(...) also fires timer callbacks inline, so one time advance can release sleepers, trigger durable timers, and complete the graph without any wall-clock waiting.
What deterministic time covers well today
Use this harness confidently for:
- delayed operators and retry backoff
- loop / wait timer behavior in plain graphs
- durable lease expiry and timer-driven resume infrastructure
- timer-driven state-machine behavior when the executor is constructed with a dedicated timer service
One caveat is worth making explicit: the current session idle-timeout path is still driven by its own scheduled runtime behavior rather than ManualTimeSource, so deterministic unit tests are easiest for graph and state-machine timers today. Session idle-timeout behavior is still worth testing, but it is better covered with focused smoke tests and policy-level assertions than by assuming full logical-clock control.
Scoping Lease-Fencing Tests — @WithExecutionLeaseFencing
When two engines share a durable store, only one of them is supposed to hold the execution lease for a given run. If the lease expires and a second engine picks the run up, the original engine must not be allowed to commit a stale checkpoint. The runtime enforces this via a fencing token on every store write, but tests need a way to prove the enforcement is wired correctly.
@WithExecutionLeaseFencing works with
ExecutionLeaseFencingExtension from bloge-test. It does one focused job:
- temporarily changes the default fencing policy for the annotated test;
- optionally enables strict rejection when no ambient lease context exists;
- restores the previous defaults after the test.
It does not create an engine, inject stores, expire a lease, or assert every write automatically. The test still owns those arrangements and assertions.
@ExtendWith(ExecutionLeaseFencingExtension.class)
@WithExecutionLeaseFencing
class CheckoutDurabilityTest {
@Test
@WithExecutionLeaseFencing(enabled = true, strictMode = true)
void missingLeaseContextRejectsDurableWrite() {
InMemoryExecutionStore store = preparedStoreWithExecution("checkout-1");
assertThrows(StaleFencingEpochException.class, () ->
store.updateStatus("checkout-1", ExecutionStatus.COMPLETED, 1));
}
}
The helper in this compact example only creates an execution record. For a
takeover test, claim once, release, claim from a second owner, then execute the
old write inside the first claim's ExecutionLeaseContext and assert rejection.
That sequence proves stale-owner fencing; the annotation alone does not.
Snapshot Testing
Asserting a single output value misses the shape of an execution.
Snapshot testing captures every node lifecycle event into a stable
GraphExecutionSnapshot, then asserts against the whole shape. This is
the right tool for "the graph still does what it always did" regression
tests.
SnapshotCapturingListener listener = new SnapshotCapturingListener();
GraphEngine engine = GraphEngine.builder()
.registry(registry)
.listeners(List.of(listener))
.build();
GraphResult result = engine.execute(checkoutGraph, ctx);
GraphExecutionSnapshot snapshot = GraphExecutionSnapshot.capture(result, listener);
GraphSnapshotAssert.assertMatchesBaseline(
snapshot,
"checkout-happy-path.snapshot.json"
);
GraphSnapshotAssert compares a captured snapshot with a JSON baseline:
assertMatchesBaseline(...)uses set semantics for completed and skipped node lists, avoiding false failures from legal parallel completion order.assertMatchesBaselineStrict(...)also requires the completed-node order to match.assertEquals(...)andassertEqualsStrict(...)compare two in-memory snapshots.updateBaseline(...)writes a reviewable baseline; use it deliberately, not as an automatic test update.
Use snapshots when the whole observable execution shape is a stable contract. For narrow rules and edge cases, individual assertions usually communicate the failure more directly.
Common Trap
❌ Asserting only the terminal output
GraphResult result = runner.execute(graph);
assertEquals("ORD-001", result.getOutput("confirm", String.class));
That assertion proves only one thing: the terminal node returned the value you expected. It does not prove that the graph actually succeeded cleanly, that an upstream node was not skipped, or that a resilience rule did not mask a failure you care about.
A safer pattern is to assert in layers:
GraphResult result = runner.execute(graph);
result.requireSuccess();
assertTrue(result.nodeSucceeded("validate"));
assertTrue(result.nodeSucceeded("price"));
assertEquals("ORD-001", result.getOutput("confirm", String.class));
Start with overall success, then check the few node-level facts that matter to this scenario, and only then assert on final outputs.
Guided Rewrite
Take the three-node graph from the first example and strengthen its tests in three passes:
- Replace the real
priceoperator withMockOperator.returning("$42.00")and assert that it was called exactly once. - Execute the graph with
GraphTestRunnerand assert the ordervalidate → price → confirm. - Break the DSL on purpose — remove the operator type from one node — and use
DslTestHelper.expectCompileError(...)to assert on the failure instead of discovering it at runtime.
By the end you should have one small graph covered at three layers: compile, execution, and operator behavior.
Brain Check
-
What is the fastest way to make a test fail with a clear summary of all node failures?
(
requireSuccess()onGraphResult.) -
When should you prefer
findOutput(nodeId, type)overgetOutput(nodeId, type)?(When the node may legitimately be skipped, fail, or be absent in the scenario under test.)
-
What problem does
MockOperatorsolve better than a hand-written lambda in many tests?(It records invocation data and offers reusable return/throw/delay behavior, so you can assert on calls without extra boilerplate.)
-
What kind of bug is best caught by
DslTestHelper?(A parse or compile bug in the
.blogesource — missing operators, invalid bindings, schema issues, or malformed syntax.) -
Why is
GraphTestRunnermore useful than checking only the terminal output?(Because it lets you assert execution order, skips, and per-node behavior, not just the final value.)
Lab
Goal: build a test suite for a small order graph before you trust it.
- Create a graph
validate → price → confirmusingGraphBuilder. - Use
MockOperatorfor all three operators. - Execute the graph with
GraphTestRunnerand assert:- all three nodes executed,
- none were skipped,
- execution order is
validate, thenprice, thenconfirm.
- Execute the same graph again and assert on the returned
GraphResult:requireSuccess()passes,confirmreturns the expected order ID,priceshows a non-null timing.
- Write one failing DSL snippet and assert that
DslTestHelperreports the compile error you expect.
Stretch: add a fallback to price, force the primary behavior to throw, and
assert that the graph still succeeds for the reason you intended.
Bridge: from graph assertions to business verification
GraphResult, GraphTestRunner, and DslTestHelper tell you whether the graph executed as the test expected. They do not bind an owner-approved Scenario, protect required Cases with Policy, control declared effects with Fixture, or publish source-bound evidence. When the question changes from “did this graph run?” to “does this business story still satisfy its approved contract?”, continue with Chapter 23 — Business Correctness Is Not Test Pass.
Experiment acceptance card
- Expected and observed: A green graph test supports only its declared graph contract.
- Failure and recovery: Remove one node-status assertion to create a false green; restore output, status, and evidence assertions.
- Proof boundary: Proves controlled runtime facts, not an external system or business answer.
- Exercise contract: One green test; remove one assertion; deliver before/after claim cards; stop when each conclusion points to an assertion.
Recap
GraphResultis the first place to assert workflow behavior. It also exposessuspendedNodes(),compensationResults(), andnodeSchemas()for suspend/resume, saga compensation, and schema contract assertions.MockOperatorkeeps tests focused by replacing unrelated I/O with a controllable test double.GraphTestRunnergives you graph-level assertions: order, execution, and skip behavior.DslTestHelperlets you test.blogesource the same way the compiler sees it.TestGraphEngine,ManualTimeSource, andDeterministicTimerServicekeep time-dependent tests fast and repeatable.- Strong BLOGE tests stack layers instead of overloading one giant integration test.
Next Step
In Chapter 19 — Observability in Production you will switch from verifying a declared contract in tests to understanding how a graph behaves when it is already running in the wild.
Reference Links
bloge-testREADME — testing helpers overviewGraphResult— immutable execution result recordMockOperator— reusable test doublesGraphTestRunner— graph harness and assertionsDslTestHelper— DSL parse and compile assertionsGraphResultSafeAccessTest.java— safe-access examples- Core Architecture — engine internals relevant to graph execution assertions
Coding Agent: Open the versioned task guide.