用 PostgreSQL 保存点测试场景树:在 COMMIT 前验证每一条业务分支

2026-08-25 30 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

数据库测试经常重复构造相同的状态。一个订单先创建、再支付,之后可能进入发货或退款;传统测试通常会为两个场景分别重建“已创建 → 已支付”的前缀。场景树测试提供了另一种组织方式:把业务分支写成目录树,用 PostgreSQL 的 SAVEPOINT 沿树遍历,让子场景继承父场景的状态,并在分支结束后回滚到分叉点。

这样,每条场景都运行在它真正依赖的历史上。共享前缀只执行一次,兄弟分支彼此隔离;更进一步,整个迁移和测试遍历可以放在一个尚未提交的事务中,只有所有已编写的场景都通过时才执行 COMMIT

把业务生命周期写成场景树

以订单生命周期为例,可以把测试目录组织成下面的结构:

orders/
├── placed/
│   └── paid/
│       ├── shipped/
│       └── refunded/

树中的每个目录代表一个可验证的状态,父目录代表共享历史。paid 只需要创建一次,shippedrefunded 都从同一个已支付订单开始。

这棵树不需要穷举所有理论上可达的历史。真实业务的状态组合通常呈指数增长,测试作者应当选择关键路径,例如支付后发货、支付后退款、重复退款、库存不足或支付超时。场景树表达的是“我们明确要证明的路径”,而不是形式化验证整个状态空间。

这里的“证明”也有明确边界:它表示实际执行场景,并检查声明的业务不变量,例如状态是否正确、退款金额是否不超过支付金额、发货前是否必须完成支付。

用 SAVEPOINT 复用共享历史

SAVEPOINT 允许在一个事务内部创建回滚点。回滚到某个保存点时,保存点之后的修改会被撤销,但此前的状态仍然保留。因此,它天然适合遍历场景树:进入一个节点时创建保存点,执行并检查分支,返回父节点后再测试兄弟节点。

下面的示例使用临时表模拟订单生命周期。它可以直接在 PostgreSQL 客户端中运行,也可以改造成迁移验证脚本。示例中的 ASSERT 检查代表项目自己的业务断言。

BEGIN;

CREATE TEMP TABLE test_orders (
    id      integer PRIMARY KEY,
    status  text NOT NULL CHECK (status IN ('placed', 'paid', 'shipped', 'refunded')),
    amount  numeric(12, 2) NOT NULL
) ON COMMIT DROP;

-- 共享前缀:placed
INSERT INTO test_orders (id, status, amount)
VALUES (1001, 'placed', 49.90);

DO $$
BEGIN
    IF (SELECT status FROM test_orders WHERE id = 1001) <> 'placed' THEN
        RAISE EXCEPTION 'placed scenario failed';
    END IF;
END
$$;

-- 进入 paid 节点。两个子分支都会继承这个状态。
UPDATE test_orders SET status = 'paid' WHERE id = 1001;

DO $$
BEGIN
    IF (SELECT status FROM test_orders WHERE id = 1001) <> 'paid' THEN
        RAISE EXCEPTION 'paid scenario failed';
    END IF;
END
$$;

SAVEPOINT after_paid;

-- 分支一:paid -> shipped
UPDATE test_orders SET status = 'shipped' WHERE id = 1001;

DO $$
BEGIN
    IF (SELECT status FROM test_orders WHERE id = 1001) <> 'shipped' THEN
        RAISE EXCEPTION 'shipped scenario failed';
    END IF;
END
$$;

-- 回到分叉点,撤销 shipped 分支的修改。
ROLLBACK TO SAVEPOINT after_paid;

DO $$
BEGIN
    IF (SELECT status FROM test_orders WHERE id = 1001) <> 'paid' THEN
        RAISE EXCEPTION 'rollback did not restore paid state';
    END IF;
END
$$;

-- 分支二:paid -> refunded
UPDATE test_orders SET status = 'refunded' WHERE id = 1001;

