把分片数调大之后,原本失败的测试竟然通过了
目标
确定性地拆分测试集,把各分片的结果以机器可读的格式保存下来,再合并成一个判定。亲手构建两样东西:守住“分片数不能改变判定”的检查,以及按记录的耗时来缩小最重分片的分配。
为什么重要
测试一变长,人就不会等结果。并行拆分运行,是在不删除测试的前提下缩短挂钟时间的最直接手段,但拆分同时也带来了新的失败模式。如果分配每次运行都不同,“分片 2 失败”这条记录在下一次运行中指向的就是另一个测试,复现也就不可能了。如果马虎地合并各分片的结果,即使某个分片整个挂掉了,整体看起来还是绿色。而总是按同样顺序运行而一直隐藏的顺序依赖,会在分片分开的那一刻暴露出来。所以引入了拆分的流水线需要一条不变式——改变分片数,总用例数和总判定必须保持不变。 本实验会让你亲手制造这条不变式被打破的场景,找出原因并修复之后,再做出防止它再次被打破的门禁。
步骤
- 创建被测库
/root/shard/tests/sampleapp.py(必须有一个模块级的配置字典SETTINGS),并在同一目录中创建 4 个以上的test_*.py文件、20 个以上的用例。其中一个是/root/shard/tests/test_slowio.py,放置 5 个用time.sleep耗时 0.1 秒以上的用例(后面要用它制造分片之间的时间差)。这一步的所有测试都必须通过。然后创建/root/shard/list_tests.sh [시험디렉터리](占位符为测试目录),让它把测试名称按모듈.클래스.메서드(占位符依次为模块、类、方法)的形式排序后,每行输出一个(默认值是/root/shard/tests)。最后把整体一次运行的输出保存到/root/shard/baseline.txt(必须包含Ran N tests和OK)。 - 创建
/root/shard/shard.sh <조각번호> <조각수>(占位符依次为分片编号与分片数)。对标准输入收到的测试名称列表,只原样输出属于该分片的名称。分片由名称的 sha256 前 8 位(十六进制)除以分片数的余数决定。分片编号从 0 开始数。必须遵守三点——(1)各分片互不重叠,全部合在一起就是整个输入;(2)相同的输入运行两次,连一个字符都相同;(3)从列表中去掉一个测试,其余名称所属的分片不会改变。把确认的结果保存到/root/shard/deterministic.txt——一行说明两次运行的输出diff的结果为空,另一行按shard0=<수> shard1=<수> shard2=<수>(占位符为个数)的形式写出拆成 3 个分片后各自的个数。 - 创建
/root/shard/runner.py <시험디렉터리> <시험목록파일> <출력XML> <조각이름>(占位符依次为测试目录、测试列表文件、输出 XML、分片名称)。把列表中的测试在一个进程中按列表顺序运行,并写出 JUnit XML。根元素是testsuite(属性name、tests、failures、errors、time),每个测试对应一个testcase(属性classname、name、time),失败记为failure子元素,错误记为error子元素。再创建/root/shard/run_shard.sh <조각번호> <조각수> [리포트디렉터리](占位符依次为分片编号、分片数、报告目录),让它生成列表、拆分、运行,并保存<리포트디렉터리>/shard-<조각번호>.xml(报告目录的默认值是/root/shard/reports)。用bash /root/shard/run_shard.sh 0 3、1 3、2 3生成三个分片的报告。 - 创建
/root/shard/merge.sh <리포트디렉터리> <기대조각수>(占位符依次为报告目录与预期分片数)。读取该目录中所有的*.xml,只输出一行:PASS cases=<수> failures=<수> errors=<수> shards=<수>(占位符为个数),或者形式相同的FAIL ...。四项中只要有一项不符,就是FAIL,退出码为 1——报告文件数与预期分片数不同、存在无法读取的 XML、一个用例也没有、存在任何失败或错误。数量要通过统计实际的testcase、failure、error元素来得到,而不是读取头部的属性值。 对第 3 步创建的/root/shard/reports运行merge.sh /root/shard/reports 3,把输出保存到/root/shard/verdict.txt。 - 创建
/root/shard/total_of.sh <조각수> <출력디렉터리>(占位符依次为分片数与输出目录)。按该分片数运行所有分片,把报告保存到输出目录,最后原样输出merge.sh的那一行(退出码也原样传递)。输出目录中留下的报告必须恰好等于分片数,不能混入之前运行的报告。运行total_of.sh 2 /root/shard/out2和total_of.sh 3 /root/shard/out3,把两行保存到/root/shard/invariant.txt。去掉shards=之后的其余部分(cases=、failures=、errors=)必须相同。 - 故意加入依赖共享状态的测试。
/root/shard/tests/test_config.py放两个会修改sampleapp.SETTINGS的值来测试、并且不恢复原值的用例;/root/shard/tests/test_profile.py放两个期望保持默认配置的用例。确认整体在一个进程中运行时,后面的测试会失败,而单独运行那个测试则能通过。接着用分片数 2、3、4、5 运行total_of.sh,观察总判定随分片数而分歧的现象,并把造成污染的测试 id 作为第一行、受害的测试 id 作为第二行,写入/root/shard/order-dep.txt。最后修改这两个测试,使它们在继续使用sampleapp.SETTINGS的同时,无论按什么顺序运行都能通过(不要靠删除测试或不使用共享配置来回避)。 - 创建
/root/shard/timings.sh <리포트디렉터리>(占位符为报告目录),让它从报告的testcase中按名称顺序输出<시험id> <초>(占位符依次为测试 id 与秒数)。用它把现有全部测试的记录写成/root/shard/timings.txt(使用用一个分片运行全部测试的报告即可)。然后创建/root/shard/balance.sh <시간기록파일> <조각수>(占位符依次为耗时记录文件与分片数)——用从最长的开始、每次放进当前最轻的分片的方式(LPT)来拆分,并逐行输出<조각번호> <시험id>(占位符依次为分片编号与测试 id)(每个测试必须恰好出现一次)。最后以 3 个分片为基准,测量第 2 步的哈希分配与这个均衡分配各自的最重分片之和,用两行写入/root/shard/balance.txt:equal <초>和balanced <초>(占位符为秒数)。 - 创建
/root/shard/order_fast_first.sh <시간기록파일> <시험목록파일>(占位符依次为耗时记录文件与测试列表文件)。按记录的耗时升序(相同则按名称顺序)输出列表,并把没有记录的测试视为 0 秒,放在最前面。然后创建/root/shard/gate.sh <조각수> [시험디렉터리](占位符依次为分片数与测试目录)(测试目录的默认值是/root/shard/tests)。生成列表、拆分成分片、每个分片从快的开始运行,然后合并,原样输出merge.sh的那一行和退出码。报告只保存在自己的临时目录中,不在/root/shard下留下 XML。把bash /root/shard/gate.sh 3的输出保存到/root/shard/gate.txt。
参考
- 工作目录统一为
/root/shard。学员创建的工具,全部设计成把报告目录或测试目录作为参数——这样才能在临时副本里重新运行。 - 这个 Pod 中无法启动容器(seccomp 阻止用户命名空间)。不要使用
podman run、podman build、buildah。要使用的是 bash、python3.12 标准库、jq、sha256sum。没有bc、make、pytest。 - 常见错误:按列表的行号或数组索引来拆分。只要测试增减一个,后面的全部错位,“分片 N 失败”这条记录就失去了意义。
- 常见错误:合并时不统计报告文件数。某个分片挂掉的话,文件根本不存在,所以只看剩下的报告,会全是绿色。
- 常见错误:只相信 JUnit XML 头部的
failures属性。这个值是写报告的一方填写的数字,所以直接统计元素更安全。 - Continuous Integration(10 分钟构建) · GitHub Actions:用 matrix 拆分作业 · GitLab CI: job artifacts
创建测试集,一次性运行,建立基准
创建被测库 /root/shard/tests/sampleapp.py(必须有一个模块级的配置字典 SETTINGS),并在同一目录中创建 4 个以上的 test_*.py 文件、20 个以上的用例。其中一个是 /root/shard/tests/test_slowio.py,放置 5 个用 time.sleep 耗时 0.1 秒以上的用例(后面要用它制造分片之间的时间差)。这一步的所有测试都必须通过。然后创建 /root/shard/list_tests.sh [시험디렉터리](占位符为测试目录),让它把测试名称按 모듈.클래스.메서드(占位符依次为模块、类、方法)的形式排序后,每行输出一个(默认值是 /root/shard/tests)。最后把整体一次运行的输出保存到 /root/shard/baseline.txt(必须包含 Ran N tests 和 OK)。
unittest 的 defaultTestLoader.discover(디렉터리, top_level_dir=디렉터리)(占位符为目录)会给出测试集,每个测试对象的 id() 就是 모듈.클래스.메서드(占位符依次为模块、类、方法)字符串。列表是后面所有步骤的输入,所以要去重并排序后输出。整体运行可以用 cd tests && python3 -m unittest $(목록)(占位符为列表)完成。
让同样的名称永远进入同一个分片
创建 /root/shard/shard.sh <조각번호> <조각수>(占位符依次为分片编号与分片数)。对标准输入收到的测试名称列表,只原样输出属于该分片的名称。分片由名称的 sha256 前 8 位(十六进制)除以分片数的余数决定。分片编号从 0 开始数。必须遵守三点——(1)各分片互不重叠,全部合在一起就是整个输入;(2)相同的输入运行两次,连一个字符都相同;(3)从列表中去掉一个测试,其余名称所属的分片不会改变。把确认的结果保存到 /root/shard/deterministic.txt——一行说明两次运行的输出 diff 的结果为空,另一行按 shard0=<수> shard1=<수> shard2=<수>(占位符为个数)的形式写出拆成 3 个分片后各自的个数。
用 printf '%s' "$name" | sha256sum | cut -c1-8 得到八位十六进制数,再在 bash 算术中用 $(( 16#$h % n )) 求余数。如果按行号或数组索引来拆分,(3)就会被破坏——前面只要少了一个,后面的全都错位。read 要用 IFS= read -r 接收,以保留空格。
运行分片,保存为机器可读的结果
创建 /root/shard/runner.py <시험디렉터리> <시험목록파일> <출력XML> <조각이름>(占位符依次为测试目录、测试列表文件、输出 XML、分片名称)。把列表中的测试在一个进程中按列表顺序运行,并写出 JUnit XML。根元素是 testsuite(属性 name、tests、failures、errors、time),每个测试对应一个 testcase(属性 classname、name、time),失败记为 failure 子元素,错误记为 error 子元素。再创建 /root/shard/run_shard.sh <조각번호> <조각수> [리포트디렉터리](占位符依次为分片编号、分片数、报告目录),让它生成列表、拆分、运行,并保存 <리포트디렉터리>/shard-<조각번호>.xml(报告目录的默认值是 /root/shard/reports)。用 bash /root/shard/run_shard.sh 0 3、1 3、2 3 生成三个分片的报告。
对 unittest.TestResult() 调用 run(),运行用 loadTestsFromName(id) 创建的测试集,就可以单独接收一个用例的结果,在它前后用 time.time() 计时,就得到每个用例的耗时。XML 用 xml.etree.ElementTree 生成。必须把测试目录放进 sys.path,모듈.클래스.메서드(占位符依次为模块、类、方法)这样的名称才能被解析。重要的是在一个进程里连续运行——第 6 步会暴露出这其中的差别。
合并之前,没有判定
创建 /root/shard/merge.sh <리포트디렉터리> <기대조각수>(占位符依次为报告目录与预期分片数)。读取该目录中所有的 *.xml,只输出一行:PASS cases=<수> failures=<수> errors=<수> shards=<수>(占位符为个数),或者形式相同的 FAIL ...。四项中只要有一项不符,就是 FAIL,退出码为 1——报告文件数与预期分片数不同、存在无法读取的 XML、一个用例也没有、存在任何失败或错误。数量要通过统计实际的 testcase、failure、error 元素来得到,而不是读取头部的属性值。 对第 3 步创建的 /root/shard/reports 运行 merge.sh /root/shard/reports 3,把输出保存到 /root/shard/verdict.txt。
如果某个分片整个挂掉,报告文件本身就不存在。所以必须把“预期有几个”作为参数接收。头部的 failures 属性是写报告的一方填写的值,只相信它的话,只把头部改写成 0 的报告就会通过。请用 ET.parse(f).getroot().iter("testcase") 直接遍历元素。
改变分片数,总判定也必须相同
创建 /root/shard/total_of.sh <조각수> <출력디렉터리>(占位符依次为分片数与输出目录)。按该分片数运行所有分片,把报告保存到输出目录,最后原样输出 merge.sh 的那一行(退出码也原样传递)。输出目录中留下的报告必须恰好等于分片数,不能混入之前运行的报告。运行 total_of.sh 2 /root/shard/out2 和 total_of.sh 3 /root/shard/out3,把两行保存到 /root/shard/invariant.txt。去掉 shards= 之后的其余部分(cases=、failures=、errors=)必须相同。
分片数只是分工的方法,所以不能影响总用例数和判定。如果两行不同,说明分配器漏掉了测试,或者重复计数了。如果之前运行的 XML 还留着,数量就会虚增,所以开始时要清空输出目录。
单独运行时能通过的测试,一起运行就崩了
故意加入依赖共享状态的测试。/root/shard/tests/test_config.py 放两个会修改 sampleapp.SETTINGS 的值来测试、并且不恢复原值的用例;/root/shard/tests/test_profile.py 放两个期望保持默认配置的用例。确认整体在一个进程中运行时,后面的测试会失败,而单独运行那个测试则能通过。接着用分片数 2、3、4、5 运行 total_of.sh,观察总判定随分片数而分歧的现象,并把造成污染的测试 id 作为第一行、受害的测试 id 作为第二行,写入 /root/shard/order-dep.txt。最后修改这两个测试,使它们在继续使用 sampleapp.SETTINGS 的同时,无论按什么顺序运行都能通过(不要靠删除测试或不使用共享配置来回避)。
在一个进程中连续运行的话,模块级变量会保持着前一个测试改动之后的样子。修复的位置是两处之一——改动的一方在 tearDown 中恢复,或者期望的一方在 setUp 中设好自己要用的值。test_render.py 已经展示了正确的做法。也确认一下:文件名的字母顺序就是运行顺序。
按记录的耗时重新拆分,最重的分片变轻了
创建 /root/shard/timings.sh <리포트디렉터리>(占位符为报告目录),让它从报告的 testcase 中按名称顺序输出 <시험id> <초>(占位符依次为测试 id 与秒数)。用它把现有全部测试的记录写成 /root/shard/timings.txt(使用用一个分片运行全部测试的报告即可)。然后创建 /root/shard/balance.sh <시간기록파일> <조각수>(占位符依次为耗时记录文件与分片数)——用从最长的开始、每次放进当前最轻的分片的方式(LPT)来拆分,并逐行输出 <조각번호> <시험id>(占位符依次为分片编号与测试 id)(每个测试必须恰好出现一次)。最后以 3 个分片为基准,测量第 2 步的哈希分配与这个均衡分配各自的最重分片之和,用两行写入 /root/shard/balance.txt:equal <초> 和 balanced <초>(占位符为秒数)。
耗时记录是会成为下一次运行输入的产物——报告里已经有了,所以不要重新测量,直接提取使用。LPT 是先把列表按耗时降序排序,再把每一项放进当前总和最小的分片。这个 Pod 里没有 bc,所以总和用 awk 或 python3 来求。
把拆分、运行、合并合成一条命令
创建 /root/shard/order_fast_first.sh <시간기록파일> <시험목록파일>(占位符依次为耗时记录文件与测试列表文件)。按记录的耗时升序(相同则按名称顺序)输出列表,并把没有记录的测试视为 0 秒,放在最前面。然后创建 /root/shard/gate.sh <조각수> [시험디렉터리](占位符依次为分片数与测试目录)(测试目录的默认值是 /root/shard/tests)。生成列表、拆分成分片、每个分片从快的开始运行,然后合并,原样输出 merge.sh 的那一行和退出码。报告只保存在自己的临时目录中,不在 /root/shard 下留下 XML。把 bash /root/shard/gate.sh 3 的输出保存到 /root/shard/gate.txt。
这是把前面步骤创建的东西串起来的题目——list_tests.sh、shard.sh、order_fast_first.sh、runner.py、merge.sh。临时目录用 mktemp -d 创建,并用 trap ... EXIT 删除。之所以把测试目录作为参数接收,是为了在故意弄坏测试的副本上,确认门禁是否真的会亮红灯。