Series overview
Part 8 of 1080% complete
2026-09-24•4 min read

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

InvariantHow it’s checked
Semantic XML parsesDocumentBuilderFactory with namespaces
Every node has a shapevalidateDiagram + direct map lookup
Every flow has an edgeid present in layout.edges with ≥2 waypoints
DI refs resolveevery bpmnElement exists in the semantic id set
Node inside laneBounds.contains
Lane inside poolBounds.contains on participant bounds
Boundary touches hostBounds.intersects
Endpoints obey pool boundariesvalidator MSG_FLOW_SAME_POOL / flow scope rules
Deterministic outputtwo generations → assertEquals on bytes
Viewer-compatiblebpmn-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:

src/test/kotlin/.../BpmnDslTest.kt
@Test
fun `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:

src/test/kotlin/.../BpmnDslTest.kt
@Test
fun `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

src/test/kotlin/.../BpmnDslTest.kt
@Test
fun `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:

src/test/kotlin/.../BpmnDslTest.kt (excerpt)
@Test
fun `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:

build.gradle.kts (test task)
tasks.register<JavaExec>("renderVerify") {
classpath = sourceSets.main.get().runtimeClasspath
mainClass.set("in.o612.eng.bpmn.MainKt")
}

and, in Node.js where bpmn-js lives:

scripts/render-verify.mjs
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 render

For this series, the verification I ran:

  • bpmn-moddle.fromXML on the generated file: 0 warnings
  • bpmn-js NavigatedViewer importXML in 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:

src/test/kotlin/.../GoldenTest.kt (sketch)
@Test
fun `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.

KotlinBPMN

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind