BLOGE
0.9.9-RC1在线版 · 编制 2026-11-02 · English
第 18 章 —— 测试你的 Graph
承诺: 读完本章后,你将知道如何在 graph 进入生产环境之前验证它声明的契约 —— 通过
GraphResult做断言、用测试替身隔离 operator、借助GraphTestRunner执行整张 graph、在运行前用DslTestHelper捕获 DSL 错误,并用确定性时间控制验证时间相关行为。
学习目标
- 把
GraphResult作为首要测试面:断言成功、以类型安全方式读取输出,并在行为变化时检查节点状态或耗时。 - 使用
MockOperator隔离 operator 行为,让测试聚焦在 graph 接线而不是外部 I/O。 - 使用
GraphTestRunner在专用测试夹具中执行 graph,并验证执行顺序、跳过行为和状态变化。 - 使用
DslTestHelper编译 DSL 片段,在运行前捕获 parse / compile 错误,并让测试层级与要阻止的失败相匹配。 - 使用
ManualTimeSource、DeterministicTimerService和TestGraphEngine对 retry、delay 和 timer 行为做确定性时间控制。
前置条件
- 第 2 章 —— 你的第一个 Graph —— 你需要知道 graph 如何加载与执行。
- 第 6 章——韧性设计 —— retry、timeout 和 fallback 会影响“正确行为”的断言方式。
- 第 7 章 —— 设计良好的 Operator —— 在隔离 operator 之前,你需要先理解 operator 契约。
源示例
| 文件 | 展示内容 |
|---|---|
GraphResultObservabilityTest.java | GraphResult 的执行标识、耗时和兼容性行为 |
GraphResultSafeAccessTest.java | 测试中安全读取类型化输出的模式 |
MockOperator.java | 返回、抛错、延迟、记录调用的测试替身 |
GraphTestRunner.java | 带顺序、状态和执行日志断言的 graph 测试夹具 |
DslTestHelper.java | .bloge 片段的 parse / compile 断言 |
TestGraphEngine.java | 内置 ManualTimeSource 与 timer 控制的确定性测试引擎 |
TestGraphEngineTest.java | 用逻辑时间推进替代墙钟等待的最小示例 |
DeterministicRetryTest.java | 用手动时间推进断言 retry/backoff 的测试 |
为什么这很重要
一张 graph 在代码评审里看起来没问题,仍然可能以三种昂贵的方式出错:
- 分支调整后跳过了原本应该执行的节点;
- fallback 掩盖了你本来想暴露的失败;
- DSL 重构在脑海里成立,但在真实编译器里并不合法。
好的 BLOGE 测试会在最便宜的层级把这些问题暴露出来。你不需要为了验证一个依赖边、一条 fallback 路径或一个类型化输出契约而启动 Spring 或调用真实服务。
BLOGE 的测试思路很简单:断言 graph 的契约,而不是偶然的实现细节。 先从 GraphResult 开始;当你需要更聚焦时,再用 MockOperator、GraphTestRunner 或 DslTestHelper 放大问题。
一次测试全绿,不等于四层声明都成立
订单测试可以完全通过,但更宽的声明仍然未知。每次读测试结果时,都要同时读 它实际覆盖的边界:
| 声明 | 所需证据 | 本章能提供什么 |
|---|---|---|
| DSL 合法 | 真实 parser/compiler 结果 | DslTestHelper |
| graph 满足声明的契约 | 状态、顺序、输出、时间与失败断言 | GraphTestRunner 与 GraphResult |
| 业务故事已获批准并仍被满足 | Owner 批准的 Scenario、Policy、Fixture 与 Oracle | 本章不能建立 |
| 发布声明已绑定源码并受治理 | 密封证据、精确源码身份、Requirement 与 ClaimCapability | 本章不能建立 |
因此,OrderGraphTest 通过只支持一句精确结论:“在这些测试输入和替身下,
graph 产生了这些可观察节点事实。”它不支持“订单业务正确”或“该版本具备发布资格”。
心智模型
把测试看作三层同心安全网:
每一层回答的问题都不同:
- DSL 编译测试 问:"这个 graph 定义合法吗?"
- Graph 测试 问:"工作流满足这个测试声明的契约吗?"
- Operator 单元测试 问:"这个 operator 的输入输出转换对吗?"
一旦测试失败,所处层级就会告诉你下一步该往哪里看。
第一个可运行的示例
这个示例展示了最常见的生产需求:运行一个 graph,断言它成功了,检查输出,并验证逐节点的耗时。以下代码是纯 JUnit 5,除了 bloge-core 之外没有外部依赖。
这 55 行 class 保留完整,目的是让 imports、graph 构造、执行和六类断言仍是一份
可复制测试,而不是彼此脱节的片段。
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. 整体成功
result.requireSuccess(); // 如果有错误则抛异常
// 2. 类型化输出提取
assertEquals("ORD-001", result.getOutput("confirm", String.class));
// 3. 当节点可能不存在时的安全访问
assertTrue(result.findOutput("confirm", String.class).isPresent());
assertTrue(result.findOutput("missing", String.class).isEmpty());
// 4. 逐节点状态
assertEquals(NodeStatus.COMPLETED, result.getStatus("validate"));
assertTrue(result.nodeSucceeded("price"));
// 5. 逐节点耗时(执行后始终非 null)
assertNotNull(result.nodeTimings().get("validate"));
assertFalse(result.nodeTimings().get("validate").isZero());
// 6. 执行标识
assertNotNull(result.executionId());
}
}
源码:
GraphResultObservabilityTest.java
和
GraphResultSafeAccessTest.java
演示了上述所有访问方法。
拆解分析
GraphResult —— 你的首要断言目标
GraphResult
是一个包含九个字段的不可变 record。在测试中最常使用的访问器:
| 方法 | 返回类型 | 使用场景 |
|---|---|---|
isSuccess() | boolean | 快速通过/失败检查 |
requireSuccess() | this 或抛出 GraphExecutionException | 在测试中快速失败 —— 异常消息列出所有错误 |
getOutput(nodeId, type) | T | 你确定节点已完成 |
findOutput(nodeId, type) | Optional<T> | 节点可能被跳过或失败 |
getOutputOrDefault(nodeId, type, default) | T | 需要一个兜底值 |
getOutputIfSuccess(nodeId, type) | Optional<T> | 只在整个 graph 成功时才关注输出 |
nodeSucceeded(nodeId) | boolean | 检查单个节点,无需提取输出 |
statusMap() | Map<String, NodeStatus> | 遍历所有状态 |
nodeTimings() | Map<String, Duration> | 性能断言 |
errors() | List<NodeError> | 查看具体的失败详情 |
suspendedNodes() | Map<String, String> | nodeId → suspendKey,表示挂起的节点 |
compensationResults() | List<CompensationResult> | graph 失败后的补偿结果 |
nodeSchemas() | Map<String, SchemaDescriptor> | 逐节点输出 schema |
bloge-test 模块
bloge-test 模块提供了三个专为 graph 测试设计的工具:
MockOperator<I, O> —— 一个记录每次调用的测试替身。
var fetch = MockOperator.<String, String>returning("Alice");
// ... 执行 graph ...
assertEquals(1, fetch.callCount());
assertEquals("test-input", fetch.lastInput());
工厂方法:returning(value)、throwing(exception)、delaying(duration, value)、recording()、of(function)。
GraphTestRunner —— 包装 GraphEngine,内部使用一个 ExecutionListener 来捕获 START:、COMPLETE:、FAILED: 和 SKIPPED: 日志条目。
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"]
DslTestHelper —— 编译 DSL 字符串并断言解析/编译错误。
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"));
对挂起节点、补偿和 schema 做断言
GraphResult 提供了三个额外字段,为挂起/恢复工作流、saga 风格的补偿和 schema 契约测试开辟了新的断言面。
suspendedNodes() —— 一个 Map<String, String>,其中 key 是 nodeId,value 是该节点交回的 suspend key。用它来验证节点确实正确挂起了,且其 key 与你的恢复逻辑预期一致。
GraphResult result = engine.execute(graph, context);
// "approval" 节点应该已挂起
assertTrue(result.isSuspended());
assertEquals("pending-manager-approval",
result.suspendedNodes().get("approval"));
// 其他节点不应挂起
assertFalse(result.suspendedNodes().containsKey("validate"));
compensationResults() —— 一个 List<CompensationResult>,在配置了补偿 operator 的 graph 失败后收集。每个条目告诉你补偿是否运行了、是否成功。
GraphResult result = engine.execute(failingGraph, context);
assertFalse(result.isSuccess());
List<CompensationResult> compensations = result.compensationResults();
assertFalse(compensations.isEmpty());
// 验证支付节点已成功补偿
CompensationResult paymentComp = compensations.stream()
.filter(c -> c.nodeId().equals("processPayment"))
.findFirst().orElseThrow();
assertTrue(paymentComp.isSuccess());
assertEquals("RefundOperator", paymentComp.operatorRef());
// 验证失败的补偿被暴露出来,而不是被吞掉
CompensationResult inventoryComp = compensations.stream()
.filter(c -> c.nodeId().equals("reserveInventory"))
.findFirst().orElseThrow();
assertFalse(inventoryComp.isSuccess());
assertNotNull(inventoryComp.error());
nodeSchemas() —— 一个 Map<String, SchemaDescriptor>,包含逐节点的输出 schema。用它做 schema 契约测试,在不运行完 整 graph 的情况下验证每个节点的输出结构。
GraphResult result = engine.execute(graph, context);
// 验证 "fetchUser" 节点暴露了预期的 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")));
这三个字段在未填充时默认为空集合,因此已有测试无需修改即可继续通过。
测试时间相关的工作流
时间是最容易让测试套件变慢、变脆,甚至两者兼而有之的因素之一。
BLOGE 的测试模块专门提供了确定性时间控制:
ManualTimeSource只会在测试显式推进时前进DeterministicTimerService会在时间推进时同步触发已调度的定时器TestGraphEngine把这两者接成一个现成可用的GraphEngine
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); // 先等延迟真正注册完成
testEngine.advanceTime(Duration.ofMinutes(10));
assertTrue(done.await(10, TimeUnit.SECONDS));
assertEquals("done", resultRef.get().getOutput("slow", String.class));
这里最关键的细节是:只有在 sleeper 或 timer 已经注册完成之后,才推进逻辑时间。 这就是 awaitPendingSleepers(...) 存在的意义。
ManualTimeSource.advance(...) 还会同步触发 timer 回调,所以一次时间推进就可能同时释放 sleeper、触发持久 timer,并让整张 graph 在没有任何墙钟等待的情况下继续跑完。
当前最适合用确定性时间验证的场景
这套测试夹具尤其适合下面这些时间相关行为:
- 延迟 operator 和 retry backoff
- 普通 graph 中的 loop / wait timer
- durable lease 过期与定时恢复基础设施
- 执行器显式接入 timer service 时,状态机里的 timer 驱动路径
但也有一个值得明确写出来的现实约束:当前 session 空闲超时路径仍然使用它自己的调度运行机制,而不是直接挂在 ManualTimeSource 上。因此,确定性单元测试目前最适合 graph 与状态机级 timer;session 空闲超时依然应该测试,但更适合用聚焦 smoke test 和策略级断言,而不是假设它已经拥有完整的逻辑时钟控制。
限定租约围栏测试范围 —— @WithExecutionLeaseFencing
当两个引擎共享一个持久化 store 时,同一次执行的执行租约应该只被其中一个 持有。如果租约过期、第二个引擎接手了,原来的引擎就不能再被允许提交陈旧的 checkpoint。运行时通过给每次 store 写入加 fencing token 来强制这一点, 但测试需要一种方式来证明这套强制是正确接上的。
@WithExecutionLeaseFencing 要与 bloge-test 的
ExecutionLeaseFencingExtension 配合使用。它只负责一件聚焦的事:
- 在当前测试范围内临时修改默认 fencing policy;
- 可选择在缺少 ambient lease context 时启用严格拒绝;
- 测试结束后恢复此前的默认值。
它不会创建 engine、注入 store、让 lease 过期,也不会自动断言每次写入。 这些准备与断言仍由测试本身负责。
@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));
}
}
这个精简示例里的 helper 只创建 execution record。真正的 takeover 测试还要:
第一次 claim、release、由第二个 owner 再 claim,然后在第一次 claim 的
ExecutionLeaseContext 中尝试旧写入并断言被拒绝。证明旧 owner 被围栏挡住的
是这段时序,而不是 annotation 本身。
快照测试
只断言某一个最终输出值,你会丢掉整次执行的形状。快照测试把每一次节点
生命周期事件都捕获成一个稳定的 GraphExecutionSnapshot,然后整体断言。这
是写"这张图还和以前一样跑"这种回归测试的正确工具。
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 用 JSON baseline 比较已捕获的 snapshot:
assertMatchesBaseline(...)对 completed/skipped 节点使用集合语义,避免合法并行完成顺序造成假失败。assertMatchesBaselineStrict(...)还要求 completed 节点顺序一致。assertEquals(...)与assertEqualsStrict(...)比较两个内存 snapshot。updateBaseline(...)写出可审阅 baseline;它应由人明确触发,而不是测试自动刷新。
只在整段可观察执行形状确实是稳定合同时使用 snapshot。对窄规则和边界用例, 单点断言通常更容易说明到底哪里错了。