图数据测试策略与回归验证

图数据的测试难点不在语法而在结果:一条 Cypher 改动可能静默改变结果集,一次算法参数调整可能让社区划分整体漂移。本文给出四层测试体系(查询断言、数据质量、算法回归、性能基准)、固定 fixture 与图快照的构造方法、结果集与执行计划的双重校验、GDS 算法输出的稳定性判定、模式迁移的回归清单,以及如何把这些接进 CI。

引言

给图数据写测试,最难的部分不是「怎么连数据库」,而是**「什么算正确」**。关系型库的测试有明确锚点: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

排序是快照可复现的关键:图遍历的返回顺序不保证稳定,不排序的话校验和每次都不一样。导出后比对校验和,就能检测出「意外修改了数据」这类问题。

方式规模可复现性用途
微型 fixture10~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 性能基准单独按日跑,别让它拖慢开发循环。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「graphdb」更多文章

  1. 查询缓存与物化视图
  2. 图数据库并发控制与批量更新
  3. Cypher 反模式与性能陷阱