QGroundControl QML 组件属性测试实战:Qt Quick Test 属性断言模式与排除规则全解析

原创2026-10-14 19:30:371,815 阅读
文章标签:无人机

QGroundControl QML 组件属性测试实战:Qt Quick Test 属性断言模式与排除规则全解析

本文以 QGroundControl 仓库中 qt-qml-test 技能体系的属性测试参考文档(tools/skills/qt-qml-test/references/qt-quick-test-properties.md)为骨架,系统讲解 QML 组件属性测试的完整模式:默认值、读写往返、计算属性、.background 访问器、颜色断言、属性别名、越界依赖处理,以及四类明确禁止测试的属性。读者学完后,可以直接为 QGroundControl 或任何 Qt 6 QML 组件写出规范、稳定、可被 CTest 收录的 tst_*.qml 属性测试。

属性测试的总原则:只测显式声明的属性

qt-qml-test 技能的 47 条规则构成了生成 QML 测试的契约(完整条文见 tools/skills/qt-qml-test/references/qt-quick-test-rules.md),其中规则 7 是所有属性测试的起点:

只为被测源码中显式定义的属性提出测试;不得为组件继承但未覆写的属性臆造测试。

这意味着测试生成器要先读源码(QML 文件)确认属性由谁声明,再决定断言对象。QGroundControl 的测试基础设施也遵循同一思路:官方 QML 测试示例 test/UnitTestFramework/QmlTesting/tests/tst_Example.qml 中,测试的 clickCount 就是内联组件上显式声明的 property int clickCount: 0。

在此基础上,所有属性测试都依赖两件脚手架(详见 tools/skills/qt-qml-test/references/qt-quick-test-template.md 的规范模板):

  • 单组件:let mybutton = createTemporaryObject(myButtonComponent, root) —— 第二个参数必须传外层 Item { id: root },因为默认父对象 TestCase 是 visible: false,其下创建的子项永远进不了可视树,鼠标事件与焦点检查会静默失败(规则 3);
  • 嵌套组件:let app = createTemporaryObject(myFormComponent, root) 之后用 let object = findChild(app, "<objectName>") 定位子项,随后必须 verify(!!object, "Object exists")(规则 4、5)。

默认值测试:只测"契约默认值"

对新建组件做默认值断言,但只测契约默认值——即下游代码依赖、翻转默认值会改变可观察行为的那些值;跳过实现细节型默认值(字号、内边距、派生尺寸、样式链取值)。判定边界:如果组件之外有任何地方引用了该值,它就是契约;否则只是实现细节。规则 8–11 将某些默认值明确排除(见下文"明确禁止测试的属性"),同一原则可推广到所有属性。

function test_defaultText() {
    let mybutton = createTemporaryObject(myButtonComponent, root)
    verify(!!mybutton, "Component exists")
    compare(mybutton.text, qsTr(""))
}

function test_defaultEnabled() {
    let mybutton = createTemporaryObject(myButtonComponent, root)
    verify(!!mybutton, "Component exists")
    compare(mybutton.enabled, true)
}

文本值一律用 qsTr() 包裹(规则 33),保证测试在翻译环境下仍能正确比较——QGroundControl 的翻译文件位于 translations/(qgc.ts、qgc_source_*.ts 等),若不遵守此约定,测试在非英文 locale 下会因文案被翻译而产生假阴性。

读写属性测试:赋值后断言往返

读写属性(read/write property)的通用测试模式是:赋一个新值,再断言能读回同一个值,验证属性的 setter/getter 往返一致:

function test_textWritesAndReads() {
    let mybutton = createTemporaryObject(myButtonComponent, root)
    verify(!!mybutton, "Component exists")
    mybutton.text = qsTr("Submit")
    compare(mybutton.text, qsTr("Submit"))
}

数值必须使用标准 JavaScript 十进制写法,绝不能用 locale 相关的小数分隔符(规则 32)——99.99 合法,99,99 会让测试在德语等 locale 下失败:

function test_priceWritesAndReads() {
    let priceBox = createTemporaryObject(priceBoxComponent, root)
    verify(!!priceBox, "Component exists")
    priceBox.value = 99.99
    compare(priceBox.value, 99.99)
}

计算(绑定)属性测试:驱动依赖状态

当属性绑定到其他状态时(QML 绑定表达式),测试不能直接赋值给计算属性本身,而要驱动其依赖的输入状态,再断言计算结果:

