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
| 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
| 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
| from PIL import Image, ImageChops import pathlib import subprocess
THRESHOLD = 0.05
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
| 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。
## 影响

```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% 的坑。其他两个看团队规模再加。