引言
给图数据写测试,最难的部分不是「怎么连数据库」,而是**「什么算正确」**。关系型库的测试有明确锚点:SELECT 出来的行集可以逐行比对,schema 约束能直接断言。图数据库没有这些锚点——一条 Cypher 改动可能让结果集静默变化(少了三跳内的节点),一次 GDS 参数调整可能让社区划分整体漂移(虽然每个社区都「看起来合理」),一次模式迁移可能让某条查询从命中索引退化为全表扫描。
更棘手的是图数据的测试无法靠抽样。抽样测试在关系型表上可行,因为行之间独立;但图的结果依赖拓扑——你抽掉的那个节点可能恰好是连接两个分量的桥,抽样直接改变了答案。所以图测试必须建立可复现的固定图(fixture),在确定的拓扑上做断言。
本文给出一个可落地的四层测试体系,从最轻的查询断言到最重的性能基准,每层都回答「测什么、怎么构造数据、断言什么、什么时候跑」。前置阅读:图版本化与差异比对 讲清了快照与 diff 的机制,是回归验证的基础;图数据建模基础 覆盖了约束与索引的语义。通用测试方法横向对照 测试体系与覆盖率 。
1. 四层测试体系
先建立整体框架。四层按「反馈速度」和「覆盖范围」排列,越靠下越慢、越靠上越细:
| 层 | 测什么 | 数据 | 反馈时间 | 何时跑 |
|---|---|---|---|---|
| L1 查询断言 | Cypher 结果正确 | 微型固定图 | 毫秒 | 每次提交 |
| L2 数据质量 | 约束/连通性/基数 | 全量或抽样图 | 秒~分 | 每次提交 + 定时 |
| L3 算法回归 | GDS 输出稳定 | 中等规模图 | 秒~分 | 每次提交 |
| L4 性能基准 | 延迟/吞吐不退化 | 生产规模图 | 分~小时 | 每日/发版前 |
关键纪律:L1 与 L3 必须能进 CI,且总时长控制在几分钟内;L4 单独跑,不要拖慢开发循环。
2. 固定 fixture 与图快照
2.1 微型固定图
L1 的 fixture 应该小到可以人肉推演,同时覆盖关键拓扑:环、桥、二分结构、多跳链、孤立节点。
// 一个覆盖常见拓扑的微型 fixture(约 10 个节点)
CREATE (a:User {id: 'a'}), (b:User {id: 'b'}), (c:User {id: 'c'}),
(d:User {id: 'd'}), (e:User {id: 'e'}), (f:User {id: 'f'}),
(g:User {id: 'g'}) // 孤立节点
CREATE (a)-[:FOLLOWS]->(b)
CREATE (b)-[:FOLLOWS]->(c)
CREATE (c)-[:FOLLOWS]->(a) // 环:a→b→c→a
CREATE (c)-[:FOLLOWS]->(d) // 桥:连接 {a,b,c} 与 {d,e}
CREATE (d)-[:FOLLOWS]->(e)
CREATE (e)-[:FOLLOWS]->(d) // 环:d↔e
CREATE (d)-[:FOLLOWS]->(f); // 叶子
这个 fixture 能同时测:环检测、桥识别、可达性、孤立点处理、多跳计数。每个测试用例前重建 fixture,保证测试之间不互相污染:
import pytest
from neo4j import GraphDatabase
@pytest.fixture
def graph():
driver = GraphDatabase.driver(URI, auth=(USER, PWD))
with driver.session() as s:
s.run("MATCH (n) DETACH DELETE n") # 清空
s.run(open("fixtures/tiny_graph.cypher").read())
yield driver
driver.close()
2.2 图快照:大图的回归基线
L3/L4 用生产规模的图,不可能每次重建。做法是快照 + 校验和:把某个时间点的图导出为规范化的边表,计算校验和,后续测试基于这个快照:
# 导出规范化的边表(排序后,保证可复现)
neo4j-admin database dump neo4j --to-path=/backups/snapshots/
# 或导出为 CSV 用于测试
cypher-shell -u neo4j -p $PWD "
MATCH (a)-[r]->(b)
RETURN a.id AS from, type(r) AS rel, b.id AS to
ORDER BY from, rel, to
" --format=plain > snapshot_edges.csv
# 计算校验和,作为基线
shasum -a 256 snapshot_edges.csv
排序是快照可复现的关键:图遍历的返回顺序不保证稳定,不排序的话校验和每次都不一样。导出后比对校验和,就能检测出「意外修改了数据」这类问题。
| 方式 | 规模 | 可复现性 | 用途 |
|---|---|---|---|
| 微型 fixture | 10~100 节点 | 完全 | L1 查询断言 |
| 构造生成图 | 10⁴~10⁶ | 完全(种子固定) | L3 算法回归 |
| 生产快照 | 10⁶+ | 依赖导出排序 | L4 性能基准 |
| 匿名化快照 | 10⁶+ | 依赖脱敏规则 | L2 数据质量 |
2.3 生成图的种子固定
用生成器造中等规模图时,随机种子必须写死,否则每次跑出来的图不同,回归就失去意义:
import random
def gen_graph(n, m, seed=42):
rng = random.Random(seed) # 固定种子
edges = set()
while len(edges) < m:
u, v = rng.randrange(n), rng.randrange(n)
if u != v:
edges.add((min(u, v), max(u, v)))
return sorted(edges)
推荐用 Barabási–Albert 或 Watts–Strogatz 这类有已知性质的模型生成图,这样断言的期望值有理论依据(例如 BA 图的度分布服从幂律),而不是靠「上次跑出来是多少」。
3. 查询断言:结果集与计划双重校验
3.1 结果集断言
最基础的断言:给定 fixture,查询结果必须精确等于期望集合。
def test_three_hop_reachable(graph):
with graph.session() as s:
result = s.run("""
MATCH (a:User {id: 'a'})-[:FOLLOWS*1..3]->(b:User)
RETURN DISTINCT b.id AS id ORDER BY id
""").data()
assert [r["id"] for r in result] == ["a", "b", "c", "d", "e"]
# 注意:a 出现在结果里是因为 a→b→c→a 构成环,三跳能回到自己
排序必须写进查询(ORDER BY),不能依赖返回顺序。断言用精确集合而不是「包含」,因为「多了」和「少了」都是 bug。
3.2 计划断言:防止性能静默退化
结果正确但性能退化的改动是最危险的。把执行计划的关键特征也纳入断言:
def test_query_uses_index(graph):
with graph.session() as s:
plan = s.run("""
EXPLAIN MATCH (u:User {id: $id}) RETURN u
""", id="a").consume().plan
ops = _collect_operators(plan)
assert "NodeIndexSeek" in ops, f"索引未命中,计划为 {ops}"
assert "NodeByLabelScan" not in ops, "退化为全标签扫描"
def _collect_operators(plan):
ops = set()
def walk(node):
ops.add(node.operator_type)
for child in node.children:
walk(child)
walk(plan)
return ops
这类断言能挡住「给属性加了函数包裹」「索引被误删」这类改动的静默退化。每个关键查询配一个计划断言,成本很低,收益极高。
3.3 断言的三类陷阱
陷阱一:依赖返回顺序
- 错:assert result[0]["id"] == "a"
- 对:查询里 ORDER BY,断言整个有序列表
陷阱二:依赖浮点精确相等
- 错:assert score == 0.3333333333
- 对:assert abs(score - 1/3) < 1e-9
陷阱三:依赖算法输出的确定性
- 错:assert community_id == 7(社区编号无意义,每次可能不同)
- 对:断言划分的「结构性质」(如社区数、模块度、成员分组关系)
第三类最隐蔽:GDS 的社区编号是内部标识,不保证跨运行一致。断言必须针对结构(哪些节点在同一社区),而不是编号本身。
4. 数据质量测试
4.1 约束与完整性
数据质量测试针对全量数据跑,用断言查询发现「不该存在的数据」:
// 断言 1:唯一键无重复(约束存在时理论上为 0,但迁移期可能有漏网)
MATCH (u:User)
WITH u.id AS id, count(*) AS c WHERE c > 1
RETURN count(*) AS dup_ids; // 期望 0
// 断言 2:必填属性无缺失
MATCH (u:User) WHERE u.email IS NULL OR u.id IS NULL
RETURN count(u) AS missing_required; // 期望 0
// 断言 3:无悬空关系(两端节点必须存在且标签正确)
MATCH ()-[r:FOLLOWS]->()
WHERE NOT (startNode(r):User) OR NOT (endNode(r):User)
RETURN count(r) AS bad_rels; // 期望 0
// 断言 4:属性类型一致(同一个键不能一会儿是字符串一会儿是数字)
MATCH (u:User) WHERE NOT u.age IS :: INTEGER
RETURN count(u) AS wrong_type; // 期望 0
把这些查询固化成一份 data_quality.cypher,每次数据同步后跑一遍,输出非 0 就告警。这是最有性价比的一层测试——它不需要构造 fixture,直接扫全量,能抓住绝大多数上游数据问题。
4.2 拓扑质量断言
图特有的质量维度是拓扑结构:
// 断言:核心业务图不应出现孤立点
MATCH (u:User)
WHERE NOT (u)--()
RETURN count(u) AS isolated; // 视业务而定,可能期望 0
// 断言:图的连通分量数不应突增(突增说明上游数据断裂)
CALL gds.wcc.stream('userGraph')
YIELD componentId
RETURN count(DISTINCT componentId) AS components;
连通分量数是极佳的「数据断裂哨兵」。正常情况下它应该稳定在一个范围;某天突然从 3 个变成 5000 个,说明上游同步出了问题(关系没导进来)。把「分量数变化幅度」设为告警阈值,比任何字段级校验都灵敏。
4.3 质量指标的时间序列
单次断言只能发现「当下有问题」,趋势监控才能发现「正在变坏」:
每日采集并入库的质量指标:
- 节点数 / 关系数(按标签、类型分组)
- 连通分量数
- 平均度数、最大度数(度数突增 = 出现超级节点)
- 属性非空率(每个必填属性)
- 重复键比例
- 关系两端标签的分布
告警规则:
- 任一指标环比变化超过 20% → 告警
- 连通分量数增加超过 5 倍 → 告警
- 最大度数超过历史 p99 的 2 倍 → 告警
5. 算法结果回归
5.1 算法测试的特殊性
GDS 算法(社区检测、PageRank、最短路)的输出有两个性质让测试变难:
- 非确定性:Louvain、标签传播等算法依赖遍历顺序,结果可能有微小差异。
- 参数敏感:分辨率(resolution)改一点,社区划分就整体变化。
因此算法回归的断言必须是结构性的,而不是逐值比对。
def test_louvain_stable_partition(graph):
with graph.session() as s:
s.run("CALL gds.graph.project('t', 'User', 'FOLLOWS')")
rows = s.run("""
CALL gds.louvain.stream('t', {maxIterations: 10, randomSeed: 42})
YIELD nodeId, communityId
RETURN gds.util.asNode(nodeId).id AS id, communityId
""").data()
s.run("CALL gds.graph.drop('t')")
# 断言:结构性质而非编号
groups = {}
for r in rows:
groups.setdefault(r["communityId"], set()).add(r["id"])
partition = sorted(sorted(g) for g in groups.values())
assert len(partition) == 2 # 期望两个社区
assert partition == [["a", "b", "c"], ["d", "e", "f", "g"]]
randomSeed 是算法可复现的前提。GDS 里几乎所有随机算法都接受 randomSeed 参数,测试里必须显式传入,否则结果每次都可能不同。
5.2 断言的三个层次
| 层次 | 断言内容 | 稳定性 | 适用 |
|---|---|---|---|
| 强断言 | 精确的节点分组 | 需固定 seed | 小图、CI |
| 中断言 | 社区数、模块度范围 | 较稳定 | 中等图 |
| 弱断言 | 输出规模、无异常值 | 稳定 | 大图、生产 |
小图(fixture)用强断言,中等生成图用中断言(例如「模块度 > 0.4」),生产快照用弱断言(例如「社区数在 100~200 之间」「无孤立社区」)。
5.3 最短路的可验证性
最短路是个例外——它的结果有唯一确定的值(假设权重唯一),可以做强断言:
def test_dijkstra_exact(graph):
with graph.session() as s:
r = s.run("""
MATCH (a:User {id: 'a'}), (e:User {id: 'e'})
MATCH p = shortestPath((a)-[:FOLLOWS*..5]->(e))
RETURN length(p) AS hops
""").single()
assert r["hops"] == 3 # a→b→c→d→e 是 4 跳?重新推演 fixture 确认
写这类断言时必须在纸上(或代码注释里)推演一遍期望值,而不是「跑一次看看是多少然后填进去」——后者会把 bug 固化成「期望」。
6. 模式迁移的回归验证
模式迁移(改属性名、拆标签、翻转关系方向)后的回归验证清单:
1. 结果集不变:迁移前后的关键查询必须返回完全相同的集合
- 对每个关键查询跑「迁移前 vs 迁移后」双跑比对
2. 计划不退化:关键查询的执行计划仍命中索引
- EXPLAIN 断言(见 3.2)
3. 数据零残留:旧结构计数为 0
- MATCH (p:Person) RETURN count(p) // 必须 0
4. 属性非空率不降:迁移后必填属性的非空率不低于迁移前
5. 拓扑指标不漂移:分量数、平均度数、最大度数在容差内
第 1 条的实现是双写比对:迁移期同时保留新旧结构,用一个脚本对每个关键查询在新旧结构上各跑一次,比对结果集:
def test_migration_equivalence(session, queries):
for name, old_q, new_q in queries:
old = set(map(frozenset, session.run(old_q).data()))
new = set(map(frozenset, session.run(new_q).data()))
assert old == new, f"{name} 结果不一致:仅旧有 {old - new},仅新有 {new - old}"
这种「双跑比对」是迁移期最有效的安全网,比任何人工抽查都可靠。差异集(old - new 与 new - old)直接指出问题范围。
7. 性能基准测试
7.1 基准的四个变量
性能基准必须固定四个变量,否则数字不可比:
1. 数据规模:节点数、关系数、度数分布
2. 查询集合:哪些查询、各占多少比例(读/写/混合)
3. 并发度:多少并发客户端
4. 预热:是否预热(page cache、计划缓存)
漏掉任何一个,两次跑出来的数字就没有可比性。特别是预热——冷启动的第一批查询会把 page cache 打满,测出来的延迟是稳态的好几倍。
7.2 指标选择
| 指标 | 含义 | 关注点 |
|---|---|---|
| p50 延迟 | 中位延迟 | 用户体验基线 |
| p99 延迟 | 尾部延迟 | 决定「最慢的用户有多慢」 |
| 吞吐(QPS) | 每秒查询数 | 容量规划依据 |
| dbHits/查询 | 存储访问量 | 与数据规模的关系 |
| 错误率 | 失败请求占比 | 稳定性 |
只看平均延迟会严重误判:平均值被大量快查询拉低,掩盖了尾部问题。图查询的延迟分布通常长尾明显,p99 才是真正的体验指标。
7.3 回归判定
性能回归判定不能只看「慢了 5% 就报警」——噪声会淹没信号。建议:
判定规则:
- 同一版本连续跑 3 次,取中位数作为基线
- 新版本跑 3 次取中位数,与基线比
- 退化超过 20% 且统计显著(用 Mann-Whitney U 检验)→ 判定为回归
- 退化在 5%~20% 之间 → 记录但不阻塞,人工复核
- 波动超过 30% → 说明基准环境不稳定,先修环境
基准的完整方法论(含负载模型设计、数据生成、结果解读)见 图数据库基准与压测 。
8. 接进 CI
8.1 分层执行
# 示例:GitHub Actions 分层跑
jobs:
unit:
steps:
- run: pytest tests/l1_queries -q # 每次提交,< 1 分钟
quality:
steps:
- run: cypher-shell -f tests/l2_quality.cypher # 每次提交
algorithm:
steps:
- run: pytest tests/l3_algorithms -q # 每次提交
benchmark:
if: github.event_name == 'schedule' # 每日/发版前
steps:
- run: python tests/l4_benchmark.py
8.2 用 Testcontainers 起真实图库
L1~L3 需要一个真实的 Neo4j 实例。用 Testcontainers 起临时容器,保证 CI 环境干净:
from testcontainers.neo4j import Neo4jContainer
def test_with_real_db():
with Neo4jContainer("neo4j:5.15") as neo4j:
driver = neo4j.get_driver()
with driver.session() as s:
s.run("CREATE (:User {id: 'a'})")
assert s.run("MATCH (u:User) RETURN count(u) AS c").single()["c"] == 1
不要用内存数据库 mock 图库。图查询的语义(遍历、变长路径、执行计划)与关系型差异太大,mock 出来的行为与真实数据库不一致,测了等于没测。
8.3 测试数据的管理
原则:
1. 每个测试用例独立建/清数据,不依赖执行顺序
2. fixture 用 Cypher 文件管理,与代码一起版本化
3. 大快照不进 Git(放对象存储,用校验和引用)
4. 测试里禁止连生产库(用独立实例 + 只读账号双重保险)
小结
图数据测试的核心难题是「什么算正确」,解法是把正确性拆成四层可断言的性质:结果集、数据质量、算法结构、性能指标。落地时抓住四条:fixture 要小到能人肉推演,同时覆盖环/桥/孤立点;断言必须带 ORDER BY,且对关键查询加执行计划断言(这是防性能静默退化的唯一手段);算法回归只断言结构性质并固定 randomSeed;迁移期用「双跑比对」当安全网,差异集直接指出问题范围。L1~L3 进 CI 控制在几分钟内,L4 性能基准单独按日跑,别让它拖慢开发循环。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。