PlantUML lives in .puml files and rots like code does — broken syntax, layout drift, diagrams out of sync with the architecture. This is the three-test approach that makes diagrams a first-class CI citizen.
Why diagrams need tests
Conventional wisdom says “docs don’t need tests.” Wrong.
A sequence diagram drawn three months ago: the signature changed but the diagram didn’t → reviewers integrate against the diagram → integration fails
The architecture was split from monolith to microservices, but the component diagram still shows the monolith → new hires understand the system from a wrong picture
Treat diagrams like code: change the architecture, the test fails, the diagram update gets forced.
Test 1: Syntax test (every PR must pass)
Goal: every .puml renders successfully in CI. Syntax errors, broken !include, missing icons → fail.
deftest_visual_no_regression(): for puml in pathlib.Path("docs/diagrams").rglob("*.puml"): baseline = pathlib.Path("tests/baselines") / puml.with_suffix(".png").name new = render_puml(puml) ifnot baseline.exists(): baseline.parent.mkdir(parents=True, exist_ok=True) new.rename(baseline) continue diff = ImageChops.difference(Image.open(baseline), Image.open(new)) bbox = diff.getbbox() if bbox: pixels = sum(1for px in diff.getdata() ifany(c > 5for c in px)) ratio = pixels / (new.size[0] * new.size[1]) assert ratio < THRESHOLD, f"{puml.name}: {ratio*100:.1f}% pixels changed"
Baseline files go into git. Every CI run diffs against them. Over 5% pixel change → fail, forcing the developer to decide: was the layout intentionally changed (update the baseline) or did the theme/border drift (fix the puml).
Test 3: Architecture drift test (diagram vs code in sync)
Goal: every component/interface in the diagram must exist in code; every component in code must appear in the diagram.
deftest_all_services_in_diagram_exist_in_code(): """Components in diagram must have matching directories in code""" services_in_diagram = set(re.findall(r"component\s+\"?(\w+)\"?", PUML_TEXT)) actual_services = { p.name for p in pathlib.Path("services").iterdir() if p.is_dir() } missing = services_in_diagram - actual_services assertnot missing, f"In diagram but missing in code: {missing}"
deftest_all_services_in_code_are_in_diagram(): """Code services must appear in the diagram""" services_in_diagram = set(re.findall(r"component\s+\"?(\w+)\"?", PUML_TEXT)) actual_services = { p.name for p in pathlib.Path("services").iterdir() if p.is_dir() } orphans = actual_services - services_in_diagram assertnot orphans, f"In code but missing in diagram: {orphans}"
How architecture evolution triggers tests:
Add new service → second test fails → reminder to add it to the diagram
Delete old service → first test fails → reminder to remove from diagram