我最初拿到 RobertFramework 的源码时,第一反应是:读取.robot文件有什么好写的?open()进来,按行split(),一个字典装下,完事。直到我自己动手写了一个解析器、跑通第一版、又在真实用例上炸了几次之后,才发现这个“读取层”恰恰是整个框架最容易藏雷的地方。
RobertFramework 是一个把测试用例写成.robot文本文件的轻量级框架。测试人员用*** Settings ***、*** Variables ***、*** Test Cases ***、*** Keywords ***四个段落组织用例,框架要做的第一件事,就是把这段人类可读的文本变成程序可以拿去调度的测试套件对象。这个系列的第一篇,就是拆解这个过程:从文件字节到结构化对象,中间到底发生了什么,代码里为什么要分那么多层,以及我在实现和排查过程中总结出来的经验。如果你也想给自己的测试工具写一个文件读取模块,或者单纯好奇一个.robot文件是怎么“活”起来的,这篇文章应该对你有用。
1. 一段 .robot 文件样本:读取模块要面对的真实输入
1.1 四段式结构与单元格规则
先看一个典型的.robot文件长什么样。下面是我在 RobertFramework 里经常看到的写法:
*** Settings *** Library DemoLibrary *** Variables *** ${BASE_URL} https://demo.internal/api @{ALLOWED_USERS} alice bob *** Test Cases *** 登录流程 [Documentation] 验证登录功能 [Tags] smoke critical Given 打开登录页 When 输入账号 ${ALLOWED_USERS}[0] And 输入密码 secret123 Then 点击登录 And 页面应包含 欢迎回来 订单查询 [Tags] regression Startup Server Query Order order-0001 Result Should Contain status=paid *** Keywords *** Startup Server Start Test Server --port=8080 Wait Until Ready这个文件的表面信息很简单:四个段落,每行看起来像“名字 + 几个空格 + 值”。但读取模块不能只做表面功夫,它必须区分几类语义完全不同的行。
- 段头行:
*** Settings ***、*** Test Cases ***这种,作用是切换整个文件的解析上下文。 - 无缩进行的标题行:
登录流程、订单查询、Startup Server,它们各自声明一个新的测试用例或关键字。 - 有缩进的行:
[Documentation] 验证登录功能是测试用例的设置项,Given 打开登录页是步骤行,Start Test Server --port=8080是关键字步骤。
这里有个容易被忽略的语法规则:.robot文件里,单元格之间用两个及以上的空格分隔,单个空格属于单元格内容的一部分。所以When 输入账号 ${ALLOWED_USERS}[0]会被切成两个单元格:When 输入账号和${ALLOWED_USERS}[0],而不是把中间的空格当成多余空白丢掉。
1.2 为什么不能靠一个 open() 解决
很多人觉得读取文件就是把文本拉进来,但下面这张表说明了 RobertFramework 读取模块面对的真实复杂度:
| 行类型 | 示例 | 读取层要做什么 |
|---|---|---|
| 段头 | *** Variables *** | 切换当前解析状态 |
| 设置 | Library DemoLibrary | 追加到套件的 settings |
| 变量 | ${HOST} https://demo.internal | 追加到套件的 variables |
| 用例标题 | 登录流程 | 新建一个 TestCase |
| 用例设置 | [Tags] smoke | 写入当前 TestCase 的 tags |
| 步骤 | Then 点击登录 | 追加到当前 TestCase 的 steps |
| 空行与注释 | # 说明 | 直接丢弃 |
如果只用open()然后readlines(),你拿到的只是字符串数组。真正麻烦的是三件事:第一,要把这些字符串按照行类型分好类,而不是一视同仁地存进一个 list;第二,要处理文件编码、换行符、BOM、续行符这些“看不见的干扰”;第三,一旦解析出错,要能指出具体是文件的第几行,方便使用者回去改用例,而不是抛一个不知所云的异常。这些问题叠加在一起,读取模块就必须有一个清晰的内部结构,而不是一个光秃秃的with open(...) as f: return f.readlines()。
2. RobertFramework 读取层的基础设施:三个类各管一段
2.1 数据模型:先给测试用例一个确定的家
读取的最终目的,是生成一个可以传给执行模块的测试套件对象。我先把数据模型定义好,后续所有逻辑才有地方落脚:
# robert/model.py from dataclasses import dataclass, field @dataclass class Variable: name: str value_parts: list source_line: int = 0 @dataclass class TestCase: name: str doc: str = "" tags: list = field(default_factory=list) setup: str = "" teardown: str = "" timeout: str = "" template: str = "" steps: list = field(default_factory=list) @dataclass class Keyword: name: str steps: list = field(default_factory=list) @dataclass class TestSuite: name: str file_path: str settings: list = field(default_factory=list) variables: list = field(default_factory=list) test_cases: list = field(default_factory=list) keywords: list = field(default_factory=list)几个设计上的取舍值得说清楚。
Variable.value_parts我故意存成列表,而不是直接拼成一个字符串。原因是${BASE_URL}和@{ALLOWED_USERS}这两种变量在语义上不一样:前者通常只有一个值,后者天然可能包含多个值。如果读取阶段就强行拼成字符串,执行阶段还要再拆开,反而容易丢失原始结构。读取层只负责“切对”,不负责“解释对”。TestCase.steps同样保存列表的列表,每个步骤就是一组单元格,后续执行器拿到这些列表之后再去做关键字匹配和参数传递。
source_line字段也很重要。测试框架的使用者不是源码作者,报错信息必须友好。如果执行到一个失败步骤,至少能回溯到.robot文件的第几行,而这一步在读取阶段记录行号最方便。
2.2 文件源读取器:只负责把文本变成逻辑行
我把“读文件”和“理解语义”拆成两个类,核心原因是两者关心的东西完全不同。文件源读取器只关心字节、编码、换行、续行;语义解析器只关心缩进、段头、单元格。
# robert/source.py import re from pathlib import Path CELL_SPLIT_RE = re.compile(r" {2,}|\t+") SECTION_HEADER_RE = re.compile(r"^\*\*\*\s*(.+?)\s*\*\*\*\s*$", re.IGNORECASE) CONTINUATION_PREFIX = "..." def leading_width(line: str) -> int: width = 0 for ch in line: if ch == " ": width += 1 elif ch == "\t": width += 4 - (width % 4) else: break return width def split_cells(text: str) -> list: stripped = text.strip() if not stripped: return [] return [cell.strip() for cell in CELL_SPLIT_RE.split(stripped) if cell.strip()]这里的CELL_SPLIT_RE是整个读取逻辑里最关键的一个正则。{2,}表示两个及以上连续空格才作为单元格分隔符,单个空格保留在单元格内部;\t+是对制表符的容错,因为总有人习惯用 Tab 对齐列。
FileSourceReader的职责是产出一个一个的CellLine,它内部已经完成了三件事:物理行到逻辑行的合并、空行和注释行的剔除、单元格切分。
# robert/source.py @dataclass class CellLine: indent: int cells: list lineno: int raw: str class FileSourceReader: def __init__(self, path): self.path = Path(path) def iter_cell_lines(self): with open(self.path, "r", encoding="utf-8-sig", newline="") as fp: buffer = None buffer_raw = "" buffer_lineno = 0 for lineno, raw in enumerate(fp, 1): line = raw.rstrip("\r\n") stripped = line.strip() if not stripped or stripped.startswith("#"): continue if line.lstrip().startswith(CONTINUATION_PREFIX): if buffer is None: raise SourceError( f"{self.path}:{lineno} 出现续行符,但前面没有可续的内容" ) buffer.extend(split_cells(line.lstrip()[len(CONTINUATION_PREFIX):])) continue if buffer is not None: yield CellLine( indent=leading_width(buffer_raw), cells=buffer, lineno=buffer_lineno, raw=buffer_raw, ) buffer = split_cells(line) buffer_raw = line buffer_lineno = lineno if buffer is not None: yield CellLine( indent=leading_width(buffer_raw), cells=buffer, lineno=buffer_lineno, raw=buffer_raw, )这个类不关心*** Test Cases ***是什么意思,也不关心[Tags]该放进哪个字段。它只保证一件事:产出给上层使用的每一行,都是语义上完整的一行。续行符...在这一层就被合并掉了,后面的解析器根本不需要感知物理行拆分。
2.3 语义解析器:状态机让每个格子都有意义
RobotParser是读取模块的“大脑”。它维护一个section状态,根据当前状态决定一行数据应该被解析成什么。
# robert/parser.py class RobotParser: SECTION_MAP = { "settings": "SETTINGS", "variables": "VARIABLES", "test cases": "TEST CASES", "keywords": "KEYWORDS", } def __init__(self, suite_name, file_path): self.suite = TestSuite(name=suite_name, file_path=file_path) self.section = None self.current_test = None self.current_keyword = None def parse(self, cell_lines): for cell_line in cell_lines: if not cell_line.cells: continue header = self._match_section_header(cell_line.cells[0]) if header: self._switch_section(header) continue if self.section == "SETTINGS": self.suite.settings.append({ "name": cell_line.cells[0], "value": cell_line.cells[1:], "lineno": cell_line.lineno, }) elif self.section == "VARIABLES": self.suite.variables.append(Variable( name=cell_line.cells[0], value_parts=cell_line.cells[1:], source_line=cell_line.lineno, )) elif self.section == "TEST CASES": self._parse_test_case_line(cell_line) elif self.section == "KEYWORDS": self._parse_keyword_line(cell_line) return self.suite状态机的思想不复杂:文件是一个流,每一行都会改变或者维持当前状态。段头行负责切换状态,其余行在对应状态下被塞进不同的对象。这个设计最早是我从编译原理里的词法分析借鉴来的,但实现层面比编译器轻量得多。
3. 核心源码拆解:逐行解析时三个必须算清楚的规则
3.1 规则一:两空格以上才算单元格分隔符
split_cells的行为直接决定了后面所有解析是否正确。我遇到过最典型的错误是:用str.split()去掉所有空格,导致步骤里的关键字参数被拆得七零八落。
看这几个例子:
| 输入行 | split_cells结果 |
|---|---|
Log value1 value2 | ["Log", "value1", "value2"] |
Set Name John Smith | ["Set Name", "John Smith"] |
Run Keyword Log hello world | ["Run Keyword", "Log", "hello", "world"] |
第三行里hello world中间有两个空格,所以会被当成单元格分隔符。如果有人希望在步骤里传一个带两个空格的字符串参数,语法上必须用变量或转义,否则读取层无法区分。这不是解析器的 bug,而是.robot这种文本格式本身的约束。读取层要做的是严格遵守这个约束,而不是自作聪明地猜测意图。
实现时还有一个容易忽略的点:切分后要过滤空字符串。比如某一行是两个单元格之间不小心多打了几个空格,re.split会产生空 cell,如果不过滤,后面解析步骤时就会混进空元素,执行阶段匹配关键字就会失败。
3.2 规则二:缩进宽度决定这是用例名还是步骤
.robot文件的语义很大程度靠缩进表达:无缩进的行是声明级内容,有缩进的行是声明下的附属内容。所以解析器在拿到一行时,第一件事不是看内容,而是看它的indent。
def _parse_test_case_line(self, cell_line): cells = cell_line.cells if cell_line.indent == 0: self.current_test = TestCase(name=cells[0]) self.suite.test_cases.append(self.current_test) return if self.current_test is None: raise SourceError(f"{cell_line.lineno} 行出现缩进,但前面没有测试用例标题") if cells[0].startswith("[") and cells[0].endswith("]"): self._apply_setting(self.current_test, cells) else: self.current_test.steps.append(cells)看到没有,判断顺序是先看indent,再看方括号。这一点特别重要,后面第 5 节的坑二会再次提到。
关于 Tab 的缩进计算,我在leading_width里做了一个对齐规则:每个 Tab 从当前宽度补到下一个 4 的倍数。举例来说,当前宽度是 1,遇到一个 Tab,那么这个 Tab 占 3 个空格宽度,总宽度变成 4;当前宽度已经是 4,再遇到一个 Tab,则占 4 个空格宽度,总宽度变成 8。这样做的好处是,就算文件里混用了空格和 Tab,缩进层级依然能稳定判断。
3.3 规则三:段头行引领整个文件的语义切换
段头行的识别用了一个大小写不敏感的正则:
SECTION_HEADER_RE = re.compile(r"^\*\*\*\s*(.+?)\s*\*\*\*\s*$", re.IGNORECASE)*** Test Cases ***会被提取出Test Cases,然后映射成TEST CASES状态。这里有个细节:段头行不一定只有Settings、Variables、Test Cases、Keywords四种。有些.robot文件还会有自定义段落,或者干脆拼错了段落名。我的选择是:未知段头不报错,只是把它当成一个不会做任何处理的段落状态,这样可以兼容一些从其他工具导出的文件。但要注意,如果在SETTINGS段里写错了段落名,后续所有设置都会静默丢失。为了排查方便,解析器遇到未知段头时会把原始行存到suite.settings的_raw字段里,方便调试时追溯。
段头带来的状态切换还意味着:上一个测试用例的容器必须收口。所以在_switch_section里,切换段头之后current_test和current_keyword都会重置为None,避免上一个用例的数据残留到下一个段。
4. 边界条件处理:续行、转义、注释和编码里的暗坑
4.1 续行符 "..." 把物理行重组成逻辑行
用例文件里经常出现很长的文档说明或者变量值,一行写不下。.robot语法规定,下一行以...开头时,表示它是上一行的延续:
${DESCRIPTION} 这是一个非常长的描述,写到第一行还没有结束 ... 所以在这里继续写 *** Test Cases *** 订单查询 [Documentation] 第一行文档 ... 第二行文档处理续行符必须在语义解析之前完成。因为[Documentation]这个设置项在语义上只需要一份完整的文本,如果解析器看到两行物理行,就会把第二行误判成一个独立步骤。
FileSourceReader里的实现逻辑是:遇到...开头的行为,就把...后面的单元格extend到当前 buffer 的尾部,不直接产出一行。直到遇到一个非续行行,才把完整 buffer 包装成CellLine交出去。这里有一个前提:续行符不能出现在文件开头。如果第一行就是...,说明没有可续的内容,直接抛错,越早发现越好。
4.2 读取阶段不碰转义和变量替换
这是我在早期实现里踩过的一个设计坑。一开始我以为读取阶段顺手把${BASE_URL}替换成实际值会很方便,结果马上遇到一个问题:如果变量值本身包含另一个变量引用怎么办?
*** Variables *** ${GREETING} hello ${NAME}如果读取阶段就尝试展开${NAME},而${NAME}还没定义,解析器直接崩溃。就算定义了,展开时机也不对,因为后续执行阶段可能还会修改变量,读取阶段的提前替换会让所有依赖变量的步骤拿到过期数据。
所以 RobertFramework 的读取层严格坚持一条原则:只切分,不解释。反斜杠转义、变量插值、关键字匹配全部留给执行层。读取层看到\${NAME}就原样存\${NAME},执行层遇到反斜杠时再决定是取消转义还是原样输出。这就像编译器只做语法分析,不做语义解释,两者的边界必须清晰。
4.3 注释、空行、BOM、不可见字符的统一清理
- 整行注释:行首是
#的直接跳过。.robot语法没有“行内注释”的概念,如果一行中间出现#,它是值的一部分,读取层不会处理。 - 空行:跳过。空行只起到视觉分隔作用,不影响语义。但要注意,如果用空行来分隔两个测试用例,解析器不会把空行当成“结束信号”,真正结束一个用例的是下一个无缩进行的出现。
- BOM:文件开头如果带有 UTF-8 BOM,直接用普通
utf-8打开会把\ufeff留在第一个字符串前,导致段头正则完全失配。所以默认编码必须用utf-8-sig,它会在读取时自动吞掉文件头的 BOM。 - 行尾
\r\n:用newline=""打开文件,保留原始换行符,然后统一rstrip("\r\n")。如果不做这一步,Windows 下生成的用例文件每一行末尾都会残留一个\r,最后的参数值会多一个不可见字符,执行阶段怎么比对都匹配不上。
5. 实测翻车记录:读取 .robot 文件时最容易踩的三个坑
5.1 坑一:中文 + Windows 记事本 = 乱码
最早我拿一台 Windows 环境下生成的用例文件跑 RobertFramework,结果一上来就是UnicodeDecodeError,报错位置在第 3 行。我第一反应是文件坏了,用十六进制看文件头,发现是EF BB BF。这个 BOM 放在 UTF-8 文件里,普通utf-8读取时通常不会报错,但会让第一个段头变成\ufeff*** Settings ***,正则匹配直接失败。
修复方式很简单,默认编码改成utf-8-sig。但这个坑让我多留了一个心眼:如果用户生成文件的工具不是 UTF-8 呢?后来我碰到另一个文件,打开后中文全是乱码,排查发现那是 UTF-16 编码。于是我在读取器里加了一个编码探测函数:
def _detect_encoding(path): with open(path, "rb") as fp: head = fp.read(4) if head.startswith(b"\xef\xbb\xbf"): return "utf-8-sig" if head.startswith(b"\xff\xfe") or head.startswith(b"\xfe\xff"): return "utf-16" return "utf-8-sig"不要迷信某种编码能解决所有文件。当解码失败时,异常信息里一定要带上文件路径和行号,提示用户检查文件编码,而不是让UnicodeDecodeError裸奔。
5.2 坑二:用例名里的中括号被当成设置项
有一天开发组的同事跑来跟我说,某个用例在结果报告里变成了“已支付”,真正的用例名订单状态[已支付]校验消失了。第一版解析逻辑确实有问题,它简单判断一行只要以[开头就当设置项,于是把用例名里的[已支付]拆成了[Tags]那样的设置行。
排查时打印每个CellLine才发现,用例标题订单状态[已支付]校验的indent是 0,而所有真正的设置项都出现在缩进区。修复顺序就一句话:先判断缩进,再判断方括号。
if cell_line.indent == 0: self.current_test = TestCase(name=cells[0]) elif cells[0].startswith("[") and cells[0].endswith("]"): self._apply_setting(self.current_test, cells) else: self.current_test.steps.append(cells)这个案例的教训是:.robot的语义层次是先看缩进,再看内容。任何“以什么开头就认定是什么”的快速判断,在真实用例文件面前都不可靠。
5.3 坑三:三万条用例加载时的内存毛刺
有一阵子 RobertFramework 跑回归集时,加载阶段经常出现明显卡顿。用内存分析工具看,问题出在早期版本用了readlines(),把几十万行文本一次性读成字符串列表,然后又对每一行做split(),整个过程产生大量临时列表,内存峰值高得离谱。
排查链路大致是这样:
- 先测
load_robot_file()的运行时间和内存曲线,发现时间主要耗在list.append上; - 定位到
readlines()把整个文件一次性加载进内存; - 看
FileSourceReader,发现每一行split_cells()产生的列表又被 parser 里的steps.append一直持有; - 改成
for raw in fp流式迭代,物理行合并成逻辑行之后立即交给 parser,parser 处理完一个CellLine就丢弃引用。
我拿一个十万行级别的用例文件做了前后对比,加载时间从两秒多降到一秒以内,内存峰值下降非常明显。核心经验是:文件读取尽量用迭代器,不要readlines();解析结果按需持有,不要为了图方便把中间过程的临时列表全部囤在内存里。
6. 一次完整的读取走查:从 demo.robot 到套件对象
6.1 调用入口与输出结果
把前面几个类串起来,对外暴露的入口非常简单:
def load_robot_file(path): reader = FileSourceReader(path) parser = RobotParser(suite_name=path.stem, file_path=str(path)) return parser.parse(reader.iter_cell_lines())用开头那份demo.robot跑一遍,得到的结果结构大致如下:
{ "name": "demo", "file_path": "demo.robot", "settings": [ {"name": "Library", "value": ["DemoLibrary"]} ], "variables": [ {"name": "${BASE_URL}", "value_parts": ["https://demo.internal/api"]}, {"name": "@{ALLOWED_USERS}", "value_parts": ["alice", "bob"]} ], "test_cases": [ { "name": "登录流程", "doc": "验证登录功能", "tags": ["smoke", "critical"], "steps": [ ["Given 打开登录页"], ["When 输入账号", "${ALLOWED_USERS}[0]"], ["And 输入密码", "secret123"], ["Then 点击登录"], ["And 页面应包含", "欢迎回来"] ] }, { "name": "订单查询", "doc": "", "tags": ["regression"], "steps": [ ["Startup Server"], ["Query Order", "order-0001"], ["Result Should Contain", "status=paid"] ] } ], "keywords": [ { "name": "Startup Server", "steps": [ ["Start Test Server", "--port=8080"], ["Wait Until Ready"] ] } ] }注意steps里的每一个元素依然是列表,而不是字符串。比如["When 输入账号", "${ALLOWED_USERS}[0]"]里面的${ALLOWED_USERS}[0],读取层并不负责把它解析成变量,也不负责替换。执行层拿到这个结构后,再去查找已注册关键字、展开变量、匹配参数,这样才能做到读取和执行完全解耦。
6.2 为系列后续留的接口:读取层到此为止
这个读取层的设计给后续模块留了一个很舒服的接口:执行器只需要关心TestSuite、TestCase、Keyword这些对象,完全不用理会.robot文件本身有多少语法细节。将来如果想要支持 YAML 或 JSON 格式的用例,只需要新增一个 Source Reader,Parser 和 Model 可以复用大半。
我在重构读取层时最深的体会是:先定数据模型,再定解析规则,最后才写文件遍历。顺序反了,后面所有模块都得跟着遭殃。下一篇我会接着拆 RobertFramework 如何把TestSuite里的步骤变成真正可执行的动作,到时候这些列表元素就会被逐个“唤醒”了。