PyQt5桌面应用开发(21):界面设计结果自动测试(二)
PyQt5桌面应用系列
- PyQt5桌面应用开发(1):需求分析
- PyQt5桌面应用开发(2):事件循环
- PyQt5桌面应用开发(3):并行设计
- PyQt5桌面应用开发(4):界面设计
- PyQt5桌面应用开发(5):对话框
- PyQt5桌面应用开发(6):文件对话框
- PyQt5桌面应用开发(7):文本编辑+语法高亮与行号
- PyQt5桌面应用开发(8):从QInputDialog转进到函数参数传递
- PyQt5桌面应用开发(9):经典布局QMainWindow
- PyQt5桌面应用开发(10):界面布局基本支持
- PyQt5桌面应用开发(11):摸鱼也要讲基本法,两个字,16
- PyQt5桌面应用开发(12):QFile与线程安全
- PyQt5桌面应用开发(13):QGraphicsView框架
- PyQt5桌面应用开发(14):数据库+ModelView+QCharts
- PyQt5桌面应用开发(15):界面动画
- PyQt5桌面应用开发(16):定制化控件-QPainter绘图
- PyQt5桌面应用开发(17):类结构+QWebEngineView
- PyQt5桌面应用开发(18):自定义控件界面设计与实现
- PyQt5桌面应用开发(19):事件过滤器
- PyQt5桌面应用开发(20):界面设计结果自动测试(一)
- PyQt5桌面应用开发(21):界面设计结果自动测试(二)
TDD+UI
上一篇简单说了下什么是测试驱动开发(TDD)。这在软件开发行业是比较常规的做法,因为只要不是完美主义强迫症的TDD,对于软件开发的质量管理是很有帮助的,因为TDD把软件开发的质量管理(计划-执行-检查-改进)的循环变得更加可用。
但是加上UI后,这个问题就变得微妙起来了。UI是一种与人相关的内容,最好的做法当然是用人作为测试,常规开发过程中,也的确是依靠人力来进行测试。
那么采用TDD来开发UI,是否能行呢?答案是肯定的。软件行业,如果说有什么事情是不可行的,那么就只有说别的事情是不可行这个事情是不可行的。万物皆虚,万事皆允。软件开发的魅力不就在这里吗?
第二个问题就是,值得这么做吗?也有不多的几篇文章探讨了这个问题。也有两篇正经发表的会议文章写了这个事情。但是文章那么少就说明一个问题,可能意义不大,收益不高。
为什么?
有几个因素限制了TDD开发UI。
首先,UI的重要元素,视觉,难以测试,写形式化测试很难,所以这部分意义不大,比如界面布局,pyqt5的设计工具里面可以用C+R来观察布局,这个预览功能中,还能完美支持界面缩放这些操作,比如界面动画,那个测试难度可想而知,现在能考虑的最大可能就是截图比较像素,然后用某种大模型来判定,但是整这个意义不大,来个儿童就能完成;
其次,UI的完整状态空间特别大,如果要写一个全状态转换的状态机,显然是不可能的,因为排列的状态是界面交互元素个数阶乘量级,所以这样做的可行性也值得怀疑。
最后,UI的设计中,如果采用MVVM或者MVC模式,一般会尽量把功能、数据、交互分开,这样各个部分的单元测试才得以进行,界面的综合功能,还是采用综合测试的比较多。
这么分析下来,思路这篇文章就没多少意义了。但是我都已经写到这里,骑虎难下,还得硬撑下去。
那么先确定几个原则:
- 有限测试:把TDD作为开发工具,太难的,太麻烦的不搞,不搞完美主义;
- 人工视觉:不测试UI的布局、控件的位置、控件的名字、控件文字标签这些一眼就能看到的东西;
- 开发规范:应用场景考虑为刚入职的萌新提供较小的控件开发输入。
确定这个几个原则之后,我们来设计一个场景。
开发任务
任务设计
假设公司来一个小码,他各项技能点满,什么都能干,于是安排小码带一个团队编个PyQt5的工控。小码把其中的一个功能抽象为一个控件,这个控件要换算露点和相对湿度。
此处忽略饱和蒸气压、湿空气、理想气体、多组分气体分压、露点、相对湿度的内容15000字。我们最终只需要知道露点是压力、温度和相对湿度的函数,对应的通过压力、温度和露点也能唯一计算相对湿度。
D = f ( p , T , R ) D = f(p, T, R) D=f(p,T,R)
R = g ( p , T , D ) R = g(p, T, D) R=g(p,T,D)
最终有一个调库大牛编写了一个库,这个库需要CoolProp(pip install CoolProp
)
来计算湿空气的热力学特性,提供接口全部使用SI单位,这个库提供两个静态方法(类方法)计算上面两个函数。另外这个库还能通过改变
p
p
p,
T
T
T,
D
D
D中任意一个来自动更新相对湿度,如果改变相对湿度,则自动改变露点。最后,这个库还能输入一个字符串元组表达四个值。这个库设计的不咋地,但是你懂的,热力学那帮人怎么会编程序……
from CoolProp.CoolProp import HAPropsSI
class RelativeHumidityModelSI:
"""
HumidAir model with SI units
temperature/dewpoint: K
pressure: Pa
"""
def __init__(self):
self._pressure: float = 1e5
self._temperature: float = 300.0
self._dewpoint: float = 273.0
self._rh: float = self.rh(self._pressure, self._temperature, self._dewpoint)
@classmethod
def rh(cls, p, t, d):
return round(HAPropsSI("R", "P", p, "T", t, "D", d) * 10000) / 100.0
@classmethod
def dp(cls, p, t, r):
return HAPropsSI("D", "P", p, "T", t, "R", r)
def _update_rh(self):
self._rh = self.rh(self._pressure, self._temperature, self._dewpoint)
def _update_dp(self):
self._dewpoint = round(self.dp(self._pressure, self._temperature, self._rh / 100.0) * 100) / 100.0
def str_tuple(self):
return ("{:.2f}".format(i) for i in [self._pressure, self._temperature, self._dewpoint, self._rh])
@property
def pressure(self):
return self._pressure
@pressure.setter
def pressure(self, value):
self._pressure = value
self._update_rh()
@property
def temperature(self):
return self._temperature
@temperature.setter
def temperature(self, value):
self._temperature = value
self._update_rh()
@property
def dewpoint(self):
return self._dewpoint
@dewpoint.setter
def dewpoint(self, value):
self._dewpoint = value
self._update_rh()
@property
def relative_humidity(self):
return self._rh
@relative_humidity.setter
def relative_humidity(self, value):
self._rh = value
self._update_dp()
小码的工作
小码把编写这个PyQt5组件的任务交给了,比如说,小机。为了让小机好好干活对他进行合理恰当的压榨,引入了单元测试,让小机写一个能通过测试的组件,至于组件是否好看,那就看小机自己的眼睛了……
小码前面已经学习过QTest类的各种方法,能够点击按钮、键盘输入、移动鼠标,就只需要再学习一下unittest的函数:
unittest函数一览表
Name | Function |
---|---|
assertTrue | 表达式是否为True。 |
assertFalse | 检查表达式是否为False。 |
assertIs | 等效于assertTrue(a is b)。 |
assertIsNot | 等效于assertTrue(a is not b)。 |
assertIsInstance | 等效于assertTrue(isinstance(obj, cls))。 |
assertNotIsInstance | 与assertIsInstance对应,并非该类的对象。 |
assertIsNone | 等效于assertTrue(obj is None)。 |
assertIsNotNone | 与assertIsNone对应。 |
assertEqual | 运算符’=='意义上的相等。 |
assertNotEqual | 两个对象不相等,含义为’!='。 |
assertGreater | 等效于assertTrue(a > b)。 |
assertGreaterEqual | 等效于assertTrue(a >= b)。 |
assertLess | 等效于assertTrue(a < b)。 |
assertLessEqual | 等效于assertTrue(a <= b),提示信息更有效。 |
assertAlmostEqual | 在一定的允差下的近似相等。 |
assertNotAlmostEqual | 与assertAlmostEqual对应,不在允差范围内。 |
assertIn | 等效于assertTrue(a in b)。 |
assertNotIn | 等价于assertTrue(a not in b) |
assertSequenceEqual | 序列相等(列表或者元组) |
assertCountEqual | 测试集合元素的个数。 |
assertMultiLineEqual | 多行字符串相等。 |
assertTupleEqual | 元组相等。 |
assertListEqual | 列表相等。 |
assertSetEqual | 集合相等。 |
assertDictContainsSubset | 测试两个字典的包含性,一个是另外一个的子集。 |
assertDictEqual | 比较两个字典,assertEqual对字典同样是调用此方法。 |
assertRegex | 匹配到正则表达式。 |
assertNotRegex | 不匹配正则表达式。 |
assertLogs | 日志信息被处罚,按照日志等级来判定。 |
assertWarns | 触发特定的 warnClass |
assertWarnsRegex | 警告消息与regexp吻合。 |
assertRaises | 触发特定的Exception类。 |
assertRaisesRegex | 触发的Exception对应的消息符合正则表达式。 |
废弃,用assertNotEqual替代 | |
废弃,用assertAlmostEqual替代 | |
废弃,用assertEqual替代 | |
废弃,用assertNotAlmostEqual替代 | |
废弃,用assertNotRegex替代 | |
废弃,用assertRaisesRegex替代 | |
废弃,用assertRegex替代 | |
废弃,用assertTrue替代 |
了解这些之后,小码很快就写出了下面的测试代码:
UI单元测试代码
import unittest
from unittest import TestCase
from HtmlTestRunner import HTMLTestRunner
from PyQt5.QtCore import Qt
from PyQt5.QtTest import QTest
from PyQt5.QtWidgets import QLineEdit, QWidget, QApplication, QComboBox, QPushButton
import relative_humidity_coolprop
from relative_humidity_ui import RelativeHumidityWidget
class RelativeHumidityWidgetConstruction(TestCase):
def setUp(self) -> None:
self.app = QApplication([])
self.widget = RelativeHumidityWidget()
self.widget.show()
QTest.qWaitForWindowExposed(self.widget)
def tearDown(self) -> None:
self.app.exit(0)
# four line edit to represent four variables
def test_interact_components(self):
self.assertIsInstance(self.widget, QWidget)
self.assertIsInstance(self.widget.pressureLineEdit, QLineEdit)
self.assertIsInstance(self.widget.temperatureLineEdit, QLineEdit)
self.assertIsInstance(self.widget.dewpointLineEdit, QLineEdit)
self.assertIsInstance(self.widget.relativeHumidityLineEdit, QLineEdit)
self.assertIsInstance(self.widget.calrh, QPushButton)
self.assertIsInstance(self.widget.caldp, QPushButton)
self.assertIsInstance(self.widget.pressureUnit, QComboBox)
cb: QComboBox = self.widget.pressureUnit
pressure_units = ['Pa', 'kPa', 'atm', 'bar', 'hPa']
units = [cb.itemText(i) for i in range(cb.count())]
self.assertCountEqual(pressure_units, units)
self.assertListEqual(pressure_units, units)
self.assertIsInstance(self.widget.temperatureUnit, QComboBox)
cb: QComboBox = self.widget.temperatureUnit
temperature_units = ["K", "℃"]
units = [cb.itemText(i) for i in range(cb.count())]
self.assertCountEqual(temperature_units, units)
self.assertListEqual(temperature_units, units)
self.assertIsInstance(self.widget.dewpointUnit, QComboBox)
cb: QComboBox = self.widget.dewpointUnit
temperature_units = ["K", "℃"]
units = [cb.itemText(i) for i in range(cb.count())]
self.assertCountEqual(temperature_units, units)
self.assertListEqual(temperature_units, units)
def test_initial_values(self):
self.assertAlmostEqual(1e5, self.widget.pressure, delta=1e-5)
self.assertAlmostEqual(300.0, self.widget.temperature, delta=1e-5)
self.assertAlmostEqual(273.0, self.widget.dewpoint, delta=1e-5)
self.assertAlmostEqual(17.07, self.widget.rh, delta=1e-2)
def test_value_accessors(self):
for p, value in zip(
[self.widget.pressureLineEdit,
self.widget.temperatureLineEdit,
self.widget.dewpointLineEdit,
self.widget.relativeHumidityLineEdit],
[lambda: self.widget.pressure,
lambda: self.widget.temperature,
lambda: self.widget.dewpoint,
lambda: self.widget.rh]):
p: QLineEdit
p.setText("akjdlajf")
self.assertRaises(ValueError, lambda: value())
p.setText("1e6")
self.assertAlmostEqual(1e6, value(), delta=1e-6)
def test_cal_relative_humidity(self):
"""
Test calrh button
:return:
"""
# setup line edits for pressure, temperature, and dewpoint
p: QLineEdit = self.widget.pressureLineEdit
t: QLineEdit = self.widget.temperatureLineEdit
d: QLineEdit = self.widget.dewpointLineEdit
p.setText("1e5")
t.setText("300")
d.setText("250")
# click calrh button
QTest.mouseClick(self.widget.calrh, Qt.LeftButton)
rh_expected = relative_humidity_coolprop.RelativeHumidityModelSI.rh(1e5, 300, 250)
# check relative humidity
self.assertAlmostEqual(rh_expected, self.widget.rh, delta=1e-2)
def test_cal_dew_point(self):
"""
Test caldp button
:return:
"""
# setup line edits for pressure, temperature, and relative humidity
p: QLineEdit = self.widget.pressureLineEdit
t: QLineEdit = self.widget.temperatureLineEdit
rh: QLineEdit = self.widget.relativeHumidityLineEdit
p.setText("1e5")
t.setText("300")
rh.setText("50")
# click caldp button
QTest.mouseClick(self.widget.caldp, Qt.LeftButton)
dp_expected = relative_humidity_coolprop.RelativeHumidityModelSI.dp(1e5, 300, 0.5)
# check dew point
self.assertAlmostEqual(dp_expected, self.widget.dewpoint, delta=1e-2)
# set unit to degree C
self.widget.dewpointUnit.setCurrentText("℃")
QTest.mouseClick(self.widget.caldp, Qt.LeftButton)
dp_expected = relative_humidity_coolprop.RelativeHumidityModelSI.dp(1e5, 300, 0.5)
# check dew point return with SI
self.assertAlmostEqual(dp_expected, self.widget.dewpoint, delta=1e-2)
self.assertAlmostEqual(dp_expected - 273, float(self.widget.dewpointLineEdit.text()), delta=1e-2)
def test_change_pressure_unit(self):
self.assertAlmostEqual(1e5, self.widget.pressure, delta=1e-5)
self.widget.pressureUnit.setCurrentText("kPa")
self.assertAlmostEqual(1e5 * 1000, self.widget.pressure, delta=1e-5)
self.widget.pressureUnit.setCurrentText("atm")
self.assertAlmostEqual(1e5 * 101325, self.widget.pressure, delta=1e-5)
self.widget.pressureUnit.setCurrentText("bar")
self.assertAlmostEqual(1e5 * 1e5, self.widget.pressure, delta=1e-5)
self.widget.pressureUnit.setCurrentText("hPa")
self.assertAlmostEqual(1e5 * 1e2, self.widget.pressure, delta=1e-5)
self.widget.pressureUnit.setCurrentText("Pa")
self.assertAlmostEqual(1e5, self.widget.pressure, delta=1e-5)
def test_change_temperature_unit(self):
self.assertAlmostEqual(300, self.widget.temperature, delta=1e-5)
self.widget.temperatureUnit.setCurrentText("℃")
self.assertAlmostEqual(300 + 273, self.widget.temperature, delta=1e-5)
self.widget.temperatureUnit.setCurrentText("K")
self.assertAlmostEqual(300, self.widget.temperature, delta=1e-5)
def test_change_dewpoint_unit(self):
self.assertAlmostEqual(273, self.widget.dewpoint, delta=1e-5)
self.widget.dewpointUnit.setCurrentText("℃")
self.assertAlmostEqual(273 + 273, self.widget.dewpoint, delta=1e-5)
self.widget.dewpointUnit.setCurrentText("K")
self.assertAlmostEqual(273, self.widget.dewpoint, delta=1e-5)
if __name__ == '__main__':
# unittest.main()
suite = unittest.TestLoader().loadTestsFromTestCase(RelativeHumidityWidgetConstruction)
runner = HTMLTestRunner(verbosity=2, output='report', report_name='report', add_timestamp=True,
combine_reports=True)
# unittest.TextTestRunner(verbosity=2).run(suite)
runner.run(suite)
这里为了方便小机编写的空间在其他成立使用,首先小码需要规定那些这个控件是QWidget的子类,还必须提供一些接口,比如获得压力、温度、露点、相对湿度(全部采用SI单位),另外,为了使用方便,还要规定提供表达单位的空间,必须是QComboBox,提供几种单位(压力和温度单位),最后要求设置的数值不合理的时候,要触发ValueError(这个实在太牵强……因为小码也是个渣渣),最后就是改变一个量,对应的相对湿度(或者露点)的变化。
写完这个,小码算是松了一口气。压力给到了小机这边。
控件代码
小机用尽了洪荒之力,反复徘徊于单元测试错误的重压之下,最终提交了一个代码:
import sys
from PyQt5 import uic
from PyQt5.QtWidgets import QWidget, QLineEdit, QApplication, QComboBox, QPushButton
from relative_humidity_coolprop import RelativeHumidityModelSI
def temperature_SI(val, unit):
"""
:param val:
:param unit: K, degC
:return: val in K
"""
if unit == "K":
return val
if unit == "℃":
return val + 273
raise ValueError(f"{unit} should be K or ℃")
def pressure_SI(val, unit):
if unit == "Pa":
return val
if unit == "kPa":
return val * 1e3
if unit == "atm":
return val * 101325
if unit == "bar":
return val * 1e5
if unit == "hPa":
return val * 100
raise ValueError(f"{unit} should be Pa, kPa, atm, bar, or hPa")
class RelativeHumidityWidget(QWidget):
def __init__(self, parent=None):
self.model = RelativeHumidityModelSI()
super(RelativeHumidityWidget, self).__init__(parent)
uic.loadUi("relative_humidity.ui", self)
self.pressureLineEdit: QLineEdit
self.pressureUnit: QComboBox
self.temperatureLineEdit: QLineEdit
self.temperatureUnit: QComboBox
self.dewpointLineEdit: QLineEdit
self.dewpointUnit: QComboBox
self.relativeHumidityLineEdit: QLineEdit
self.calrh: QPushButton
self.caldp: QPushButton
p, t, d, r = self.model.str_tuple()
self.pressureLineEdit.setText(p)
self.temperatureLineEdit.setText(t)
self.dewpointLineEdit.setText(d)
self.relativeHumidityLineEdit.setText(r)
self.calrh.clicked.connect(self.calculate_rh)
self.caldp.clicked.connect(self.calculate_dp)
def calculate_dp(self):
self.model.pressure = self.pressure
self.model.temperature = self.temperature
self.model.relative_humidity = self.rh
p, t, d, r = self.model.str_tuple()
if self.dewpointUnit.currentText() == "℃":
d = "{:.2f}".format(self.model.dewpoint - 273)
self.dewpointLineEdit.setText(d)
def calculate_rh(self):
self.model.pressure = self.pressure
self.model.temperature = self.temperature
self.model.dewpoint = self.dewpoint
p, t, d, r = self.model.str_tuple()
self.relativeHumidityLineEdit.setText(r)
@property
def temperature(self):
value: str = self.temperatureLineEdit.text()
unit: str = self.temperatureUnit.currentText()
return temperature_SI(float(value), unit)
@property
def pressure(self):
value: str = self.pressureLineEdit.text()
return pressure_SI(float(value), self.pressureUnit.currentText())
@property
def dewpoint(self):
value: str = self.dewpointLineEdit.text()
unit: str = self.dewpointUnit.currentText()
return temperature_SI(float(value), unit)
@property
def rh(self):
value: str = self.relativeHumidityLineEdit.text()
return float(value)
if __name__ == '__main__':
app = QApplication([])
widget = RelativeHumidityWidget()
widget.show()
sys.exit(app.exec_())
界面设计,当然是拖几个控件,搞搞布局就行。
对应的ui文件也很简单。
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>696</width>
<height>185</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
<horstretch>1</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,0">
<item>
<layout class="QGridLayout" name="gridLayout" columnstretch="0,1,0" rowminimumheight="1,1,1,1,0">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Pressure</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="dewpointLineEdit"/>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="pressureLineEdit"/>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="temperatureLineEdit"/>
</item>
<item row="2" column="2">
<widget class="QComboBox" name="dewpointUnit">
<item>
<property name="text">
<string>K</string>
</property>
</item>
<item>
<property name="text">
<string>℃</string>
</property>
</item>
</widget>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="relativeHumidityLineEdit"/>
</item>
<item row="0" column="2">
<widget class="QComboBox" name="pressureUnit">
<item>
<property name="text">
<string>Pa</string>
</property>
</item>
<item>
<property name="text">
<string>kPa</string>
</property>
</item>
<item>
<property name="text">
<string>atm</string>
</property>
</item>
<item>
<property name="text">
<string>bar</string>
</property>
</item>
<item>
<property name="text">
<string>hPa</string>
</property>
</item>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Dew Point</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Temperature</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QComboBox" name="temperatureUnit">
<item>
<property name="text">
<string>K</string>
</property>
</item>
<item>
<property name="text">
<string>℃</string>
</property>
</item>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_7">
<property name="text">
<string>RH</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QLabel" name="label_3">
<property name="text">
<string>%</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QPushButton" name="calrh">
<property name="text">
<string>Relative Humidity</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="caldp">
<property name="text">
<string>Dew Point</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::MinimumExpanding</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
测试报告
最终就能够运行python relative_humidity_unittest.py
得到测试报告:
总结
通过完整走一遍TDD驱动的UI开发,小码和小机有几个体会。
- TDD对于UI设计有至少5毛钱的作用,通过编写单元测试,对单一控件的设计概念到设计实现由很好的推动作用;
- 看起来不是很好处理设计变更的……因为测试算例多了之后,跟踪变化将会消耗大量的注意力;
- 有单元测试的UI开发确实挺轻松,这样设计的工作就被很好地分离为功能设计和功能实现,对于某些场景的软件开发组织应该有很高的价值;
- TDD驱动UI设计,真的要注意的是,界面的正式设计文档、UI/UX设计的结果与测试用例之间的一致性问题。
- 行为测试的算例编写实际上进行的是设计工作,这一点必须要弄很清楚,所以编写UI测试用例不应该有一些一蹴而就、三两下就行的奢望,这是设计工作。