Skip to main content

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 with GraphTestRunner, catching DSL mistakes at compile time, and controlling time-dependent behavior with deterministic test clocks.


Learning Goals

  1. Use GraphResult as the primary test surface: assert success, inspect typed outputs safely, and verify per-node status or timing when behavior changes.
  2. Isolate operator behavior with MockOperator so tests stay focused on graph wiring instead of unrelated I/O.
  3. Execute a graph in a purpose-built harness with GraphTestRunner and verify execution order, skips, and status transitions.
  4. Compile DSL snippets with DslTestHelper so parse or compile failures stop before runtime, and choose the test level that matches the failure.
  5. Control retries, delays, and timer-driven behavior deterministically with ManualTimeSource, DeterministicTimerService, and TestGraphEngine.

Prerequisites

Source Examples

FileWhat it shows
GraphResultObservabilityTest.javaGraphResult execution identity, timings, and compatibility behavior
GraphResultSafeAccessTest.javaSafe typed output access patterns for tests
MockOperator.javaTest doubles that return, throw, delay, or record invocations
GraphTestRunner.javaGraph harness with ordering, status, and execution-log assertions
DslTestHelper.javaParse/compile assertions for .bloge snippets
TestGraphEngine.javaDeterministic test harness with ManualTimeSource and timer control
TestGraphEngineTest.javaMinimal examples of advancing logical time instead of waiting on wall clock
DeterministicRetryTest.javaRetry/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:

Diagram: one green graph test and four claim boundaries

ClaimEvidence neededWhat this chapter contributes
The DSL is legalReal parser/compiler resultDslTestHelper
The graph follows its declared contractStatus, order, output, time, and failure assertionsGraphTestRunner and GraphResult
The business story is approved and still satisfiedOwner-approved Scenario, Policy, Fixture, and OracleNot established here
The release claim is source-bound and governedSealed evidence, exact source identity, Requirement and ClaimCapabilityNot 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:

Diagram: 18-testing-your-graphs figure 1

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:

MethodReturnsUse when
isSuccess()booleanQuick pass/fail check
requireSuccess()this or throws GraphExecutionExceptionFail-fast in tests — exception message lists all errors
getOutput(nodeId, type)TYou are certain the node completed
findOutput(nodeId, type)Optional<T>The node may have been skipped or failed
getOutputOrDefault(nodeId, type, default)TYou need a fallback value
getOutputIfSuccess(nodeId, type)Optional<T>Only care about the output when the whole graph succeeded
nodeSucceeded(nodeId)booleanCheck 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:

  • ManualTimeSource advances only when the test tells it to
  • DeterministicTimerService fires scheduled timers inline when time advances
  • TestGraphEngine wires those pieces into a ready-to-use GraphEngine

Diagram: 18-testing-your-graphs figure 2

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:

  1. temporarily changes the default fencing policy for the annotated test;
  2. optionally enables strict rejection when no ambient lease context exists;
  3. 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(...) and assertEqualsStrict(...) 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:

  1. Replace the real price operator with MockOperator.returning("$42.00") and assert that it was called exactly once.
  2. Execute the graph with GraphTestRunner and assert the order validate → price → confirm.
  3. 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

  1. What is the fastest way to make a test fail with a clear summary of all node failures?

    (requireSuccess() on GraphResult.)

  2. When should you prefer findOutput(nodeId, type) over getOutput(nodeId, type)?

    (When the node may legitimately be skipped, fail, or be absent in the scenario under test.)

  3. What problem does MockOperator solve 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.)

  4. What kind of bug is best caught by DslTestHelper?

    (A parse or compile bug in the .bloge source — missing operators, invalid bindings, schema issues, or malformed syntax.)

  5. Why is GraphTestRunner more 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.

  1. Create a graph validate → price → confirm using GraphBuilder.
  2. Use MockOperator for all three operators.
  3. Execute the graph with GraphTestRunner and assert:
    • all three nodes executed,
    • none were skipped,
    • execution order is validate, then price, then confirm.
  4. Execute the same graph again and assert on the returned GraphResult:
    • requireSuccess() passes,
    • confirm returns the expected order ID,
    • price shows a non-null timing.
  5. Write one failing DSL snippet and assert that DslTestHelper reports 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

  • GraphResult is the first place to assert workflow behavior. It also exposes suspendedNodes(), compensationResults(), and nodeSchemas() for suspend/resume, saga compensation, and schema contract assertions.
  • MockOperator keeps tests focused by replacing unrelated I/O with a controllable test double.
  • GraphTestRunner gives you graph-level assertions: order, execution, and skip behavior.
  • DslTestHelper lets you test .bloge source the same way the compiler sees it.
  • TestGraphEngine, ManualTimeSource, and DeterministicTimerService keep 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.


Coding Agent: Open the versioned task guide.