ARTICLE DETAIL

资讯详情

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

自动化测试脚本PASS但数据全零?三大坑与排查方法

自动化测试脚本PASS但数据全零?三大坑与排查方法 凌晨三点我被电话从床上拽起来对面是老化测试房的同事语气很急设备跑了四十八个小时自动老化脚本日志里明明白白写着 PASS结果质检那边拿工具直读数据满屏全是 0x00。两边都觉得自己没错最后问题甩到我这儿脚本说 PASS、OS 读全零到底是谁在撒谎干这行久了你会发现自动化脚本的 PASS 和真实设备状态之间有时隔着一层看不见的滤镜。那天晚上的经历让我把脚本判定可靠性这件事重新想了一遍。今天就把这个坑拆开讲清楚为什么脚本报了 PASS实际数据却是全零以及你该怎么一步步把真凶揪出来。1. 先别急着吵架把现场原原本本捋一遍1.1 老化测试脚本平时是怎么干活的设备老化测试burn-in test是量产前最狠的一道筛子目的是让早期失效的设备在高温、高压、高负载下提前暴露问题。全自动执行脚本负责把整件事跑完初始化环境、循环读写测试数据、记录每步日志、最后在设备特性曲线上画一个 PASS。常见的脚本长这样#!/bin/bash DEVICE/dev/sda PATTERN0x00 for round in {1..10}; do echo round $round: writing... dd if/dev/zero of$DEVICE bs1M count16 echo round $round: reading... dd if$DEVICE bs1M count16 | md5sum /tmp/read.md5 md5sum /dev/zero | head -c 32 /tmp/write.md5 if [ $(cat /tmp/read.md5) $(cat /tmp/write.md5) ]; then echo PASS round $round else echo FAIL round $round exit 1 fi done echo FINAL RESULT: PASS注意看这个脚本的问题它写的是0x00也就是全零数据。这个细节当时所有人都没当回事因为写什么读什么比对一致听着天经地义。但偏偏是这全零的数据埋下了整场闹剧的雷。1.2 真正要追问的PASS 到底是怎么算出来的通宵排查的时候我把脚本一行行翻过去才意识到整件事的矛盾根源不在设备而在脚本的判定逻辑。这个脚本的 PASS 由三部分组成写入命令有没有报错读取命令有没有报错读回内容的哈希值和期望值是否一致听起来很完整对吧但实际执行时dd的返回码只能告诉你命令发出去了不能告诉你数据真的落到介质上了。读回比对用的是哈希值如果整个测试范围内读回来的数据全是 0x00而期望值也是 0x00 的哈希那校验逻辑自然认为一致于是疯狂输出 PASS。而 OS 直读看到全零说明介质的真实状态和脚本声称的状态不是一回事。换句话说脚本的 PASS 是流程走完了的 PASS不是数据正确的 PASS。这两个概念在自动化测试里差了十万八千里。所以谁在撒谎这个问题本身就有误导性。脚本没有主观撒谎它是按照人写的判定规则机械地给出结论。真正该问的是我们为什么把判定规则写得这么脆弱2. 翻完代码之后发现三个坑全踩中了2.1 只查了命令返回值没查数据本身很多老化测试脚本在写判定逻辑时特别容易犯一个错过度依赖命令行工具的退出码。比如有些人喜欢这样写write_data read_data if [ $? -eq 0 ]; then echo PASS fi这段代码的问题是$?捕获的是最后一条命令的返回值。如果最后一条命令是read_data还好但脚本中间一旦夹了别的操作比如grep过滤、echo写日志$?就变成了那条命令的返回值。grep只要匹配到任何内容哪怕是空行、哪怕是错误提示文本里的某个关键字都会返回 0于是脚本愉快地打出了 PASS。为什么全零数据会加剧这个问题因为全零数据的哈希可以预先算出来而且很多工具对全零有特殊优化——比如某些情况下dd源是/dev/zero内核会走一个 zero-copy 的路径读回的时候如果设备处于异常状态可能直接返回一个全零缓冲根本不会暴露介质层面的故障。命令不报错数据比对也一致PASS 就这么水灵灵地打了出来。2.2 读到了缓存没读到介质这是第二个坑也是最经典的坑。操作系统对写操作有缓存机制你写进去的数据先落到 page cache 和设备的 DRAM buffer 里内核认为写成功了实际介质上的数据在那一刻可能是还没真正落盘的状态。脚本回读时因为刚写完数据热度还在读到的直接是缓存里的内容自然和写入的一模一样。但 OS 层面如果绕过缓存直读介质或者设备掉电后重新上电读到的就是介质上的真实物理状态——如果写入时掉电保护逻辑没生效、或者映射表没刷新那介质上可能真就是一片全零。生活里有个差不多的类比你在电商平台点了确认收货系统显示签收成功但快递其实还没送到。系统的状态是成功实物的状态是在路上两者不一致只是因为看的是不同层。测试脚本看到的是操作系统说的成功OS 直读看到的是介质实际的内容。这个坑在设备老化测试里尤其危险因为老化测试往往会持续几十个小时中间可能有无数次写成功但未真正落盘的写操作。等到最后做数据完整性的抽检时缓存早就被冲掉了读回的当然是真实介质状态。2.3 测试数据挑了个全零挖坑给自己跳全零数据作为测试 pattern 有一个致命的盲区它无法区分正常和异常。设想一下如果设备处于以下几种故障状态之一你读回来的是什么故障场景读回数据和期望值比对脚本判定写入命令失败但没报错全零一致PASS数据根本没落介质全零一致PASS读接口/总线异常返回默认值全零一致PASS介质被意外擦除全零一致PASS数据正常写入读出全零一致PASS发现问题了吧全零 pattern 会让所有故障场景和正常场景长得一模一样。你用全零数据做测试等于把一个眼神不好的保安放到监控室他当然什么都看不见当然天天报告一切正常。正确的做法是用非零、有辨识度的 pattern比如递增字节0x00,0x01,0x02,...、固定的十六进制魔法数如0xA5A5A5A5、或者带随机种子的伪随机序列。这样一旦读回的数据不对立刻就能比对出差异。3. 用受控实验把真凶揪出来3.1 第一步原样复现确认不是偶发排查这种事最忌讳一上来就怀疑设备玄学。我的第一步是原样重跑一遍脚本不加任何改动看看它是不是还能稳定打出 PASS。实测结果复现毫无悬念脚本继续一路 PASSOS 直读继续一片全零。这说明问题不是随机抖动而是稳定的、系统性的判定缺陷。复现确认之后我随手加了一条sync命令到脚本里强制把缓存刷到介质然后再跑一遍发现脚本开始报 FAIL 了。这一步非常关键它把缓存干扰从嫌疑名单里直接提到了主犯位置。3.2 第二步把缓存这层黑纱彻底掀掉复现之后我给测试环境手动下了三道命令sync echo 3 /proc/sys/vm/drop_caches dd if/dev/sda bs1M count16 | od -A x -t x1z | headsync强制把脏页写回介质drop_caches把系统读缓存清掉最后od直接以字节方式把设备内容打出来。这一步之后读回来的数据不再有缓存兜底介质上有什么就是什么。实测结果全零。也就是在写命令报成功、脚本判断 PASS 的背后介质上的数据压根不是脚本以为的那一份。这一步还顺手验证了一个换角度的问题脚本测试用的设备路径和 OS 直读的设备路径是不是同一个。因为以前出过变量替换的乌龙脚本里某个变量没传进去结果测试写到了/tmp下的镜像文件而 OS 直读的是真实设备两边当然对不上。这次我特意用lsblk和readlink -f核对了路径确认写到和读到的是同一个设备才把谁在撒谎的矛盾锁定在缓存和落盘上。3.3 第三步换掉全零 pattern让故障原形毕露确认缓存问题之后我又做了一组对比实验把测试数据从全零换成递增字节 pattern。这一步不是为了复现而是为了验证全零 pattern 掩盖故障的假设。# 写入 16MB 递增数据 python3 -c import sys data bytes([i % 256 for i in range(1024*1024)]) with open(/tmp/pattern.bin, wb) as f: for _ in range(16): f.write(data) dd if/tmp/pattern.bin of/dev/sda bs1M count16 convfsync # 掉电重启后直读比对 dd if/dev/sda bs1M count16 | sha256sum sha256sum /tmp/pattern.bin实验结果完全没有悬念期望 pattern 的哈希和实际读回哈希对不上而且把读回数据的头几个字节打印出来能看到一堆0x00而不是0x00,0x01,0x02...的递增序列。这说明设备在写成功后并没有把数据真正保存下来读路径返回的默认值恰好是全零。正是这个换 pattern 的动作让原本被全零掩盖的故障彻底暴露了出来。3.4 第四步掉电重启验证数据持久性测试脚本那种写完马上读的模式本质上是在测试缓存的诚实度而不是介质的可靠度。真正的数据完整性验证必须经过掉电。把设备断电再上电让操作系统重新枚举所有缓存、DRAM buffer 里的东西全部归零这时候再读介质拿到的东西才是设备真实的物理状态。这次掉电重启之后直读结果依然是一串全零。到这里真凶就彻底锁定了脚本的 PASS 来自写命令成功 缓存读回一致的错误认定而 OS 直读全零暴露的是数据根本没有可靠落盘。整个过程走下来脚本撒谎还是 OS 撒谎的答案已经很清楚了谁都没撒谎是测试设计有漏洞让一个不靠谱的 PASS 蒙混过关。4. 方向本身没错的话这些静默失败你迟早也会遇到上一节讲的是老化测试脚本最典型的判定盲区。但做排查这些年我还积累了几个同样阴险的静默失败场景它们不会让脚本报错只会让脚本在错误的方向上自我感觉良好。4.1 权限和环境变量让命令没跑但没报错有一次脚本在 root 下跑得好好的换到普通用户执行老化测试的写操作开始失败但脚本没崩也没报错日志照样输出 PASS。一查才发现写操作的命令前面有个sudo而测试机上的 sudo 配置对某些设备节点没有放行命令内部直接吞掉了错误退出码依然是 0。更常见的是环境变量问题。脚本里如果用了$DEVICE这类变量而这个变量只在一部分 shell 会话里被 export换一个环境执行时变量为空命令就从dd if... of/dev/sda变成了dd if... of后半段直接变成一个非法命令。非法命令在某些写法下会被静默忽略脚本继续往下跑最后输出一个看起来很美的 PASS。我在排查那次全零事件时特别留意了这点因为 QA 那边直读用的就是他们自己环境里的变量和我脚本里的变量若不仔细核对很容易把两种不同设备混为一谈。这个环节建议在脚本开头加了环境快照打印env | grep -E DEVICE|PATTERN|TEST_DIR | tee /tmp/test_env.log所有变量都在日志里留痕。4.2 子任务失败被整体状态掩盖老化测试脚本往往很长几十个步骤首尾相接。如果中间某个子任务失败但脚本没有立即exit而是让后面的任务继续跑最后一步的结果可能就把前面的失败盖过去了。我见过一个典型写法循环遍历多个测试项目每项结果追加到同一个结果文件循环结束后脚本用grep FAIL result.log判断有没有失败。尴尬的是如果有哪一项测试因为设备移除根本没执行那个项目压根不会出现在结果文件里grep FAIL搜不到任何东西脚本就打了 PASS。这种没跑和跑了且通过被一视同仁的设计是静默失败的重灾区。改进办法很简单结果文件里每一项都要显式写入 PASS 或 FAIL不允许缺省。最后统计时不仅要搜 FAIL还要核对执行项数量是否等于计划项数量缺一项直接判定整个批次异常。4.3 工具链版本差异导致行为不一致同一个脚本在开发机、量产测试机、质检电脑上跑出来的行为可能完全不一样。最典型的是grep -qGNU grep 和 busybox 的 grep 对部分无文本文件的设备节点处理方式不同有的会返回 0有的会返回 2。一般脚本只检查返回 0 就 PASS一旦工具链返回 2脚本可能直接误判 FAIL反过来也可能因为返回 0 稀里糊涂打出 PASS。我在那次排障后把脚本里的dd | md5sum改成了dd if... bs1M count16 2/dev/null | sha256sum并且统一在测试机上用#!/usr/bin/env python3写了一个独立的校验模块。因为 Python 的hashlib在不同机器上的行为一致不会因为 shell 工具链差异出现这种环境决定结论的问题。5. 经验沉淀问题速查表和改进后的脚本设计5.1 遇到脚本 PASS 但实际数据不对先查这张表这次排障的经验我整理成了一张速查表。以后谁再遇到类似矛盾照着表里顺序查一遍基本能快速锁定问题方向。现象可能原因验证方法解决方向脚本 PASSOS 直读全零数据未真正落盘被缓存掩盖掉电重启后再读写后强制 sync测试验证需掉电脚本 PASS外部对比数据不一致判定只看了命令返回值检查脚本$?捕获位置改为对数据内容做断言校验脚本 PASS但某步骤实际未执行权限或环境变量导致命令静默失败打印环境快照和每步退出码显式检查变量、添加命令输出留痕脚本 PASS但子任务有 FAIL 被覆盖最后一步结果覆盖了中间失败查看完整日志中 FAIL 关键字逐项记录数量核对不允许缺省脚本 PASS但另一台机器复现 FAIL工具链版本/环境差异对比两机 grep、dd、sha 版本用 Python 做统一校验模块脚本 PASS但数据 pattern 全零测试数据无可辨识度换递增/随机 pattern 重测禁止使用全零作为唯一测试数据这张表不是万能的但它覆盖了自动化测试脚本里最常见的几类假 PASS根因。遇到新问题把现象往表里一套能省很多冤枉路。5.2 我改完之后脚本的骨架长这样那次排障之后我把老化测试脚本推倒重写了。核心思路只有一个PASS 必须有可验证的证据链。下面是我认为比较靠谱的骨架结构#!/usr/bin/env python3 import hashlib import os import sys import time def assert_data(device, pattern_file, rounds3): 核心断言写入 - 强制落盘 - 掉电后或直读比对 for r in range(rounds): # 1. 写入带辨识度的 pattern os.system(fdd if{pattern_file} of{device} bs1M count16 convfsync) # 2. 强制 sync 清缓存 os.system(sync) with open(/proc/sys/vm/drop_caches, w) as f: f.write(3) # 3. 直读介质并计算哈希 read_hash os.popen(fdd if{device} bs1M count16 2/dev/null | sha256sum).read().split()[0] expect_hash hashlib.sha256(open(pattern_file, rb).read()).hexdigest() if read_hash ! expect_hash: print(fFAIL round {r}: hash mismatch) sys.exit(1) print(fPASS round {r}: hash {read_hash}) print(FINAL RESULT: PASS)相比原来的脚本改动集中在三处写命令加了convfsync让写操作在返回前就把数据刷到介质杜绝命令成功但数据没落盘的状态。读之前强制syncdrop_caches确保读到的是介质真实内容而不是缓存。用 Python 计算期望哈希绕开了 shell 工具链的版本差异也让整个判定逻辑更可读。固化到团队之后新增测试项只需要维护一份 pattern 目录脚本逻辑不用动。5.3 三个设计原则比任何代码都重要改完代码之后我把这次排障沉淀成三条原则写在了团队规范里比具体脚本更值钱第一记住命令成功不等于数据正确。命令行工具的退出码只能告诉你操作没报错不能证明数据内容是期望的内容。判断数据正确必须拿实际读回的数据做比对而不是拿退出码做推断。第二自动化测试必须设计独立复核通道。脚本自己测自己本质上有点像用左手测右手。老化测试这种场景尤其要设计一个不由被测脚本控制的验证节点掉电重启、外部工具直读、哈希比对。这个节点越独立对整个测试的可信度越高。第三PASS 不是终点是证据链的起点。一个合格的 PASS 不仅要告诉我通过了还要告诉我因为什么通过了用的什么 pattern、读回的哈希是多少、环境快照长什么样、有没有经过掉电验证。缺任何一项都要回到 FAIL 阵营重新审视。写在最后一句实在话那次凌晨的排障给我留下的不是找到了一个 bug的成就感而是对自动化测试的敬畏。到现在每次看到测试报告里飘着 PASS 两个字我的第一反
返回列表