function test_totalUpdatesWhenItemAdded() {
    let cart = createTemporaryObject(cartComponent, root)
    verify(!!cart, "Component exists")
    cart.itemCount = 3
    cart.unitPrice = 10
    compare(cart.total, 30)
}

若绑定依赖焦点变化,必须在断言前显式设置 focus(规则 28)。下面的例子验证失焦(blur)触发的校验状态:先 focus = true 写入非法文本,再 focus = false 触发校验,最后断言 acceptableInput 为假:

function test_validationStateOnBlur() {
    let app = createTemporaryObject(formComponent, root)
    verify(!!app, "App exists")
    let emailField = findChild(app, "emailField")
    verify(!!emailField, "Object exists")
    emailField.focus = true
    emailField.text = qsTr("not-an-email")
    emailField.focus = false
    verify(!emailField.acceptableInput)
}

注意:对于 RegularExpressionValidator 挂载的输入控件,acceptableInput 是常用断言目标,正确/错误两类输入都要覆盖(规则 38)。计算属性测试还常与动画打交道——如果绑定值被 NumberAnimation 或 Behavior 驱动,则必须用 tryCompare 轮询而非立即 compare(规则 36),因为动画是异步的。

.background 访问器与样式钩子

对于 Qt Quick Controls 样式化控件的 background 属性,必须使用 .background 访问器(规则 6),不能把 background 当作字符串或颜色值直接断言。background 是 Item 子对象,其颜色等属性要通过访问器链读取:

function test_dialBackgroundColor() {
    let app = createTemporaryObject(myFormComponent, root)
    verify(!!app, "App exists")
    let dial = findChild(app, "tempDial")
    verify(!!dial, "Object exists")
    compare(dial.background.color, '#ff0000')
}

同一模式适用于 contentItem、indicator 等以 Item 子对象形式暴露的样式钩子。例如 SpinBox 的 up.indicator / down.indicator 既用于读取,也用于 mouseClick(sb.up.indicator) 驱动 valueModified 信号(交互式信号只能通过真实输入触发,见 tools/skills/qt-qml-test/references/qt-quick-test-controls.md 的 "Interactive-only signals" 表格)。

颜色属性测试规范

颜色断言使用小写十六进制(规则 31):'#ff0000' 而非 '#FF0000';透明色一律写 '#00000000',禁止写 'transparent'——后者是具名字符串,在 compare 时与 color 属性规范化后的十六进制表示不相等,是典型假阴性来源:

function test_warningColor() {
    let badge = createTemporaryObject(badgeComponent, root)
    verify(!!badge, "Component exists")
    badge.severity = "warning"
    compare(badge.color, '#ffaa00')
}

function test_hiddenIsTransparent() {
    let badge = createTemporaryObject(badgeComponent, root)
    verify(!!badge, "Component exists")
    badge.hidden = true
    compare(badge.color, '#00000000')
}

仓库内示例也印证了颜色规范化写法:test/UnitTestFramework/QmlTesting/tests/tst_Example.qml 中 compare(rect.color.toString(), "#ff0000", "Initial color is red")——先 toString() 再比对小写 hex,是颜色断言的可靠姿势。

属性别名测试:按普通读写属性处理

属性别名(property alias)按普通读写属性测试:先在别名上做往返赋值,若底层属性可观察,再确认它同步变化:

function test_textAliasWritesThrough() {
    let labeledButton = createTemporaryObject(labeledButtonComponent, root)
    verify(!!labeledButton, "Component exists")
    labeledButton.label = qsTr("Save")
    compare(labeledButton.label, qsTr("Save"))
}

注意别名暴露的往往是"外层契约":窗口/根组件的 property 别名、外层作用域属性仍应覆盖(规则 46 的补充说明)。

依赖测试范围外组件的属性:宁缺毋滥

当某个属性的值依赖测试范围之外的其他 QML 组件(例如测试无法实例化的兄弟组件、外部提供的 model),应为该属性排除测试(规则 40)。典型的不可测绑定如:

// Source component:
//   property string formattedPrice: priceFormatter.format(value)
// where priceFormatter is provided by the parent and not loaded
// in the test scope.

此时不要为 formattedPrice 写测试,改为直接测可测的 value:

function test_valueAssignment() {
    let priceBox = createTemporaryObject(priceBoxComponent, root)
    verify(!!priceBox, "Component exists")
    priceBox.value = 42
    compare(priceBox.value, 42)
}

