news 2026/9/13 1:43:51

SerenityOS LibTest/Randomized:深入解析 SerenityOS 的属性测试(Property-Based Testing)框架

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SerenityOS LibTest/Randomized:深入解析 SerenityOS 的属性测试(Property-Based Testing)框架

SerenityOS LibTest/Randomized:深入解析 SerenityOS 的属性测试(Property-Based Testing)框架

【免费下载链接】serenityThe Serenity Operating System 🐞项目地址: https://gitcode.com/GitHub_Trending/se/serenity

LibTest/Randomized 是 SerenityOS 内置的随机化(属性驱动)测试库,它让开发者用「生成随机输入、断言通用性质」的方式代替手工构造用例,并在发现失败时自动把反例收缩(shrink)到最小可复现形式。本文以 Userland/Libraries/LibTest/Randomized/README.md 为骨架,结合源码逐文件拆解其生成器、随机比特记录(RandomRun)与收缩算法,读完你将能够独立编写随机化测试用例,并理解其「对随机比特收缩而非对值收缩」这一核心设计。

认识 LibTest/Randomized

Userland/Libraries/LibTest/Randomized/目录下的类实现了一套用于 SerenityOS 的随机化(即「属性驱动测试」,Property Based Testing,PBT)测试库。它与传统的单元测试(TEST_CASE)、基准测试(BENCHMARK_CASE)并列,是 LibTest 框架的第三种测试形态——通过宏 RANDOMIZED_TEST_CASE 注册。

它的核心工作方式是:测试运行器(runner)用随机数据反复执行同一个测试函数;一旦发现某个输入组合让断言失败,就自动把该输入收缩为一个最小失败样例并展示给用户。

快速上手:第一个随机化测试用例

README 给出了一个非常直观的例子——验证 CSSPixels 加法满足交换律:

RANDOMIZED_TEST_CASE(addition_commutative) { GEN(int_a, Gen::unsigned_int()); GEN(int_b, Gen::unsigned_int()); CSSPixels a(int_a); CSSPixels b(int_b); EXPECT_EQ(a + b, b + a); }

逐行解读:

  • RANDOMIZED_TEST_CASE(addition_commutative)声明一个随机化测试用例。该宏在 Macros.h 中展开,最终调用TestCase::randomized(...)把用例注册进测试套件(TestSuite)。
  • GEN(int_a, Gen::unsigned_int())声明一个「由生成器产生的随机局部变量」:Gen::unsigned_int()每次生成一个随机无符号整数并赋给int_a
  • 测试体像普通代码一样使用这些变量,并用EXPECT_EQ等断言检查对任何输入都成立的性质(这里是交换律)。

运行器会把该用例执行很多次,每次所有GEN变量都被填充为新的随机数据。一旦找到任何一组让断言失败的取值,它就会收缩出最小失败样例并呈现给用户:

Running test 'addition_commutative'. int_a = 0 int_b = 1 FAIL: /.../SomeTest.cpp:26: EXPECT_EQ(a + b, b + a) failed with lhs=0 and rhs=1 Failed test 'addition_commutative' in 0ms

注意输出中的int_a = 0int_b = 1收缩后的最小值——运行器最初拿到的很可能是像1587197753361这样的大随机数。在上面的场景中,测试运行器告诉我们CSSPixels(0) + CSSPixels(1)CSSPixels(1) + CSSPixels(0)不相等。

[!NOTE] 这个失败例子是 README 作者编造的,不必担心——CSSPixels本身没有问题。它只是用来演示「属性不满足时,输出会被收缩到最简」这一行为。

GEN 宏背后的实现

从 Macros.h 可以看到GEN宏的真实面目:

#define GEN(identifier, value) \ auto identifier = (value); \ if (::Test::current_test_result() == ::Test::TestResult::Overrun) \ return; \ if (::Test::is_reporting_enabled()) \ ::AK::warnln("{} = {}", #identifier, (identifier))

它做三件事:

  1. 用生成器表达式初始化变量identifier
  2. 若当前测试结果是Overrun(随机比特耗尽,见下文),立即返回——这保证了收缩重放时不会读到不存在的随机数;
  3. 只有在报告开启时才把identifier = 值打印出来。这正是收缩循环里disable_reporting()、最终报告时enable_reporting()切换的基础:正常生成过程中不刷屏,只在最终展示最小失败样例时打印每个 GEN 变量的值。

什么是属性测试(PBT)?

属性测试(Property Based Testing,PBT)的核心思路是:生成随机测试输入,并检查它们是否满足某些性质。这让你可以自动测试大量可能的情况,并且在发现边界情况(edge case)方面极其有效。

