Part 8: Testing BPMN semantics, DI, and visual output
“Generated XML that parses” is a low bar — an XML file that parses and produces a blank canvas has passed it. The real contract is stronger: every semantic node has a BPMNShape, every flow has a BPMNEdge, every reference resolves, every container contains. This part turns that contract into tests, then verifies the output against the reference implementation of the BPMN spec’s own tooling.
The test matrix
| Invariant | How it’s checked |
|---|---|
| Semantic XML parses | DocumentBuilderFactory with namespaces |
| Every node has a shape | validateDiagram + direct map lookup |
| Every flow has an edge | id present in layout.edges with ≥2 waypoints |
| DI refs resolve | every bpmnElement exists in the semantic id set |
| Node inside lane | Bounds.contains |
| Lane inside pool | Bounds.contains on participant bounds |
| Boundary touches host | Bounds.intersects |
| Endpoints obey pool boundaries | validator MSG_FLOW_SAME_POOL / flow scope rules |
| Deterministic output | two generations → assertEquals on bytes |
| Viewer-compatible | bpmn-moddle parse + bpmn-js import |
The happy-path test is the hardest to write first
The full example is the best fixture precisely because it’s the hardest thing the pipeline produces — 68 flow elements, 2 processes, 7 message flows, 3 boundary events:
@Testfun `procurement collaboration validates clean`() { val c = enterpriseProcurementCollaboration() val diags = validator.validate(c) assertEquals(emptyList(), diags.filter { it.severity == Severity.ERROR }, "unexpected errors: $diags") assertEquals(emptyList(), diags.filter { it.severity == Severity.WARNING }, "unexpected warnings: $diags")}Zero warnings is the meaningful assertion — a “passes validation with warnings” gate silently accumulates debt.
The DI-invariant test
The expensive property isn’t that XML parses; it’s that every semantic element has corresponding DI geometry. The test walks the DOM for all ids, then walks the DI section checking every bpmnElement reference:
@Testfun `generated xml parses and di references resolve`() { val c = enterpriseProcurementCollaboration() val layout = LayeredLayoutEngine().layout(c) val xml = BpmnXmlWriter(FlowableExtensionSerializer).writeToString(c, layout)
val doc = DocumentBuilderFactory.newInstance() .apply { isNamespaceAware = true } .newDocumentBuilder() .parse(InputSource(StringReader(xml)))
// collect every semantic id val ids = mutableSetOf<String>() val all = doc.getElementsByTagName("*") for (i in 0 until all.length) { all.item(i).attributes?.getNamedItem("id")?.nodeValue?.let(ids::add) }
// every DI element references a real semantic element val shapes = doc.getElementsByTagNameNS(BpmnNs.BPMNDI, "BPMNShape") val edges = doc.getElementsByTagNameNS(BpmnNs.BPMNDI, "BPMNEdge") for (i in 0 until shapes.length + edges.length) { val el = if (i < shapes.length) shapes.item(i) else edges.item(i - shapes.length) val ref = el.attributes.getNamedItem("bpmnElement").nodeValue assertTrue(ref in ids, "BPMN DI element references missing semantic id $ref") }
// every visible node has a shape; every edge endpoint has a route for (n in c.processes.flatMap { it.allNodes() }) { assertTrue(n.id in layout.shapes, "no shape for ${n.id}") } for (f in c.processes.flatMap { it.allSequenceFlows() } + c.messageFlows.map { SequenceFlow(it.id, it.name, it.sourceRef, it.targetRef) }) { assertTrue(f.id in layout.edges, "no edge for ${f.id}") }}The dual direction matters: DI → semantic (every shape’s bpmnElement resolves) and semantic → DI (every node has a shape). Catching only one direction lets a node exist in XML but not in the diagram, or a shape exist for nothing.
Determinism
@Testfun `generation is deterministic`() { val a = BpmnXmlWriter(FlowableExtensionSerializer) .writeToString(enterpriseProcurementCollaboration(), layout(enterpriseProcurementCollaboration())) val b = BpmnXmlWriter(FlowableExtensionSerializer) .writeToString(enterpriseProcurementCollaboration(), layout(enterpriseProcurementCollaboration())) assertEquals(a, b)}Byte-identical output from two runs of the same input is a real property — it means a code reviewer’s diff of generated BPMN is a diff of intent, not of iteration order. It’s only true because LinkedHashMap and declaration-order iteration run end to end; one HashMap in the chain and this test fails.
Deliberately breaking things
Testing the validator means writing models that should fail — this is where the negative tests pay for themselves:
@Testfun `diagram validation catches a missing shape`() { val c = enterpriseProcurementCollaboration() val layout = LayeredLayoutEngine().layout(c) val broken = layout.copy(shapes = layout.shapes - "sendRfq") // drop one shape val diags = validator.validateDiagram(c, broken) assertTrue(diags.any { it.code == DiagnosticCodes.DI_MISSING_SHAPE && it.elementId == "sendRfq" })}The full suite has the same shape for each rule: build a model with exactly one defect, assert the defect’s code appears and nothing else spuriously fails. Thirteen tests total; the four shown here (clean validation, DI refs resolve, deterministic, DI missing-shape detected) cover the contract’s hardest surfaces.
Rendering verification — not just structure
XML that parses and satisfies DI invariants can still render as garbage. The closing step runs the output through the actual viewer technology:
tasks.register<JavaExec>("renderVerify") { classpath = sourceSets.main.get().runtimeClasspath mainClass.set("in.o612.eng.bpmn.MainKt")}and, in Node.js where bpmn-js lives:
import { BpmnModdle } from 'bpmn-moddle';import fs from 'node:fs';
const xml = fs.readFileSync('build/procurement.bpmn', 'utf8');const { rootElement, warnings } = await new BpmnModdle().fromXML(xml);if (warnings?.length) throw new Error(`moddle warnings: ${warnings.length}`);// then load into bpmn-js in a headless browser for an actual SVG renderFor this series, the verification I ran:
bpmn-moddle.fromXMLon the generated file: 0 warningsbpmn-jsNavigatedViewerimportXMLin headless Chromium (Playwright): 0 warnings, SVG produced- Screenshot inspection: both pools render, all lanes labeled, all nodes visible, boundary events on their hosts, message flows dashed and crossing the pool gap
That is the honest level of the guarantee: it renders and it’s structurally sound — not pixel-perfect across tools, and Part 10 lists where a real product would push next (ELK for crossing minimization, golden SVG snapshots for layout regression).
Golden-file testing for layout
A layout engine that changes coordinates silently is a regression generator. The cheap defense is a golden test:
@Testfun `layout is stable`() { val layout = LayeredLayoutEngine().layout(enterpriseProcurementCollaboration()) val actual = layout.shapes.entries.sortedBy { it.key } .joinToString("\n") { (k, b) -> "$k ${b.x},${b.y} ${b.width}x${b.height}" } val golden = File("src/test/resources/procurement.layout.txt").readText() assertEquals(golden.trim(), actual)}Every intentional layout change then requires regenerating the golden file — a reviewed diff, not a silent drift.
Common pitfalls
- Only asserting on parse — a file can parse and still have no DI, wrong refs, or overlapping geometry. Parse + resolve + contain.
- Tests that only exercise the happy path — the validator’s value is finding defects; every diagnostic code deserves a negative test.
- Non-determinism treated as cosmetic — non-deterministic generated files destroy reviewability; test byte-equality.
- Skipping the viewer — structure checks can’t see “the diagram renders with everything overlapping”; import into real viewer tooling at least once.
Reference implementation
The full suite — 19 tests, including every negative case from Part 5 — is BpmnDslTest.kt and GoldenTest.kt, with the golden layout at procurement.layout.txt. The moddle + headless bpmn-js verification is scripts/render-verify.mjs. All in pcnixsys/bpmn-dsl-kotlin.
Next
Part 9 isolates engine specifics: how the flowable: namespace gets into the XML through the EngineExtensionSerializer seam, what process variables actually mean at runtime, and a minimal Spring Boot deployment of the buyer process.