Python桌面应用自动化测试实战:基于pytest的分层测试方案设计
1. 项目概述与背景
最近在折腾一个老项目,需要为Pelco KBD300A键盘的模拟器软件做自动化测试。Pelco KBD300A是安防监控领域里一款经典的硬件控制键盘,很多老的矩阵系统、DVR/NVR都靠它来操作。我们做的这个模拟器,说白了就是在电脑上用一个软件完全复现这个键盘的所有功能,让用户在没有实体键盘的情况下,也能通过鼠标、快捷键或者网络指令来控制后端的监控设备。这个项目本身不算新,但代码经过几轮迭代,功能越加越多,手动测试的负担也越来越重。每次改点代码,都得把几十个按键功能、各种协议通讯(比如Pelco-D/P)从头到尾点一遍,费时费力还容易漏测。所以,上自动化测试就成了一个必然的选择。
为什么选pytest?这几乎是Python生态里做单元测试和中小型集成测试的事实标准了。它比Python自带的unittest更简洁灵活,夹具(fixture)机制强大,插件生态丰富,写起测试用例来非常符合Pythonic的“感觉”。对于我们的模拟器项目,它既有纯Python的业务逻辑(比如协议解析、状态管理),也有PyQt/PySide构建的图形界面,还可能涉及一些串口或网络模拟。pytest能很好地覆盖这些层面,从底层的函数单元测试,到模块间的集成测试,甚至结合一些工具做简单的界面操作模拟,都能在一个框架下搞定。这次的目标,就是为这个KBD300A模拟器项目,规划一套切实可行、易于维护的pytest自动化测试方案。
2. 测试方案整体设计与思路拆解
给一个既有GUI又有底层逻辑的桌面应用做自动化测试,不能一上来就埋头写用例。我的思路是分层、分模块进行,核心是 “核心逻辑与界面解耦” 。
2.1 测试金字塔与分层策略
我遵循经典的测试金字塔模型,但根据桌面应用的特点做了调整:
-
单元测试(底层,量最大) :针对模拟器的核心业务逻辑。这包括:
- 协议解析模块 :测试Pelco-D、Pelco-P等控制协议的命令生成与解析函数。输入摄像头地址、指令类型(如云台转动、镜头变倍),断言输出的字节串是否正确。
- 键盘状态机 :测试键盘模式切换(如光标模式、预置点调用模式)、按键队列处理等逻辑。模拟按键事件,断言内部状态是否正确转移。
- 配置管理 :测试串口参数、网络设置、快捷键映射等配置的加载、保存和验证逻辑。
- 工具类函数 :所有纯计算的、无副作用的工具函数。
- 这一层完全使用pytest, mocking掉所有外部依赖(如串口、网络、文件系统),追求执行速度极快,毫秒级完成。
-
集成测试(中层,承上启下) :测试模块之间的协作。这是本项目的关键层。
- 协议模块与通讯模块的集成 :测试协议解析器生成的命令,是否能正确通过“虚拟串口”或“虚拟Socket”发送出去。这里我们会使用一些测试替身,比如用
unittest.mock模拟一个串口对象,检查write方法是否被以预期的参数调用。 - 业务逻辑与数据模型的集成 :测试用户操作(通过模拟的事件)如何触发业务逻辑,并更新数据模型(如当前控制的摄像头ID、云台速度)。例如,模拟一个“方向键按下”事件,断言业务逻辑层是否正确地生成了协议命令,并且数据模型中的“当前速度”值是否被更新。
- 这一层会引入有限的、可控的外部依赖模拟,重点验证接口契约和数据流。
- 协议模块与通讯模块的集成 :测试协议解析器生成的命令,是否能正确通过“虚拟串口”或“虚拟Socket”发送出去。这里我们会使用一些测试替身,比如用
-
端到端(E2E)测试(顶层,量少但重要) :从用户角度验证完整功能。对于GUI程序,这是最棘手但最能体现价值的。
- 核心用户流程验证 :例如,“启动模拟器 -> 选择串口 -> 设置摄像头地址 -> 按下方向键 -> 验证是否发送了正确的协议数据”。这需要自动化工具模拟用户操作界面。
- 对于PyQt/PySide,我们不会直接依赖重量级的UI自动化工具(如针对编译后exe的)去操作原生窗口,而是采用一种更轻量、更稳定的方法:直接调用Qt Widgets的方法,或者使用内存中的QApplication进行测试。 例如,获取界面上的某个按钮对象,直接调用其
click()方法,然后断言相应的信号是否发射,或者业务逻辑是否被触发。这避免了界面渲染不稳定、控件查找失败等问题,将E2E测试的重点放在“逻辑”而非“渲染”上。 - 这一层用例数量会严格控制,只覆盖最重要的、跨多个模块的“快乐路径”。
2.2 技术栈与工具选型
- 核心框架 : pytest 。毫无争议的选择。它的断言写法直观 (
assert something == expected),夹具系统可以优雅地管理测试资源(如临时配置文件、模拟的通讯接口)。 - 测试替身与模拟 : pytest-mock (或直接使用
unittest.mock)。用于模拟串口 (serial.Serial)、网络套接字 (socket.socket)、文件操作等。这是实现单元测试和集成测试隔离性的关键。 - GUI测试辅助 : Qt for Python (PySide6/PyQt5) 的测试工具 。利用
QTest模块来发送模拟的鼠标键盘事件,或者更直接地,获取界面控件对象进行方法调用和状态检查。 我们明确不首选Selenium for Desktop(如pyautogui, SikuliX)这类基于图像识别或屏幕坐标的工具,因为它们过于脆弱,对UI布局变化极度敏感。 - 测试数据管理 : JSON/YAML文件 + pytest的
@pytest.mark.parametrize装饰器 。将测试用例数据(如不同的协议命令参数组合)外置到文件中,实现数据与代码分离,便于维护和扩展。 - 持续集成 : GitHub Actions / GitLab CI 。规划在代码推送后自动运行测试套件,特别是单元测试和集成测试层,快速反馈代码质量。
2.3 项目目录结构规划
一个清晰的目录结构是测试方案可持续的基础。我建议在项目根目录下设立一个 tests 目录,结构如下:
KBD300A_Simulator/
├── src/ # 项目源代码
│ ├── core/ # 核心逻辑(协议解析、状态机)
│ ├── gui/ # 用户界面
│ ├── communication/ # 串口、网络通讯
│ └── utils/ # 工具函数
├── tests/ # 测试代码根目录
│ ├── unit/ # 单元测试
│ │ ├── core/ # 对应src/core的测试
│ │ ├── communication/ # 对应src/communication的测试(大量使用mock)
│ │ └── utils/ # 对应src/utils的测试
│ ├── integration/ # 集成测试
│ │ ├── test_protocol_to_comms.py # 协议与通讯集成
│ │ └── test_gui_to_core.py # 界面与核心逻辑集成
│ ├── e2e/ # 端到端测试(少量)
│ │ └── test_basic_workflow.py
│ ├── conftest.py # 全局pytest配置和共享fixture
│ ├── data/ # 存放测试数据文件(JSON/YAML)
│ └── fixtures/ # 复杂的、可复用的fixture定义
├── pytest.ini # pytest配置文件
└── requirements-test.txt # 测试环境依赖
3. 核心模块测试策略详解
3.1 协议解析模块的单元测试
这是模拟器最核心、最稳定的部分,非常适合做彻底的单元测试。
测试重点:
- 命令生成函数 :给定输入参数(地址、命令类型、数据),输出是否符合Pelco-D/P协议规范。例如,测试
generate_ptz_command(address=1, command='LEFT', speed=0x20)是否返回正确的字节数组[0xFF, 0x01, 0x00, 0x04, 0x20, 0x00, 0x25](示例,非真实Pelco-D)。 - 命令解析函数 :给定一个字节数组,是否能正确解析出地址、命令和参数。这对于模拟器“接收”来自设备的应答信号很重要。
- 校验和计算 :协议中的校验和计算逻辑必须100%正确。
实操示例与技巧:
# tests/unit/core/test_protocol.py
import pytest
from src.core.protocol import PelcoDProtocol
class TestPelcoDProtocol:
@pytest.mark.parametrize("address, command, pan_speed, tilt_speed, expected_bytes", [
(1, 'STOP', 0x00, 0x00, [0xFF, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01]),
(1, 'LEFT', 0x20, 0x00, [0xFF, 0x01, 0x00, 0x04, 0x20, 0x00, 0x25]),
# ... 更多测试用例
])
def test_generate_ptz_command(self, address, command, pan_speed, tilt_speed, expected_bytes):
"""测试PTZ命令生成"""
protocol = PelcoDProtocol()
result = protocol.generate_ptz_command(address, command, pan_speed, tilt_speed)
# 断言生成的字节列表完全匹配预期
assert list(result) == expected_bytes
# 额外断言:校验和是否正确(如果协议类内部计算)
# assert result[-1] == calculate_checksum(result[1:-1])
def test_parse_incoming_packet(self):
"""测试解析接收到的数据包"""
protocol = PelcoDProtocol()
# 模拟一个来自设备的“预置点调用成功”应答包
fake_packet = bytes([0xFF, 0x01, 0x00, 0x07, 0x00, 0x02, 0x0A])
parsed = protocol.parse_packet(fake_packet)
assert parsed['address'] == 1
assert parsed['command'] == 'PRESET_CALL_ACK'
assert parsed['preset_number'] == 2
注意: 协议测试的数据(
expected_bytes)必须100%准确。建议从官方协议文档、设备实际通讯抓包(如用串口调试助手)或现有稳定版本的输出中获取,绝不能靠“猜测”。一个字节的错误都可能导致真实设备无法识别命令。
3.2 通讯模块的集成测试(使用Mock)
通讯模块(串口、UDP/TCP)直接与外部环境交互,必须用Mock进行隔离测试。
测试重点:
- 发送逻辑 :业务层调用通讯模块的发送接口时,底层串口或Socket的write/sendto方法是否被正确调用,且参数正确。
- 接收与回调 :模拟底层收到数据,通讯模块是否能正确解析并触发预设的回调函数,将数据传递给业务层。
实操示例与技巧:
# tests/integration/test_protocol_to_comms.py
import pytest
from unittest.mock import Mock, call, patch
from src.core.protocol import PelcoDProtocol
from src.communication.serial_manager import SerialManager
class TestProtocolToSerialIntegration:
@pytest.fixture
def mock_serial_port(self):
"""创建一个模拟的串口对象"""
port = Mock()
port.write = Mock()
return port
def test_send_ptz_command_via_serial(self, mock_serial_port):
"""测试协议命令通过串口管理器发送"""
# 1. 创建协议对象和串口管理器,注入模拟的串口
protocol = PelcoDProtocol()
manager = SerialManager(port_instance=mock_serial_port) # 假设构造函数支持注入
# 2. 生成命令
command_bytes = protocol.generate_ptz_command(1, 'LEFT', 0x20, 0x00)
# 3. 通过管理器发送
manager.send_data(command_bytes)
# 4. 断言:模拟串口的write方法被调用了一次,且参数是预期的字节串
mock_serial_port.write.assert_called_once()
call_args = mock_serial_port.write.call_args
assert call_args[0][0] == command_bytes # 检查传入的第一个参数
# 5. 更严格的断言:检查写入的精确字节
expected_bytes = bytes([0xFF, 0x01, 0x00, 0x04, 0x20, 0x00, 0x25])
assert call_args[0][0] == expected_bytes
@patch('src.communication.serial_manager.serial.Serial') # 模拟整个serial.Serial类
def test_serial_manager_initialization(self, MockSerialClass):
"""测试串口管理器初始化时是否正确打开了串口"""
mock_port_instance = Mock()
MockSerialClass.return_value = mock_port_instance
manager = SerialManager(port_name='COM3', baudrate=9600)
# 断言 serial.Serial 被以正确的参数调用
MockSerialClass.assert_called_once_with('COM3', baudrate=9600, timeout=1)
# 断言 open() 方法被调用(如果Serial初始化后需要显式open)
# assert mock_port_instance.open.called
实操心得: 在Mock时,要弄清楚你模拟的对象的“行为契约”。是模拟一个类(
@patch(‘module.ClassName’))还是模拟一个实例(在fixture中创建Mock()对象)?assert_called_once_with非常强大,它能同时检查调用次数和参数,是集成测试中的利器。对于异步或回调式的通讯,可以使用Mock的side_effect属性来模拟复杂行为。
3.3 GUI与业务逻辑的集成测试
这是桌面应用测试的难点。我们的策略是 “绕过UI渲染,直接测试逻辑连接” 。
测试重点:
- 信号与槽连接 :确保界面控件发出的信号(如
button.clicked)正确连接到业务逻辑的槽函数。 - 控件状态映射 :用户操作界面控件(如选择摄像头地址的下拉框),业务模型中的相应数据是否同步更新。
- 业务逻辑触发 :界面操作是否能正确触发核心业务函数(如协议生成、命令发送)。
实操示例与技巧:
# tests/integration/test_gui_to_core.py
import pytest
from PySide6.QtWidgets import QApplication, QComboBox
from PySide6.QtCore import Qt
from src.gui.main_window import MainWindow
from src.core.device_manager import DeviceManager
# 需要一个QApplication实例来运行任何Qt GUI测试
@pytest.fixture(scope="session")
def qapp():
"""全局的QApplication fixture,整个测试会话只存在一个实例"""
app = QApplication.instance()
if app is None:
app = QApplication([])
yield app
# 测试结束后,不需要主动退出,由pytest管理
class TestMainWindowIntegration:
@pytest.fixture
def main_window(self, qapp):
"""创建并返回主窗口实例,测试完成后自动清理"""
window = MainWindow()
yield window
window.close()
def test_camera_address_selection_updates_model(self, main_window):
"""测试界面选择摄像头地址后,设备管理器的当前地址被更新"""
# 1. 获取界面上的地址选择下拉框(假设其对象名为cameraAddressCombo)
combo_box = main_window.findChild(QComboBox, "cameraAddressCombo")
# 或者通过窗口的公开属性访问:combo_box = main_window.ui.cameraAddressCombo
# 2. 获取业务层的设备管理器(假设主窗口持有其引用)
device_manager = main_window.device_manager
# 3. 记录初始状态
initial_address = device_manager.current_camera_address
# 4. 模拟用户操作:直接设置下拉框的当前索引(不经过鼠标事件)
test_address_index = 3 # 假设索引3对应地址4
combo_box.setCurrentIndex(test_address_index)
# 触发currentIndexChanged信号(如果连接不是自动的,可能需要手动emit)
combo_box.currentIndexChanged.emit(test_address_index)
# 5. 断言业务模型中的数据已更新
expected_address = combo_box.currentData() # 假设下拉框的itemData存储了实际地址
assert device_manager.current_camera_address == expected_address
assert device_manager.current_camera_address != initial_address
def test_ptz_button_click_triggers_command(self, main_window, mocker):
"""测试点击PTZ方向按钮会触发命令生成和发送"""
# 1. Mock业务层的关键函数,避免真实发送数据
mock_send_command = mocker.patch.object(main_window.device_manager, 'send_ptz_command')
# 2. 获取方向键按钮(例如“左转”按钮)
left_button = main_window.findChild(QPushButton, "ptzLeftButton")
# 3. 模拟点击:直接调用click()槽函数
left_button.click()
# 4. 断言:业务层的send_ptz_command被以正确的参数调用
mock_send_command.assert_called_once()
# 检查调用参数,例如命令类型是'LEFT',速度是当前设置值
call_args = mock_send_command.call_args
assert call_args[0][0] == 'LEFT' # 第一个位置参数是命令
# 可以进一步断言速度参数来自界面上的速度滑块
speed_slider = main_window.findChild(QSlider, "ptzSpeedSlider")
expected_speed = speed_slider.value()
assert call_args[0][1] == expected_speed # 第二个位置参数是速度
重要提示: GUI测试容易因为界面控件查找失败(对象名不对、控件未即时创建)而变得脆弱。务必确保在测试中使用的控件对象名(
objectName)与UI设计文件(.ui)或代码中设置的一致。使用findChild配合类型和对象名是相对稳定的方式。 永远不要依赖控件的屏幕坐标或顺序索引。
4. Pytest Fixture 的实战设计与应用
Fixture是pytest的灵魂,能极大提升测试代码的复用性和可读性。针对KBD300A模拟器项目,我设计了几个核心Fixture。
4.1 模拟硬件接口的Fixture
# tests/conftest.py
import pytest
from unittest.mock import Mock, MagicMock
import serial
@pytest.fixture
def mock_serial_port():
"""提供一个配置好的模拟串口对象"""
port = Mock(spec=serial.Serial) # 使用spec确保模拟对象具有Serial的接口
port.write = Mock()
port.read = Mock(return_value=b'') # 默认返回空字节
port.is_open = True
# 可以模拟readline、read_until等常用方法
port.readline = Mock(return_value=b'ACK\r\n')
yield port
# 清理(如果需要)
@pytest.fixture
def mock_socket():
"""提供一个模拟的UDP/TCP socket对象"""
sock = Mock(spec=socket.socket)
sock.sendto = Mock()
sock.recvfrom = Mock(return_value=(b'Simulated Response', ('192.168.1.100', 5000)))
yield sock
@pytest.fixture
def serial_manager_with_mock(mock_serial_port):
"""提供一个注入了模拟串口的SerialManager实例"""
from src.communication.serial_manager import SerialManager
# 假设我们可以通过参数或setter注入端口实例
manager = SerialManager()
manager._port = mock_serial_port # 通过私有属性或特定方法注入
return manager
4.2 测试数据驱动的Fixture
# tests/conftest.py
import pytest
import json
import os
def load_test_data(data_file_name):
"""辅助函数:从JSON文件加载测试数据"""
data_dir = os.path.join(os.path.dirname(__file__), 'data')
file_path = os.path.join(data_dir, data_file_name)
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
@pytest.fixture(params=load_test_data('pelco_d_commands.json'))
def pelco_d_command_case(request):
"""参数化fixture:为每个Pelco-D命令测试用例提供数据"""
# request.param 是来自JSON文件的一个字典,例如:
# {"name": "PAN_LEFT", "address": 1, "command": "LEFT", "speed": 32, "expected": "FF010004200025"}
yield request.param
# 在测试用例中使用
def test_pelco_d_command_generation(pelco_d_command_case):
protocol = PelcoDProtocol()
result = protocol.generate_ptz_command(
pelco_d_command_case['address'],
pelco_d_command_case['command'],
pelco_d_command_case.get('pan_speed', 0),
pelco_d_command_case.get('tilt_speed', 0)
)
expected_bytes = bytes.fromhex(pelco_d_command_case['expected'])
assert result == expected_bytes
4.3 临时环境与资源管理的Fixture
# tests/conftest.py
import pytest
import tempfile
import os
@pytest.fixture
def temp_config_file():
"""创建一个临时的配置文件,测试完成后自动删除"""
# 创建临时文件,并写入默认配置内容
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as f:
default_config = {
"serial_port": "COM1",
"baudrate": 9600,
"default_camera": 1
}
json.dump(default_config, f)
temp_path = f.name
yield temp_path # 将文件路径提供给测试用例
# 测试结束后,清理临时文件
os.unlink(temp_path)
@pytest.fixture
def app_with_temp_config(temp_config_file):
"""创建一个使用临时配置文件的应用上下文"""
from src.config import AppConfig
from src.gui.main import Application
# 让应用加载临时配置文件
original_load_path = AppConfig.get_config_path
AppConfig.get_config_path = lambda: temp_config_file
app = Application()
yield app
# 恢复原始配置路径函数
AppConfig.get_config_path = original_load_path
app.quit()
5. 测试用例组织与标记策略
当测试用例数量增长后,如何高效地运行和管理它们至关重要。
5.1 使用pytest.mark进行标记
在 pytest.ini 中定义自定义标记:
# pytest.ini
[pytest]
markers =
slow: 标记运行缓慢的测试(如涉及真实等待的集成测试)。
gui: 需要GUI环境的测试。
serial: 需要真实或模拟串口的测试。
protocol: 协议解析相关测试。
unit: 快速运行的单元测试。
integration: 集成测试。
e2e: 端到端测试。
在测试用例上使用标记:
# tests/unit/core/test_protocol.py
import pytest
@pytest.mark.unit
@pytest.mark.protocol
class TestPelcoDProtocol:
...
@pytest.mark.integration
@pytest.mark.serial
def test_serial_communication():
...
@pytest.mark.e2e
@pytest.mark.gui
@pytest.mark.slow
def test_complete_workflow():
...
5.2 运行策略示例
- 快速反馈(开发中) :只运行单元测试。
pytest -m "unit" -v - 提交前检查 :运行单元测试和集成测试。
pytest -m "not slow and not e2e" -v - 完整回归(CI/CD中) :运行所有测试。
pytest -v - 仅运行GUI相关测试 :
pytest -m "gui" -v - 跳过标记为slow的测试 :
pytest -m "not slow" -v
5.3 测试报告与覆盖率
集成 pytest-html 和 pytest-cov 来生成美观的报告。
# 运行测试并生成HTML报告和覆盖率报告
pytest --html=report.html --self-contained-html --cov=src --cov-report=html --cov-report=term-missing -v
这会在当前目录生成 report.html (测试结果)和 htmlcov 目录(覆盖率详情),可以非常直观地看到哪些代码被测试覆盖,哪些没有。
6. 常见问题与排查技巧实录
在实际搭建和运行这套测试方案的过程中,我踩过不少坑,这里总结一下。
6.1 GUI测试中的“幽灵窗口”与事件循环
问题 :在测试中创建了QWidget(如MainWindow),测试结束后窗口没有正确销毁,或者需要手动处理事件循环 ( QApplication.exec() ),否则一些信号槽异步逻辑无法触发。
解决方案 :
- 使用Session级别的QApplication Fixture :如前文所示,确保整个测试会话只有一个
QApplication实例。 - 在Fixture中yield后清理 :对于每个测试创建的窗口,在fixture的yield之后调用
window.close()和window.deleteLater()。 - 使用
QTest.qWaitFor:对于需要等待界面更新的操作,不要用time.sleep,而是用QTest.qWaitFor(lambda: condition, timeout)。from PySide6.QtTest import QTest def test_button_enabled_after_load(self, main_window): load_button = main_window.findChild(QPushButton, "loadButton") load_button.click() # 等待某个条件成立,比如一个标签文本改变 QTest.qWaitFor(lambda: status_label.text() == "Loaded", timeout=2000) assert process_button.isEnabled()
6.2 Mock对象行为不符合预期
问题 :Mock了某个对象,但测试运行时发现实际调用的并不是这个Mock对象,或者调用次数/参数不对。
排查技巧 :
- 检查导入路径(Import Path) :这是最常见的问题。
@patch(‘module.ClassName’)中的路径必须是 被测代码中导入该类的路径 ,而不是该类的定义路径。使用print(module.ClassName)在被测代码中查看其完整导入路径。 - 使用
autospec=True:在patch时设置autospec=True,可以让Mock对象严格模仿原始对象的接口,如果调用不存在的方法会报错,有助于发现问题。@patch('src.communication.serial_manager.serial.Serial', autospec=True) - 检查Mock的调用记录 :使用
mock_obj.mock_calls或call_args_list查看所有调用记录,与预期进行对比。print(mock_serial.write.call_args_list) # 打印所有调用历史
6.3 测试依赖与状态污染
问题 :测试用例A修改了某个全局状态或类变量,导致测试用例B运行失败。
解决方案 :
- 充分利用Fixture的
scope和独立性 :默认function级别的fixture为每个测试函数提供全新的实例。对于需要共享但需隔离的状态,使用合适的scope(如class,module),并在fixture内部做好初始化。 - 使用
setup_method/teardown_method或 Fixture Finalizer :在测试类中,可以重写setup_method来为每个测试方法初始化环境,用teardown_method来清理。 - 避免使用真正的单例或全局变量 :在测试中,尽量通过依赖注入(如将
mock_serial_portfixture传入)来提供依赖,而不是让代码去访问一个全局的、难以替换的单例对象。如果原有代码结构如此,考虑在测试时使用monkeypatch临时替换全局变量。
6.4 异步操作测试
问题 :模拟器可能有异步处理,比如定时轮询串口、网络异步回调。测试需要等待这些异步操作完成。
解决方案 :
- 模拟时间 :对于定时器,可以使用
unittest.mock.patch替换QTimer或threading.Timer,直接触发其回调函数,避免真实等待。def test_polling_timer(mocker): mock_timer_class = mocker.patch('PySide6.QtCore.QTimer') mock_timer_instance = Mock() mock_timer_class.return_value = mock_timer_instance # 创建你的管理器,它会内部启动一个QTimer manager = SomePollingManager() # 断言QTimer被创建并启动了 mock_timer_instance.start.assert_called_once_with(1000) # 假设间隔1秒 # 直接手动调用定时器的槽函数,模拟超时 manager._on_polling_timeout() # 调用内部的槽函数 # 然后断言预期的行为发生了 - 使用信号等待 :如果异步操作最终会发射一个Qt信号,可以使用
QSignalSpy来捕获和等待信号。from PySide6.QtTest import QSignalSpy def test_async_operation(self, some_object): # 创建一个信号间谍,监视some_object的operationFinished信号 spy = QSignalSpy(some_object.operationFinished) # 触发异步操作 some_object.start_async_operation() # 等待信号被发射,最多等2秒 assert spy.wait(2000) # 可以进一步检查信号携带的参数 if len(spy) > 0: result = spy[0][0] # 第一个信号的第一个参数 assert result == "success"
规划这套pytest自动化测试方案,核心思想是 “以终为始,逐步构建” 。不要试图一开始就写出覆盖100%的完美测试。先从最核心、最稳定的协议解析模块的单元测试开始,快速获得正反馈和信心。然后逐步向外围扩展,增加集成测试,最后谨慎地添加关键的E2E测试。同时,把测试基础设施(如conftest.py中的fixture、测试数据文件)当作项目代码一样精心设计和维护,它们会随着项目一起成长,并成为未来开发最坚实的保障。对于KBD300A模拟器这类既有复杂逻辑又有用户界面的项目,这样一套分层、解耦的测试方案,是保证其长期稳定和可维护性的不二法门。
更多推荐

所有评论(0)