规则 40 还覆盖另一种隐蔽情形:当属性有效值被 State { PropertyChanges { ... } } 块改写时,设置兄弟绑定的右侧并不会翻转绑定的输出——活动状态的 PropertyChanges 会覆盖它。此时应驱动状态的 guard 属性(state 的 when 条件),或干脆跳过该断言。两个判据可以合并成一条决策链:该属性能否在测试作用域内被完全确定?能,则测;不确定,则省略。生成测试的目标是"干净、可验证",宁缺毋滥。

明确禁止测试的四类属性(规则 8–11)

即使这些属性出现在被测组件上,也不得为其生成测试:

属性 规则 原因
appControl 尺寸 规则 8 顶层尺寸由测试宿主窗口决定,断言它必然产生 flaky 测试
anchors 属性 规则 9 anchors 是布局原语,不是单元测试要验证的值
currentIndex 规则 10 与模型状态强耦合,容易造成测试与源码的耦合
cursorVisible 规则 11 由焦点与光标闪烁定时器驱动,无头测试环境下不可靠

若被测源码把这些属性作为核心行为,改为测试可观察的副作用。例如 currentIndex 被禁止直测,就改测选择变化时发出的信号;cursorVisible 不测,就改测焦点变化驱动的其他可观察属性。这种"测副作用"的思路与 QGroundControl 的 C++ UI 测试一致——test/QmlUITests/FlyViewCameraCaptureUITest.cc 不直接断言按钮内部状态,而是验证第二次点击后 MAVLink 侧收到 MAV_CMD_IMAGE_STOP_CAPTURE 命令计数为 1、相机回到 CapturePhotosStateIdle,即通过外部可观察结果确认行为。相关反模式与症状对照可查 tools/skills/qt-qml-test/references/qt-quick-test-pitfalls.md。

测试编写约定:消息、objectName 与存在性校验

属性测试还须遵守两条横切约定:

  1. compare / verify 不写自定义消息(规则 30)。唯一的三个规范形式是 verify(!!x, "Object exists")(findChild 之后以及 createTemporaryObject 返回的非顶层句柄)、verify(!!x, "Component exists")(单组件测试的顶层实例化)、verify(comp.status === Component.Ready, comp.errorString())(Qt.createComponent 状态检查,errorString() 是诊断值必须保留)。其余断言一律无消息——Qt Test 默认失败信息已经足够且风格统一。

  2. 凡测试经 findChild 到达的后代都必须声明 objectName(规则 5、46),并用 verify(!!object, "Object exists") 校验结果。QML 的 id 是文档作用域的,跨文档查找只能靠 objectName 解析。QGroundControl 源码中随处可见这一约定,例如 src/QmlControls/ParameterDiffDialog.qml 中的 objectName: "diffCheckAllCheckbox"。若被测子项只有 id 没有 objectName,测试会退化成只含外层薄断言——正确做法是给源码补一行 objectName: "<id>" 后再生成测试,而不是用 parent.children 索引或 toString() 匹配等脆弱变通(规则 46 明确禁止,因为它们会因源码的装饰性改动而失效)。

在 QGroundControl 中运行 QML 属性测试

生成的 tst_*.qml 可以接入 QGroundControl 现有的 Qt Quick Test 执行链(构建与运行配置见 test/UnitTestFramework/QmlTesting/CMakeLists.txt):

  • 测试文件放在 test/UnitTestFramework/QmlTesting/tests/ 下,命名必须是 tst_*.qml(见 test/UnitTestFramework/QmlTesting/QmlTesting.h 的说明);
  • 仓库提供独立的离进程测试可执行文件 QGCQmlQuickTests(通过 quick_test_main() 驱动),QML 测试不能在内嵌于标准 UnitTest harness 的进程中运行;
  • 通过 CTest 运行:ctest -R QmlQuickTests;测试以 -platform offscreen 启动,并设置 QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software 环境——这正是属性测试中"隐式尺寸在 offscreen 下可能退化为 0×0、合成点击落空"(规则 44)的根源,因此在模板的 Component 块里给被测组件显式设置 width/height 是标配;
  • 属性测试中凡依赖渲染与焦点场景的断言,TestCase 都要写 when: windowShown,否则断言在场景可见之前运行而产生假阴性。

属性测试是 Qt Quick 组件测试中最基础也最容易踩坑的一环:锁定"只测显式声明属性"的边界,用契约默认值、读写往返、绑定驱动三类核心模式覆盖可测路径,用 .background 访问器与规范化颜色/数值/文本写法消除假阴性,再用规则 8–11、40 明确划出"不测"的禁区,就能产出干净、稳定、可长期维护的 tst_*.qml。

登录后查看全文
qgroundcontrol