当发现失败时,系统会自动把失败收缩为最小可复现用例。这一点很重要——因为随机值中包含大量无关紧要的细节(比如1587197这个具体数字),只会妨碍对根因的理解(这正是经典文章Short, Self Contained, Correct (Compilable), Example——sscce 理念的体现)。

从实现流派上看,本实现属于"internal shrinking"(内部收缩)家族——同类库包括 Hypothesis、minithesis、elm-test 等。相比「integrated shrinking」的惰性树实现(如 Hedgehog)和「manual shrinking」的手动收缩实现(如最早的 QuickCheck),内部收缩对开发者体验(DX)最友好:不需要用户做任何额外工作,就能得到最有韧性的收缩器

核心实现原理:对随机比特收缩,而不是对值收缩

从高层看,PBT 运行器的设计非常巧妙:

  1. 运行器记住生成随机值时用到的随机比特序列(称为RandomRun);
  2. 当发现一个值让用户测试失败时,它不去收缩这个值本身,而是尝试收缩这些随机比特;
  3. 然后用收缩后的随机比特重新生成一个新值

这为什么可行?因为生成器被特意设计成「更短/更小的RandomRun会产生更简单的值」。所以收缩随机比特,就等于自动地、泛化地对任何可生成的数据进行收缩——不需要为每种数据类型单独编写收缩逻辑

收缩循环的推进方式(README 原文流程):

  • 如果新的RandomRun能成功转换成一个值,并且测试在这个更简单的新值上仍然失败,就保留这个RandomRun作为新的最优解,继续尝试;
  • 一旦无法继续改进,就打开错误报告(enable_reporting()),用最终的RandomRun再运行一次测试,把失败值报告给用户。

主循环的源码验证

TestCase.h 中的TestCase::randomized(...)承载了上述主循环,关键路径是:

