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 测试金字塔与分层策略

我遵循经典的测试金字塔模型,但根据桌面应用的特点做了调整:

  1. 单元测试(底层,量最大) :针对模拟器的核心业务逻辑。这包括:

    • 协议解析模块 :测试Pelco-D、Pelco-P等控制协议的命令生成与解析函数。输入摄像头地址、指令类型(如云台转动、镜头变倍),断言输出的字节串是否正确。
    • 键盘状态机 :测试键盘模式切换(如光标模式、预置点调用模式)、按键队列处理等逻辑。模拟按键事件,断言内部状态是否正确转移。
    • 配置管理 :测试串口参数、网络设置、快捷键映射等配置的加载、保存和验证逻辑。
    • 工具类函数 :所有纯计算的、无副作用的工具函数。
    • 这一层完全使用pytest, mocking掉所有外部依赖(如串口、网络、文件系统),追求执行速度极快,毫秒级完成。
  2. 集成测试(中层,承上启下) :测试模块之间的协作。这是本项目的关键层。

    • 协议模块与通讯模块的集成 :测试协议解析器生成的命令,是否能正确通过“虚拟串口”或“虚拟Socket”发送出去。这里我们会使用一些测试替身,比如用 unittest.mock 模拟一个串口对象,检查 write 方法是否被以预期的参数调用。
    • 业务逻辑与数据模型的集成 :测试用户操作(通过模拟的事件)如何触发业务逻辑,并更新数据模型(如当前控制的摄像头ID、云台速度)。例如,模拟一个“方向键按下”事件,断言业务逻辑层是否正确地生成了协议命令,并且数据模型中的“当前速度”值是否被更新。
    • 这一层会引入有限的、可控的外部依赖模拟,重点验证接口契约和数据流。
  3. 端到端(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 协议解析模块的单元测试

这是模拟器最核心、最稳定的部分,非常适合做彻底的单元测试。

测试重点:

  1. 命令生成函数 :给定输入参数(地址、命令类型、数据),输出是否符合Pelco-D/P协议规范。例如,测试 generate_ptz_command(address=1, command='LEFT', speed=0x20) 是否返回正确的字节数组 [0xFF, 0x01, 0x00, 0x04, 0x20, 0x00, 0x25] (示例,非真实Pelco-D)。
  2. 命令解析函数 :给定一个字节数组,是否能正确解析出地址、命令和参数。这对于模拟器“接收”来自设备的应答信号很重要。
  3. 校验和计算 :协议中的校验和计算逻辑必须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进行隔离测试。

测试重点:

  1. 发送逻辑 :业务层调用通讯模块的发送接口时,底层串口或Socket的write/sendto方法是否被正确调用,且参数正确。
  2. 接收与回调 :模拟底层收到数据,通讯模块是否能正确解析并触发预设的回调函数,将数据传递给业务层。

实操示例与技巧:

# 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渲染,直接测试逻辑连接”

测试重点:

  1. 信号与槽连接 :确保界面控件发出的信号(如 button.clicked )正确连接到业务逻辑的槽函数。
  2. 控件状态映射 :用户操作界面控件(如选择摄像头地址的下拉框),业务模型中的相应数据是否同步更新。
  3. 业务逻辑触发 :界面操作是否能正确触发核心业务函数(如协议生成、命令发送)。

实操示例与技巧:

# 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对象,或者调用次数/参数不对。

排查技巧

  1. 检查导入路径(Import Path) :这是最常见的问题。 @patch(‘module.ClassName’) 中的路径必须是 被测代码中导入该类的路径 ,而不是该类的定义路径。使用 print(module.ClassName) 在被测代码中查看其完整导入路径。
  2. 使用 autospec=True :在patch时设置 autospec=True ,可以让Mock对象严格模仿原始对象的接口,如果调用不存在的方法会报错,有助于发现问题。
    @patch('src.communication.serial_manager.serial.Serial', autospec=True)
    
  3. 检查Mock的调用记录 :使用 mock_obj.mock_calls call_args_list 查看所有调用记录,与预期进行对比。
    print(mock_serial.write.call_args_list) # 打印所有调用历史
    

6.3 测试依赖与状态污染

问题 :测试用例A修改了某个全局状态或类变量,导致测试用例B运行失败。

解决方案

  1. 充分利用Fixture的 scope 和独立性 :默认 function 级别的fixture为每个测试函数提供全新的实例。对于需要共享但需隔离的状态,使用合适的scope(如 class , module ),并在fixture内部做好初始化。
  2. 使用 setup_method / teardown_method 或 Fixture Finalizer :在测试类中,可以重写 setup_method 来为每个测试方法初始化环境,用 teardown_method 来清理。
  3. 避免使用真正的单例或全局变量 :在测试中,尽量通过依赖注入(如将 mock_serial_port fixture传入)来提供依赖,而不是让代码去访问一个全局的、难以替换的单例对象。如果原有代码结构如此,考虑在测试时使用 monkeypatch 临时替换全局变量。

6.4 异步操作测试

问题 :模拟器可能有异步处理,比如定时轮询串口、网络异步回调。测试需要等待这些异步操作完成。

解决方案

  1. 模拟时间 :对于定时器,可以使用 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() # 调用内部的槽函数
        # 然后断言预期的行为发生了
    
  2. 使用信号等待 :如果异步操作最终会发射一个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模拟器这类既有复杂逻辑又有用户界面的项目,这样一套分层、解耦的测试方案,是保证其长期稳定和可维护性的不二法门。

更多推荐