用单元测试守住 PlantUML 图不腐烂

puml.online

PlantUML 写在 .puml 文件里,跟代码一样会腐烂——语法过期、布局跑偏、架构改了图没改。这篇是三种测试方法把图变成 CI 流水线的一部分。

为什么图也需要测试

传统观念:图是文档,文档不需要测试。

  • 三个月前画的时序图,接口签名改了图没改 → 评审者按图对接 → 集成失败
  • 自动布局换了主题,组件重叠看不清 → 文档可用性下降
  • 架构从 monolith 拆成微服务,但代码里那张组件图还是老样子 → 新人是按图理解系统的

把图当代码:改架构时,测试 fail → 提醒改图。

测试 1:语法测试(每个 PR 都要过)

目标:确保 .puml 在 CI 里能渲染成功。语法错、include 找不到、图标路径失效都 fail。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# tests/test_plantuml_syntax.py
import subprocess
import pathlib
import pytest

PUML_FILES = list(pathlib.Path("docs/diagrams").rglob("*.puml"))

@pytest.mark.parametrize("puml", PUML_FILES)
def test_puml_renders(tmp_path, puml):
out = tmp_path / (puml.stem + ".svg")
result = subprocess.run(
["docker", "run", "--rm", "-v", f"{puml.parent}:/data",
"plantuml/plantuml", f"/data/{puml.name}", "-tsvg", "-o", str(tmp_path)],
capture_output=True, text=True)
assert result.returncode == 0, f"{puml} failed: {result.stderr}"
assert out.exists(), f"{puml} did not produce SVG"

Go 版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// diagrams_test.go
package diagrams

import (
"os/exec"
"path/filepath"
"testing"
)

func TestPumlRenders(t *testing.T) {
files, _ := filepath.Glob("docs/diagrams/*.puml")
for _, f := range files {
cmd := exec.Command("docker", "run", "--rm",
"-v", "$(pwd)/docs/diagrams:/data",
"plantuml/plantuml", "/data/"+filepath.Base(f), "-tsvg")
if out, err := cmd.CombinedOutput(); err != nil {
t.Errorf("%s: %v\n%s", f, err, out)
}
}
}

好处:

  • !include 路径错 → 立即发现
  • plantuml 版本不兼容 → 整个 CI 挂
  • 团队新人 commit 了语法错的图 → PR 拒收

测试 2:视觉回归测试(布局变化报警)

目标:布局变了(组件换位置、连线变长)→ 截图 diff 报警。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# tests/test_plantuml_visual.py
from PIL import Image, ImageChops
import pathlib
import subprocess

THRESHOLD = 0.05 # 5% pixel diff

def render_puml(puml_path: pathlib.Path) -> pathlib.Path:
out = puml_path.with_suffix(".png")
subprocess.run([
"docker", "run", "--rm", "-v", f"{puml_path.parent}:/data",
"plantuml/plantuml", f"/data/{puml_path.name}",
"-tpng", "-o", str(puml_path.parent)
], check=True)
return out

def test_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)
if not 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(1 for px in diff.getdata() if any(c > 5 for c in px))
total = new.stat().st_size
ratio = pixels / (new.size[0] * new.size[1])
assert ratio < THRESHOLD, f"{puml.name}: {ratio*100:.1f}% pixels changed"

baseline 文件 进 git,CI 每次跑对比。超过 5% 像素变化就 fail,强制开发者审视:是布局真的改了(那更新 baseline),还是主题/border 跑偏了(那修 puml)。

测试 3:架构漂移测试(图跟代码同源)

目标:图里出现的组件/接口必须真实存在;代码里有的组件必须在图里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# tests/test_architecture_drift.py
import re
import pathlib

PUML_TEXT = pathlib.Path("docs/diagrams/architecture.puml").read_text()

def test_all_services_in_diagram_exist_in_code():
"""图里出现的 service 必须在代码里有对应目录"""
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
assert not missing, f"图里有但代码里没有: {missing}"

def test_all_services_in_code_are_in_diagram():
"""代码里有的 service 必须在图里出现"""
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
assert not orphans, f"代码里有但图里漏了: {orphans}"

架构演化:

  • 加新 service → 第二个测试 fail → 提醒加进图
  • 删老 service → 第一个测试 fail → 提醒从图删
  • 架构文档永远不会过时

ADR(架构决策记录)里强制带图

Michael Nygard 的 ADR 模板建议每个 ADR 包含一段”影响”的图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# ADR-007: 用 Redis 做 session store

## 状态
已接受

## 背景
PHP-FPM 的 session 存本地文件,多机部署时 session 不一致。

## 决策
引入 Redis 共享 session。

## 影响

![session-store](diagrams/adr-007.svg)

```puml
@startuml
component "Web 1" as w1
component "Web 2" as w2
database "Redis" as r
w1 --> r : session
w2 --> r : session
@enduml

结果

  • 多机 session 一致
  • 引入 Redis 运维成本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

`adr-007.md` 和 `adr-007.svg` **一起进 git**。CI 测试时,ADR 里的 puml 代码块也要跑语法测试。

## 在 CI 里整合

```yaml
# .github/workflows/diagrams.yml
name: diagrams
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: syntax test
run: pytest tests/test_plantuml_syntax.py -v
- name: visual regression
run: pytest tests/test_plantuml_visual.py -v
- name: architecture drift
run: pytest tests/test_architecture_drift.py -v
- name: upload visual diffs
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diffs
path: tests/diffs/

评审者 看 PR 时,GitHub Actions 的 status check 会显示:

1
2
✓ syntax test (47 files passed)
✗ visual regression: architecture.puml 12.3% pixels changed

12% 变化 → 打开 artifact 看 diff 图 → 决定接受还是修。

投入产出

测试 实现成本 维护成本 防的坑
语法测试 30 分钟 低(plantuml 升级才动) include 错、版本不兼容
视觉回归 2 小时 中(每次故意改布局要更新 baseline) 主题漂移、自动布局跑偏
架构漂移 半天 中(图文同步靠开发者) 文档过时、新人按错图集成

最小可行版本:先做语法测试,30 分钟搞定,挡住 80% 的坑。其他两个看团队规模再加。

  • 标题: 用单元测试守住 PlantUML 图不腐烂
  • 作者: puml.online
  • 创建于 : 2026-07-30 16:35:00
  • 更新于 : 2026-08-14 21:34:29
  • 链接: https://puml.online/blog/plantuml-diagram-as-test/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。