ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

SerenityOS LibTest/Randomized:深入解析 SerenityOS 的属性测试(Property-Based Testing)框架 SerenityOS LibTest/Randomized深入解析 SerenityOS 的属性测试Property-Based Testing框架【免费下载链接】serenityThe Serenity Operating System 项目地址: https://gitcode.com/GitHub_Trending/se/serenityLibTest/Randomized 是 SerenityOS 内置的随机化属性驱动测试库它让开发者用「生成随机输入、断言通用性质」的方式代替手工构造用例并在发现失败时自动把反例收缩shrink到最小可复现形式。本文以 Userland/Libraries/LibTest/Randomized/README.md 为骨架结合源码逐文件拆解其生成器、随机比特记录RandomRun与收缩算法读完你将能够独立编写随机化测试用例并理解其「对随机比特收缩而非对值收缩」这一核心设计。认识 LibTest/RandomizedUserland/Libraries/LibTest/Randomized/目录下的类实现了一套用于 SerenityOS 的随机化即「属性驱动测试」Property Based TestingPBT测试库。它与传统的单元测试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 lhs0 and rhs1 Failed test addition_commutative in 0ms注意输出中的int_a 0、int_b 1是收缩后的最小值——运行器最初拿到的很可能是像1587197、753361这样的大随机数。在上面的场景中测试运行器告诉我们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))它做三件事用生成器表达式初始化变量identifier若当前测试结果是Overrun随机比特耗尽见下文立即返回——这保证了收缩重放时不会读到不存在的随机数只有在报告开启时才把identifier 值打印出来。这正是收缩循环里disable_reporting()、最终报告时enable_reporting()切换的基础正常生成过程中不刷屏只在最终展示最小失败样例时打印每个 GEN 变量的值。什么是属性测试PBT属性测试Property Based TestingPBT的核心思路是生成随机测试输入并检查它们是否满足某些性质。这让你可以自动测试大量可能的情况并且在发现边界情况edge case方面极其有效。当发现失败时系统会自动把失败收缩为最小可复现用例。这一点很重要——因为随机值中包含大量无关紧要的细节比如1587197这个具体数字只会妨碍对根因的理解这正是经典文章Short, Self Contained, Correct (Compilable), Example——sscce 理念的体现。从实现流派上看本实现属于internal shrinking内部收缩家族——同类库包括 Hypothesis、minithesis、elm-test 等。相比「integrated shrinking」的惰性树实现如 Hedgehog和「manual shrinking」的手动收缩实现如最早的 QuickCheck内部收缩对开发者体验DX最友好不需要用户做任何额外工作就能得到最有韧性的收缩器。核心实现原理对随机比特收缩而不是对值收缩从高层看PBT 运行器的设计非常巧妙运行器记住生成随机值时用到的随机比特序列称为RandomRun当发现一个值让用户测试失败时它不去收缩这个值本身而是尝试收缩这些随机比特然后用收缩后的随机比特重新生成一个新值。这为什么可行因为生成器被特意设计成「更短/更小的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)公平布尔 / 以概率p0..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)区间 doublepercentage或任意范围的 double向 0 收缩代码注释还特别说明了vector采用几何分布而非「先随机长度再生成元素」的动机后者会把长度与元素耦合在一起导致删除元素时长度仍引用已删除的随机比特而逐个掷硬币的方式让每个元素「局部化」对收缩更友好。RandomnessSource.h —— 随机比特的两种模式RandomnessSource.h 是随机比特的来源有两个变体Live实时使用 AK/Random 产生 u32 随机值并把它们记录进自己的RandomRunRecorded回放从一个静态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 会以不同方式使用同一个 ChunkOriginal RandomRun: [5,1,3,9,4,2,3,0] Chunk (size4, index2): [_,_,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 内所有比特置 0SortChunk对 chunk 内的比特排序只用于 size ≥ 2SwapChunkWithNeighbour把 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(...)经典的二分查找式收缩用于MinimizeChoice与RedistributeChoicesAndMaybeInc——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的行为与其他测试类型一致。编写随机化测试的要点总结用性质而非实例思考随机化测试最适合表达「对所有输入都成立」的定律如交换律、结合律、幂等性、往返编码一致性序列化→反序列化等于原值、不变量保持等用GEN 生成器组合输入Gen::number_u64(min, max)、Gen::vector(min, max, gen)、Gen::one_of(...)、Gen::frequency(...)可以自由嵌套构造贴近真实场景的随机数据结构用ASSUME过滤无效输入当某些随机输入对测试前提没有意义时如除数不能为 0用ASSUME(x)丢弃——连续 30 次被拒则会以Rejected结束信任收缩器无需手动编写 shrink 逻辑Gen生成器与RandomRun机制会保证最终呈现的是最简失败样例注意EXPECT_EQ这类断言宏只在报告开启时才打印FAIL信息因此正常随机轮次是静默的控制轮次默认 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),仅供参考
返回列表