case TestResult::Failed: { generated_successfully = true; RandomRun first_failure = randomness_source().run(); RandomRun best_failure = shrink(first_failure, test_function); // Run one last time with reporting on, so that the user can see the minimal failure enable_reporting(); run_with_randomness_source(RandomnessSource::recorded(best_failure), test_function); return; }

可以看到:第一次失败时取出当前随机比特序列first_failure,交给shrink()得到best_failure,最后打开报告、用RandomnessSource::recorded(best_failure)重放,从而打印出每个 GEN 变量的最小失败值。每次值生成还有硬上限MAX_GEN_ATTEMPTS_PER_VALUE = 30:若连续 30 次生成都被ASSUME过滤拒绝,则放弃并报告REJECTED信息。

代码组织结构(源码级导览)

README 把实现按职责拆成了 8 个文件,逐一说明如下。所有实现均位于 Userland/Libraries/LibTest/Randomized/ 目录。

TestResult.h —— 测试结果状态机

TestResult.h 定义了enum class TestResult,它扩展了传统的「通过/失败」二元结果,因为随机化测试还需要关心生成器「拒绝」某个 RandomRun 的情况(例如用户通过ASSUME(...)宏提出了无法满足的谓词):

状态含义
NotRun尚未运行
Passed测试函数完整跑完,未触发任何失败标记
Failed断言(EXPECT(...)等)失败
Rejected随机化测试中连续多次未通过ASSUME(...)过滤,或用户显式调用REJECT(...)
OverrunRandomRun 随机比特耗尽(收缩重放时发生),此时换一种收缩方式即可

Generator.h —— 生成器大全

Generator.h 包含形如T()的生成器函数,它们隐式依赖单例TestSuite持有的RandomnessSource。生成器可以直接调用,但顶层使用应始终通过GEN(...)宏——这样失败时生成的值才能被记录并打印给用户。

README 中给出的示例为Gen::vector(1, 4, []() { return Gen::unsigned_int(5); }):生成长度在 1~4 之间、元素为 0..5 无符号整数的向量,例如{2,5,3}{0}{1,5,5,2}

对照当前源码,Gen命名空间下实际可用的生成器包括(注意:README 示例中的unsigned_int在源码中对应number_u64/number_u32系列命名):

生成器行为收缩方向
number_u64(max)/number_u64(min, max)生成[0, max][min, max]的无符号整数;min == max时不消耗随机比特(RandomRun 为空)向 0 / 向最小值收缩
number_u64()/number_u32()全范围整数,偏好 8/4/16/32/64 位小数字,约 11% 概率尝试0、各NumericLimits::max()等边界向 0 收缩
one_of(choices...)等概率从参数中选一个值靠前的参数
frequency(Choice {weight, value}...)按权重随机选择,如{5,999}{1,111}表示 5/6 概率出 999靠前的参数
boolean()/weighted_boolean(p)公平布尔 / 以概率p(0..1)为真的布尔;概率为 0 或 1 时不消耗随机比特向 false 收缩
vector(min, max, item_gen)/vector(length, item_gen)/vector(item_gen)随机长度向量(默认 0..32)。长度用「逐个掷硬币决定是否再加一个元素」的几何分布算法生成,而不是先生成长度——这样 RandomRun 模式更容易收缩(参考 Hypothesis 论文 §3.3)更短的向量 + 更简单的元素
number_f64(min, max)/number_f64(max)/percentage()[0,1)区间 double(percentage),或任意范围的 double向 0 收缩

代码注释还特别说明了vector采用几何分布而非「先随机长度再生成元素」的动机:后者会把长度与元素耦合在一起,导致删除元素时长度仍引用已删除的随机比特;而逐个掷硬币的方式让每个元素「局部化」,对收缩更友好。

RandomnessSource.h —— 随机比特的两种模式

RandomnessSource.h 是随机比特的来源,有两个变体

  • Live(实时):使用 AK/Random 产生 u32 随机值,并把它们记录进自己的RandomRun
  • Recorded(回放):从一个静态RandomRun中依次取出(重放)u32 值,用于「模拟」PRNG——这既支持重放某个特定值的生成过程,也支持试验「替代历史」:假如 PRNG 那次给出的是 0 而不是 13,会怎样?

draw_value(max, random_generator)是两者的统一入口:Live 模式调用真实随机生成器并追加记录;Recorded 模式从m_run.next()取下一个预录值(超过max时截断),若序列耗尽则设置TestResult::Overrun并返回 0 作为占位。

RandomRun.h —— 随机比特的有限序列

RandomRun.h 是一个有限的随机比特序列(实践中为 u32/u64 序列),例如{2,5,0,11,8,0,0,1}。它承载了收缩的核心度量——shortlex(短字典序)排序

  • 更短更好[9,9,9] < [0,0,0,0]
  • 长度相同则按字典序[8,9,9] < [9,0,0](出现更早的数字更重要)。

这正是收缩的优化目标:is_shortlex_smaller_than()用于判断一个新 RandomRun 是否「更优」,而with_sorted(Chunk)with_deleted(Chunk)提供了 chunk 级排序与删除两个基础变换,供收缩命令调用。

Chunk.h —— RandomRun 的切片描述

Chunk.h 描述 RandomRun 的一段连续切片,例如Chunk{size = 4, index = 2}表示[_,_,X,X,X,X,...]。chunk 尺寸可取1,2,3,4,8。不同的 ShrinkCommand 会以不同方式使用同一个 Chunk:

Original RandomRun: [5,1,3,9,4,2,3,0] Chunk (size=4, index=2): [_,_,X,X,X,X,_,_] ZeroChunk: [5,1,0,0,0,0,3,0] SortChunk: [5,1,2,3,4,9,3,0] DeleteChunkAndMaybeDecPrevious: [5,1, 3,0]

ShrinkCommand.h —— 六种收缩「配方」

ShrinkCommand.h 定义了如何尝试最小化给定 RandomRun 的高层「配方」,之后由 PBT 运行器在具体的 RandomRun 上解释执行。ShrinkCommand::for_run(run)会按「预估有效性」排序生成全套命令——删除 chunk 优于最小化它们:

命令含义
DeleteChunkAndMaybeDecPrevious删除一段 chunk;若前一个值 >0 则尝试把它减 1(处理「先随机长度、再生成元素」这类 RLE 模式)
ZeroChunk把 chunk 内所有比特置 0
SortChunk对 chunk 内的比特排序(只用于 size ≥ 2)
SwapChunkWithNeighbour把 chunk 与其右侧相邻 chunk 交换
MinimizeChoice对某个索引处的数值做二分收缩
RedistributeChoicesAndMaybeInc在两个索引间重分配数值(保持和不变),必要时对前一个值 +1 以跨越 frequency 桶边界

has_a_chance(run)会在执行前快速判断命令是否越界,跳过没有机会成功的命令。

Shrink.h —— 收缩算法与主收缩循环

Shrink.h 负责解释ShrinkCommand,并实现主收缩循环:

  • keep_if_better(new_run, current_best, test_function):只有当new_run严格 shortlex 更小时才尝试;用 Recorded 模式重放生成值并重跑测试,若仍Failed则视为改进;
  • binary_shrink(...):经典的二分查找式收缩,用于MinimizeChoiceRedistributeChoicesAndMaybeInc——high保证失败、low保证通过/拒绝,迭代找「仍失败的最低值」;
  • shrink_once(run, test_function):对当前最优 RandomRun 依次尝试全部命令,凡是有改进的就采纳;
  • shrink(first_failure, test_function):外层循环反复调用shrink_once,直到next不再比currentshortlex 更小,返回最终的最小失败 RandomRun。

shrink_delete中还解释了「对前一个值减 1」的必要性:对于[3(length), 50, 21, 1]这样的 RLE 型 RandomRun,直接删掉第二个元素会让长度 3 与剩下的两个元素对不上(随机比特耗尽);把长度先减为 2 才能正常生成——这也是vector生成器刻意采用「逐个掷硬币」算法的原因。

TestCase.h —— 主测试循环

TestCase.h 的TestCase::randomized(...)是随机化测试的入口:外层循环执行randomized_runs()次(命令行可配,默认 100,见 TestSuite.cpp 的--randomized_runs参数);每次先disable_reporting()静默生成并运行,命中失败就shrink()收缩,最后enable_reporting()重放最小样例。若一次生成被Rejected/Overrun,则最多重试MAX_GEN_ATTEMPTS_PER_VALUE = 30次。

命令行运行与参数控制

随机化测试与普通测试一样通过 LibTest 的测试入口(TestMain.cpp)运行,并共享 TestSuite.cpp 的命令行参数:

--randomized_runs RUNS Number of times to run each RANDOMIZED_TEST_CASE (default 100)

即每个RANDOMIZED_TEST_CASE默认跑 100 轮随机数据;--list可列出全部用例,位置参数pattern可按名称过滤。其余如--tests/--bench的行为与其他测试类型一致。

编写随机化测试的要点总结

  1. 用性质而非实例思考:随机化测试最适合表达「对所有输入都成立」的定律,如交换律、结合律、幂等性、往返编码一致性(序列化→反序列化等于原值)、不变量保持等;
  2. GEN+ 生成器组合输入Gen::number_u64(min, max)Gen::vector(min, max, gen)Gen::one_of(...)Gen::frequency(...)可以自由嵌套,构造贴近真实场景的随机数据结构;
  3. ASSUME过滤无效输入:当某些随机输入对测试前提没有意义时(如除数不能为 0),用ASSUME(x)丢弃——连续 30 次被拒则会以Rejected结束;
  4. 信任收缩器:无需手动编写 shrink 逻辑,Gen生成器与RandomRun机制会保证最终呈现的是最简失败样例;注意EXPECT_EQ这类断言宏只在报告开启时才打印FAIL信息,因此正常随机轮次是静默的;
  5. 控制轮次:默认 100 轮通常足够发现边界问题,必要时用--randomized_runs调大以获得更强信心。

LibTest/Randomized 把「生成—执行—收缩—报告」整条链路封装在 TestCase::randomized 与 Shrink.h 之中,配合 RANDOMIZED_TEST_CASE 宏,让 SerenityOS 的测试编写者能以极低的成本获得 Hypothesis 级别的内部收缩能力——这正是它的核心价值所在。

【免费下载链接】serenityThe Serenity Operating System 🐞项目地址: https://gitcode.com/GitHub_Trending/se/serenity

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 1:43:11

COLMAP + IMU 位姿估计实战指南:3 步把轨迹误差压到 1/3

COLMAP IMU 位姿估计实战指南&#xff1a;3 步把轨迹误差压到 1/3 【免费下载链接】colmap COLMAP - Structure-from-Motion and Multi-View Stereo 项目地址: https://gitcode.com/GitHub_Trending/co/colmap 无人机在树冠间快速穿行&#xff0c;机身一晃&#xff0c;…

作者头像 李华
网站建设 2026/9/13 1:41:33

Pixelle-Video 如何用 Docker Compose 部署并检查服务健康状态?

Pixelle-Video 如何用 Docker Compose 部署并检查服务健康状态&#xff1f; 【免费下载链接】Pixelle-Video &#x1f680; AI 全自动短视频引擎 | AI Fully Automated Short Video Engine 项目地址: https://gitcode.com/GitHub_Trending/pi/Pixelle-Video Pixelle-Vid…

作者头像 李华
网站建设 2026/9/13 1:41:21

STM32F429驱动DS1307:I2C实时时钟芯片驱动开发与调试实战

简介&#xff1a;围绕STM32F429与DS1307实时时钟芯片的I2C通信例程&#xff0c;面向嵌入式初学者及需要快速上手STM32外设开发的工程师。内容涵盖GPIO复用模式配置、I2C外设初始化、读写时序实现&#xff0c;以及通过HAL库完成时间设置与读取的完整思路&#xff0c;可作为学习I…

作者头像 李华