DO $$
BEGIN
    IF (SELECT status FROM test_orders WHERE id = 1001) <> 'refunded' THEN
        RAISE EXCEPTION 'refunded scenario failed';
    END IF;
END
$$;

-- 所有已编写场景通过后,测试状态被丢弃,事务才提交。
ROLLBACK;
COMMIT;

这个最小例子中,shipped 分支的修改不会泄漏到 refunded 分支。ROLLBACK TO SAVEPOINT after_paid 只撤销分叉点之后的状态,因此退款分支看到的是准确的 paid 父状态,而不是重新构造出来的近似状态。

实际项目可以把每个目录映射为一个测试函数或 SQL 文件,例如:

scenario-tree/
├── placed.sql
├── paid.sql
└── paid/
    ├── shipped.sql
    └── refunded.sql

测试运行器负责进入目录、执行节点脚本、创建保存点,并在返回父目录时执行 ROLLBACK TO SAVEPOINT。目录只是表达业务结构的载体,保存点则负责把这种结构落实为数据库中的状态继承和隔离。

让迁移在验证后才提交

场景树测试最有价值的使用位置之一,是部署迁移时的同一事务。可以把迁移、测试树和提交动作放入一个 SQL 文件:

BEGIN;

-- 这里放本次迁移,例如:
-- ALTER TABLE orders ADD COLUMN paid_at timestamptz;

-- 这里调用或展开场景树测试。
-- 任意一个 DO 块抛出异常,psql 都会停止并回滚事务。

DO $$
BEGIN
    -- 替换为项目的真实场景检查。
    IF false THEN
        RAISE EXCEPTION 'authored scenario failed';
    END IF;
END
$$;

COMMIT;

使用 psql 时,应开启错误即停:

psql "$DATABASE_URL" \
  --set ON_ERROR_STOP=1 \
  --single-transaction \
  --file migration_and_scenarios.sql

ON_ERROR_STOP=1 确保 SQL 错误不会被忽略,--single-transaction 让脚本在一个事务中执行。项目也可以显式管理 BEGINCOMMIT,但不要同时设计出互相矛盾的事务边界。

这套流程适合事务性 PostgreSQL DDL,例如普通的 CREATE TABLEALTER TABLE 和索引变更。不过并非所有数据库操作都能放进事务。某些扩展操作、CREATE INDEX CONCURRENTLY 等命令有特殊事务要求,部署脚本必须单独处理。长事务还可能持有锁、增加表膨胀或影响在线流量,因此不应把生产验证事务无限期地保持打开。

实施时需要明确的边界

场景树不是自动生成的全量测试器。它只能覆盖作者写入树中的路径,所以目录评审、路径选择和业务不变量仍然重要。建议每个节点同时描述三件事:进入该状态所需的前置历史、执行的业务动作,以及必须成立的结果。

还要避免把所有测试都塞进一个巨大事务。共享历史适合复用,但过大的树会让失败定位变慢,也会扩大锁和资源占用。可以按生命周期、租户或业务能力拆分测试树,并为每个场景使用稳定、独立的测试数据。

一份实用的采用清单如下:

  • 先找出测试中反复出现的状态前缀,例如“已支付订单”或“已审批工作流”。
  • 用目录或等价的数据结构表达父子状态和兄弟分支。
  • 在每个分叉点创建保存点,分支结束后回滚到该保存点。
  • 为每个节点写显式不变量检查,而不是只检查 SQL 没有报错。
  • 在部署事务中运行关键场景,并使用错误即停配置。
  • 盘点迁移命令是否支持事务,评估锁、执行时间和回滚成本。

场景树的核心收益不是减少几行测试代码,而是让测试状态的来源变得清晰:每条分支都继承一段明确的共享历史,测试结果也更接近真实业务流程。对于关键生命周期,先在未提交事务中走完作者选择的路径,再决定是否提交,是一种直接而有约束力的部署验证方式。


相关推荐