Prometheus 的 PromQL 测试脚本语言 promqltest:从 load/eval 语法到 expect 断言与迁移工具的完整实战指南
本文围绕 Prometheus 仓库中的 promql/promqltest 测试脚本语言展开:它是一套用纯文本描述「造数据、跑查询、断言结果」的引擎无关测试框架,并附带一套随源码分发的内置 PromQL 测试集。读完本文,你将掌握 .test 脚本的完整语法(load/clear/eval 与 expect 断言)、注解位置与 step 不变性检查等隐含机制,以及如何用官方迁移工具把旧版 eval_fail/eval_warn 语法批量升级为新的 expect 写法。
promqltest 是什么:一个可复用的 PromQL 引擎验收套件
promql/promqltest 包包含两样东西(见 README):
- PromQL 引擎的测试脚本语言实现:把一段纯文本脚本解析为命令序列,在内存测试存储上加载数据、执行即时/范围查询,并逐条断言结果与注解;
- 一套预定义的测试集:位于 promql/promqltest/testdata/ 的 21 个
.test文件(如 aggregators.test、info.test、native_histograms.test 等),覆盖聚合、选择器、直方图、staleness、子查询等主题。
两个对外入口分别是:
promqltest.RunBuiltinTests():把全部内置.test文件当作验收套件跑在任意 PromQL 引擎实现上;promqltest.RunTest():执行你自己写的任意测试脚本。
从源码看,内置测试集通过 //go:embed testdata 直接嵌入编译产物(test.go#L275-L276),因此 RunBuiltinTests 可以脱离具体仓库目录运行。PromQL 引擎自身的测试就是这样调用它的:promql/promql_test.go#L36 中有一行 promqltest.RunBuiltinTests(t, newTestEngine(t))。
包还导出了构造测试引擎的辅助函数 NewTestEngine / NewTestEngineWithOpts(test.go#L99-L125)。默认引擎启用了内置测试文件用到的全部实验特性,见 TestParserOpts(test.go#L91-L97):
var TestParserOpts = parser.Options{
EnableExperimentalFunctions: true,
ExperimentalDurationExpr: true,
EnableExtendedRangeSelectors: true,
EnableBinopFillModifiers: true,
}
RunBuiltinTestsWithStorage 的注释明确要求:被测引擎必须用启用所有实验特性的 ParserOptions 创建,否则测试文件中的实验表达式会解析失败。若你想自定义存储(例如开启 start timestamp 或特定 chunk 编码),可以直接调用 RunBuiltinTestsWithStorage,默认版本 RunBuiltinTests 底层用的就是它,并开启了 EnableSTStorage、FloatChunkEncoding = chunkenc.EncXOR2、EnableHistogramSTEncoding(test.go#L160-L184)。
脚本整体结构:注释、命令与执行顺序
每个测试脚本都是纯文本文件。以 # 开头的行是注释,例如:
# This is a comment.
源码层面,getLines 会先按行切分、去掉首尾空白,并把以 # 开头的整行置空(test.go#L803-L814)。每个测试文件由一组命令按文件出现顺序依次执行,共三种命令:
load:向测试环境追加数据;clear:清空已加载的全部数据;eval:执行查询并断言结果。
parse 函数按行扫描,用命令首词分派到 parseLoad / parseEval,未知命令直接报错(test.go#L816-L844)。
注意(README 原文强调):
eval的旧变体eval_fail、eval_warn、eval_info、eval_ordered已被弃用,应改用新的expect行(见下文 eval 命令);同理,expected_fail_message与expected_fail_regexp也已弃用。
load 命令:用扩展记号造数据
load 向测试环境添加数据,语法如下:
load <interval>
<series> <points>
...
<series> <points>
参数含义:
<interval>:采样点之间的步长,如1m、30s;<series>:常规 Prometheus 时序名,metric{label="value"}形式;<points>:为该时序添加的点,使用与promtool unittest相同的扩展记号,详见 规则单元测试文档的 series 一节。
例如:
load 1m
my_metric{env="prod"} 5 2+3x2 _ stale {{schema:1 sum:3 count:22 buckets:[5 10 7]}}
将创建一条标签为 my_metric{env="prod"} 的时序,产生如下点:
- t=0:值 5;
- t=1m:值 2;
- t=2m:值 5;
- t=3m:值 8;
- t=4m:无点(
_表示缺失样本); - t=5m:stale 标记;
- t=6m:原生直方图,schema 1,sum 3,count 22,桶计数 5、10、7。
扩展记号的核心规则(与 unit_testing_rules.md 一致):a+bxn 展开为 a a+b a+(2*b) … a+(n*b),即从 a 起再有 n 个步长为 b 的样本;a-bxn 为递减;axn 是 a+0xn 的简写,表示 a 重复 n+1 次;_ 表示缺失、stale 表示 stale 样本。原生直方图可用 {{schema:1 sum:3 count:22 buckets:[5 10 7]}} 这类花括号记号,支持全部属性(schema、sum、count、z_bucket、z_bucket_w、buckets、offset、n_buckets、n_offset、counter_reset_hint、custom_values),且与浮点数一样支持 axn、a+bxn 等扩展记号。
源码中 parseLoad 复用解析器的 ParseSeriesDesc 来解析每一行时序定义(test.go#L285-L344);loadCmd.set 把序列展开为带毫秒时间戳的 promql.Sample,起始时间统一锚定在 Unix epoch(testStartTime = time.Unix(0, 0).UTC(),test.go#L72)。
关键点:load 是追加式的,clear 用于重置
每条 load 命令都是追加语义——它不会替换之前 load 的数据。需要重置环境时显式执行 clear,它会关闭当前存储并重新打开一个空的测试存储(t.clear() 实现,test.go#L1870-L1881)。testdata 中大量文件(如 info.test)正是反复用 clear 分隔出独立的测试场景。
load _with_nhcb:经典直方图批量转 NHCB
加载一批经典直方图浮点时序时,可以在 load 后追加 _with_nhcb 后缀,把它们转换成「带自定义桶的原生直方图」(Native Histograms with Custom Buckets,NHCB),并同时加载原始浮点时序与转换后的直方图时序。
从源码看,这条路径由 loadCmd.appendCustomHistogram 实现(test.go#L969-L1030):它按时间戳把 _bucket/_count/_sum 后缀的经典直方图样本汇总成 convertnhcb.TempHistogram,调用 Convert() 生成 schema 为自定义桶(-53)的原生直方图后逐点写入存储。解析侧则由正则 ^load(?:_(with_nhcb))?\s+(.+?)$ 捕获该可选后缀(test.go#L52)。
clear 命令
clear 移除此前所有 load 加载的数据。注意它不是删除样本,而是整体更换测试存储实例,因此 clear 之后所有 load/eval 都基于全新空环境。
eval 命令:Instant 与 Range 两种查询
eval 对测试环境执行查询并断言结果。除非提供了 expect fail 行,否则要求查询成功;旧版 eval 默认期望没有任何 info/warn 注解,而现在必须显式提供 expect no_info 与 expect no_warn 行来表达这一约束。即时查询与范围查询都受支持,语法:
# 即时查询
eval instant at <time> <query>
<expect>
...
<expect>
<series> <points>
...
<series> <points>
# 范围查询
eval range from <start> to <end> step <step> <query>
<expect>
...
<expect>
<series> <points>
...
<series> <points>
参数说明:
<time>:即时查询的评估时间戳,如1m(相对 Unix epoch 的持续时间);<start>/<end>:范围查询的起止时间,语法同<time>;<step>:范围查询步长,语法同<time>,如30s;<expect>(可选):声明期望的注解、错误或结果顺序;expect range vector(可选):即时查询时可声明期望的范围向量时间戳;expect string "<string>"(可选):断言字符串字面量结果;<series>与<points>:期望值,语法同load。
解析实现见 parseEval(test.go#L623-L781),它用正则区分即时/范围两种形态,并把期望行按类型分桶存入 evalCmd.expectedCmds。一个真实用例(摘自 aggregators.test):
load 5m
http_requests{job="api-server", instance="0", group="production"} 0+10x10
http_requests{job="api-server", instance="1", group="production"} 0+20x10
http_requests{job="api-server", instance="0", group="canary"} 0+30x10
http_requests{job="api-server", instance="1", group="canary"} 0+40x10
# Simple sum.
eval instant at 50m SUM BY (group) (http_requests{job="api-server"})
{group="canary"} 700
{group="production"} 300
原生直方图的 counter reset hint 特殊处理
<points> 中的原生直方图可以也可以不带显式 counter_reset_hint 属性:
- 显式提供时(
unknown、reset、not_reset或gauge),测试会校验直方图的 reset hint 恰为该值; - 未指定时,reset hint 完全不参与校验(而不是按默认值
unknown校验)。
源码印证:compareNativeHistogram 接收一个 counterResetHintSet 布尔参数,只有当期望值里显式写了 hint 时才比较 CounterResetHint 字段,注释明确写着「当 counterResetHintSet 为 false 时表示『不关心』」(test.go#L1393-L1443)。同时该函数对 Count/Sum 等浮点字段使用 almost.Equal 做相对误差容忍比较,容忍度由常量 defaultEpsilon = 0.000001 控制(test.go#L61)。
expect string:断言字符串字面量
用于断言查询返回的字符串字面量,仅支持即时查询:
eval instant at 50m ("Foo")
expect string "Foo"
期望字符串必须加引号,支持双引号或反引号。解析侧 parseAsStringLiteral 用 strconv.Unquote 解引号,缺少引号会直接报「a quoted string literal is required」(test.go#L783-L801)。
expect range vector:断言即时查询返回的范围向量
即时查询默认只能返回单点向量;若要断言「返回的是一个范围向量」,需要显式声明其时间戳窗口:
expect range vector <start> to <end> step <step>
完整例子(摘自 README):
load 10s
some_metric{env="a"} 1+1x5
some_metric{env="b"} 2+2x5
eval instant at 1m some_metric[1m]
expect range vector from 10s to 1m step 10s
some_metric{env="a"} 2 3 4 5 6
some_metric{env="b"} 4 6 8 10 12
源码中的实现细节值得注意:一旦某条即时查询出现 expect range vector 行,该查询会被打上 excludeFromRangeQuery 标记,跳过框架默认的「范围模式复查」,且未声明时若期望多个点会直接报错提示使用 expect range vector(test.go#L724-L738、test.go#L774-L777)。
expect 语法详解:fail、info、warn、no_info、no_warn、ordered
新版断言统一写作:
expect <type> <match_type>: <string>
参数说明:
<type>是期望类型:fail:期望查询失败;info:期望查询返回至少一条 info 注解;warn:期望查询返回至少一条 warn 注解;no_info:期望查询不返回任何 info 注解;no_warn:期望查询不返回任何 warn 注解;ordered:期望查询按指定顺序返回结果。
<match_type>(可选)指定注解消息的匹配方式:msg:精确字符串匹配;regex:正则匹配;- 对
ordered、no_info、no_warn不适用。
<string>是期望的注解消息;对fail则是期望的错误消息。warn与info的注解字符串以位置信息结尾,精确msg匹配时必须带上位置,见下文 注解位置。
完整示例(摘自 README):
eval instant at 1m sum by (env) (my_metric)
expect warn
expect no_info
{env="prod"} 5
{env="test"} 20
eval range from 0 to 3m step 1m sum by (env) (my_metric)
expect warn msg: PromQL warning: something went wrong (1:15)
expect info regex: PromQL info: something went (wrong|boom) \(1:15\)
{env="prod"} 2 5 10 20
{env="test"} 10 20 30 45
eval instant at 1m ceil({__name__=~'testmetric1|testmetric2'})
expect fail
eval instant at 1m ceil({__name__=~'testmetric1|testmetric2'})
expect fail msg: "vector cannot contain metrics with the same labelset"
eval instant at 1m ceil({__name__=~'testmetric1|testmetric2'})
expect fail regex: "vector cannot contain metrics .*|something else went wrong"
eval instant at 1m sum by (env) (my_metric)
expect ordered
{env="prod"} 5
{env="test"} 20
同一条 eval 可以对同一 <type> 写多行 expect:每一行各自校验对应注解、错误或顺序,互不干扰;但每一行 <expect> 都必须至少命中一条对应注解或错误。一旦存在至少一行 warn 或 info 类型的 expect,则所有对应注解都必须有匹配的 expect 行——即双向包含校验。这一点在 validateExpectedAnnotationsOfType 中实现:先遍历期望列表确认每条都有命中,再遍历实际注解确认每条都有对应期望,否则报「unexpected … annotation」(test.go#L1181-L1210)。
validateExpectedCmds 还施加了组合约束(test.go#L559-L570):
info与no_info不能同时使用;warn与no_warn不能同时使用;expect fail只允许出现一行。
真实测试文件里也能看到用法,例如 info.test 末尾:
eval range from 0m to 2m step 1m info(metric, {__name__=~".+_info"})
expect fail regex: conflicting label
以及「查询应成功且无注解」的标准写法——expect no_warn + expect no_info 成对出现(这正是迁移工具 strict 模式会自动补上的两行)。
注解位置(Annotation Positions)
warn 与 info 期望是拿注解在 API 中的渲染形态来匹配的,而该形态以触发注解的表达式位置结尾:
eval instant at 0m sum(metric)
expect warn msg: PromQL warning: encountered a mix of histograms and floats for aggregation (1:5)
其中 (1:5) 是 metric 所在的行号与列号——也就是注解所针对的表达式位置。断言位置可以确保注解仍然指向查询的正确部分。fail 期望匹配的是查询错误,错误本身不携带位置。
有两类情况让精确位置难以维护,解法都是用 regex:
- 位置附加的详细信息可能随评估时间变化。例如修复非单调直方图的 info 注解在展开后包含细节:
eval instant at 50m histogram_quantiles(nonmonotonic_bucket, "q", 0.01, 0.5, 0.99)
expect info regex: PromQL info: input to histogram_quantile needed to be fixed for monotonicity .* \(1:21\)
- 即时查询会被框架以追加
@修饰符的形式重跑一遍。重新序列化表达式会规范空格并使每个选择器变长,导致第一个选择器之后的列号全部右移。因此精确位置只在「被注解表达式不晚于第一个选择器开始」时才稳定(上述两例都满足),否则应宽松匹配位置:
eval instant at 50m histogram_quantiles(non_existent, "q", NaN)
expect warn regex: PromQL warning: quantile value should be between 0 and 1, got NaN \([0-9]+:[0-9]+\)
第 2 条机制有直接的源码依据:atModifierTestCases 会遍历表达式 AST,给所有尚未带 @ 的 VectorSelector、MatrixSelector、SubqueryExpr 补上评估时间,然后生成多个改写后的用例在额外时间点上重跑(test.go#L1594-L1654);execInstantEval 则把原始查询排在这些改写用例之前逐个执行(test.go#L1748-L1761)。这正是 README 所说「即时查询还会以每个选择器追加 @ 修饰符的形式重新执行,以检查结果与步长无关」的实现。
源码级补充:框架还替你做了哪些隐式断言
文档语法之外,阅读 test.go 可以发现几条影响写法行为的隐式规则:
- step 不变性 + 范围模式复查:除
@改写重跑外,每条即时查询还会被转成一个范围为[eval-1m, eval+1m]、步长 1m 的范围查询重跑,并与中间步的结果比对,确保即时查询在范围模式下结果一致(runInstantQuery,test.go#L1802-L1851)。因此测试文件里出现的表达式必须「任意步长下结果一致」,这也是部分函数(如start()/end()/range()/step()相关查询)被显式跳过复查的原因(test.go#L1590-L1591)。 - 矩阵结果必须按标签有序:范围查询结果会经过
assertMatrixSorted检查,确保引擎输出始终按标签排序(test.go#L1853-L1868);而expect ordered则进一步断言即时向量的具体顺序,范围模式复查时对 ordered 用例会跳过(test.go#L1817-L1820)。 - 浮点比较带相对误差:所有标量比较走
almost.Equal(actual, expected, 0.000001),即相对误差 1e-6 内视为相等(test.go#L61、test.go#L1306-L1308)。 - 每条 eval 成为独立子测试:非测试模式下,每条
eval会以line <行号>/<表达式>为名称注册为一个子测试(test.go#L1693-L1713),失败定位天然精确到脚本行。
此外,LazyLoader(test.go#L1895 起)是同一脚本语言的「只读数据加载器」变体,专门用于规则单元测试——它只接受 load 命令,被 promtool test rules 用来把 promtool unittest 文档 中的 input_series 喂进存储,这解释了为什么两者的 <points> 扩展记号完全同源。
旧语法迁移工具:三种模式把 eval_* 升级为 expect 行
README 提供了官方迁移工具,用于把旧版脚本原地升级为新语法:
- 目录由
--dir指定(默认即内置测试集目录),其中所有.test文件会被原地更新; - 弃用语法会被替换为推荐的
expect行。
用法:
go run ./promql/promqltest/cmd/migrate/main.go --mode=strict [--dir=<directory>]
--mode 控制迁移策略(cmd/migrate/main.go 中 mode 默认为 strict):
- strict:把所有期望严格迁移到新语法。由于旧语法隐含了许多常常并不需要的约束,迁移结果可能比预期更啰嗦(例如普通
eval会被补上expect no_warn与expect no_info); - basic:类似 strict,但从不生成
no_info/no_warn期望。适合作为手动补充这两类期望、或按需删除info/warn期望的起点; - tolerant:仅在合适处生成
expect fail与expect ordered,所有关于info/warn存在与否的期望都需手动添加。
三种模式对「原本通过的测试」都会产出「依然通过的测试」;basic 与 tolerant 只是校验的期望更少。
从 test_migrate.go 的映射表可以看到各模式的精确行为,例如 strict 模式下:
| 旧命令 | 生成的 expect 行 |
|---|---|
eval_fail |
expect fail + expect no_warn + expect no_info |
eval_warn |
expect warn + expect no_info |
eval_info |
expect info + expect no_warn |
eval_ordered |
expect ordered + expect no_warn + expect no_info |
普通 eval |
expect no_warn + expect no_info |
同时,旧的 expected_fail_message/expected_fail_regexp 行会被改写成 expect fail msg: …/expect fail regex: …(test_migrate.go#L173-L193);若某个 eval 块里已经存在 expect 行,迁移器会跳过该块不做改动,避免重复处理(test_migrate.go#L132-L147)。迁移器还会从首个缩进行自动探测 tab/空格风格并保持一致。
上手路径小结:如何运行内置测试集
结合源码,验证任何 PromQL 引擎实现的标准流程是:
- 用
promqltest.NewTestEngineWithOpts(或等价方式)构造启用全部实验解析特性的引擎; - 调用
promqltest.RunBuiltinTests(t, engine),或需要自定义存储时调用RunBuiltinTestsWithStorage; - 对自有表达式,编写
.test脚本(load造数 →eval+expect断言),通过promqltest.RunTest执行。
一个可直接参考的最小脚本结构(综合 aggregators.test 与 README 示例):
# 注释以 # 开头
load 1m
my_metric{env="prod"} 2 5 10 20
my_metric{env="test"} 10 20 30 45
eval instant at 50m sum by (env) (my_metric)
expect no_warn
expect no_info
{env="prod"} 5
{env="test"} 20
eval range from 0 to 3m step 1m sum by (env) (my_metric)
expect no_warn
expect no_info
{env="prod"} 2 5 10 20
{env="test"} 10 20 30 45
clear
# clear 之后是全新空环境,可继续 load/eval
需要留意的适用前提:本文描述的行为均以当前仓库 promql/promqltest/ 下的实现为准——expect 新语法与「显式 expect no_info/expect no_warn」的强制性是近期版本才成为默认契约的,旧文档或第三方引擎适配层若仍使用 eval_fail 等弃用写法,可优先用上述迁移工具对齐。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00