强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Qt 与 GTK 图形框架教程 / 14 - UI 测试 / UI Testing

UI 测试 / UI Testing

掌握 Qt QTest 和 Python pytest-gtk 自动化 UI 测试。 Master Qt QTest and Python pytest-gtk for automated UI testing.


14.1 测试策略 / Testing Strategy

测试金字塔 / Testing Pyramid

层级 / Layer类型 / Type比例 / Ratio工具 / Tools
单元测试逻辑测试70%QTest / pytest
集成测试组件交互20%QTest / pytest-qt
端到端用户流程10%Squish / dogtail

14.2 Qt QTest 单元测试 / Qt QTest Unit Testing

完整测试示例

// test_usermodel.h
#ifndef TEST_USERMODEL_H
#define TEST_USERMODEL_H

#include <QtTest/QtTest>
#include <QSqlDatabase>
#include "userrepository.h"

class TestUserModel : public QObject {
    Q_OBJECT

private slots:
    void initTestCase() {
        // 测试数据库初始化
        QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE");
        db.setDatabaseName(":memory:");
        QVERIFY(db.open());
        QSqlQuery query;
        query.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, "
                   "name TEXT, email TEXT, age INTEGER)");
    }

    void cleanupTestCase() {
        QSqlDatabase::database().close();
    }

    void testCreateUser() {
        UserRepository repo;
        UserRepository::User user;
        user.name = "Test User";
        user.email = "[email protected]";
        user.age = 25;

        int id = repo.create(user);
        QVERIFY(id > 0);
    }

    void testFindById() {
        UserRepository repo;
        auto user = repo.findById(1);
        QCOMPARE(user.name, QString("Test User"));
        QCOMPARE(user.email, QString("[email protected]"));
        QCOMPARE(user.age, 25);
    }

    void testUpdate() {
        UserRepository repo;
        auto user = repo.findById(1);
        user.name = "Updated User";
        QVERIFY(repo.update(user));

        auto updated = repo.findById(1);
        QCOMPARE(updated.name, QString("Updated User"));
    }

    void testDelete() {
        UserRepository repo;
        // 创建并删除
        UserRepository::User user;
        user.name = "To Delete";
        user.email = "[email protected]";
        int id = repo.create(user);
        QVERIFY(repo.remove(id));
    }

    void testValidation_data() {
        QTest::addColumn<QString>("name");
        QTest::addColumn<QString>("email");
        QTest::addColumn<bool>("expected");

        QTest::newRow("valid") << "Alice" << "[email protected]" << true;
        QTest::newRow("empty name") << "" << "[email protected]" << false;
        QTest::newRow("invalid email") << "Alice" << "invalid" << false;
    }

    void testValidation() {
        QFETCH(QString, name);
        QFETCH(QString, email);
        QFETCH(bool, expected);

        ValidationResult result;
        if (!name.isEmpty())
            Validator::required(name, "Name", result);
        if (!email.isEmpty())
            Validator::email(email, "Email", result);

        QCOMPARE(result.isValid(), expected);
    }
};

#endif
// main_test.cpp
#include "test_usermodel.h"
QTEST_MAIN(TestUserModel)
# CMakeLists.txt - 测试配置
enable_testing()
find_package(Qt6 REQUIRED COMPONENTS Test Sql)

add_executable(test_usermodel test_usermodel.h main_test.cpp)
target_link_libraries(test_usermodel PRIVATE Qt6::Test Qt6::Sql app_lib)
add_test(NAME UserModelTest COMMAND test_usermodel)

14.3 Qt GUI 测试 / Qt GUI Testing

// test_mainwindow.h
#include <QtTest/QtTest>
#include <QApplication>
#include <QPushButton>
#include <QLineEdit>
#include <QTableView>
#include "mainwindow.h"

class TestMainWindow : public QObject {
    Q_OBJECT

private slots:
    void initTestCase() {
        window = new MainWindow();
        window->show();
        QVERIFY(QTest::qWaitForWindowExposed(window));
    }

    void cleanupTestCase() {
        delete window;
    }

    void testButtonClick() {
        // 查找按钮并模拟点击
        auto *btn = window->findChild<QPushButton*>("addButton");
        QVERIFY(btn);
        QTest::mouseClick(btn, Qt::LeftButton);
        // 验证结果
    }

    void testTextInput() {
        auto *edit = window->findChild<QLineEdit*>("nameEdit");
        QVERIFY(edit);
        QTest::keyClicks(edit, "Hello World");
        QCOMPARE(edit->text(), QString("Hello World"));
    }

    void testKeyboardNavigation() {
        QTest::keyClick(window, Qt::Key_Tab);
        QTest::keyClick(window, Qt::Key_Tab);
        QTest::keyClick(window, Qt::Key_Return);
    }

private:
    MainWindow *window;
};

14.4 pytest-qt Python 测试 / pytest-qt Testing

# conftest.py
import pytest
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt


@pytest.fixture(scope="session")
def qapp():
    """创建 QApplication 单例"""
    app = QApplication.instance()
    if app is None:
        app = QApplication([])
    yield app


@pytest.fixture
def main_window(qapp):
    """创建主窗口"""
    from mainwindow import MainWindow
    window = MainWindow()
    window.show()
    yield window
    window.close()
# test_user_view.py
"""用户视图测试"""

import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QLineEdit, QPushButton, QTableView


class TestUserView:
    def test_form_labels(self, qtbot, main_window):
        """测试表单标签存在"""
        # 查找标签
        labels = main_window.findChildren(QLabel)
        label_texts = [l.text() for l in labels]
        assert any("姓名" in t for t in label_texts)

    def test_add_user(self, qtbot, main_window):
        """测试添加用户"""
        # 查找输入框
        name_edit = main_window.findChild(QLineEdit, "nameEdit")
        email_edit = main_window.findChild(QLineEdit, "emailEdit")
        add_btn = main_window.findChild(QPushButton, "addButton")

        assert name_edit is not None
        assert add_btn is not None

        # 模拟输入
        qtbot.keyClicks(name_edit, "测试用户")
        qtbot.keyClicks(email_edit, "[email protected]")

        # 点击按钮
        qtbot.mouseClick(add_btn, Qt.MouseButton.LeftButton)

        # 验证表格有数据
        table = main_window.findChild(QTableView, "userTable")
        model = table.model()
        assert model.rowCount() > 0

    def test_search(self, qtbot, main_window):
        """测试搜索功能"""
        search = main_window.findChild(QLineEdit, "searchEdit")
        if search:
            qtbot.keyClicks(search, "张三")
            # 等待过滤生效
            qtbot.wait(100)

    def test_validation_error(self, qtbot, main_window):
        """测试验证错误显示"""
        add_btn = main_window.findChild(QPushButton, "addButton")
        if add_btn:
            # 不填写直接点击
            qtbot.mouseClick(add_btn, Qt.MouseButton.LeftButton)
            # 检查错误标签是否可见
            error_label = main_window.findChild(QLabel, "errorLabel")
            if error_label:
                assert error_label.isVisible()
# test_repository.py
"""Repository 层测试"""

import pytest
from user_repository import UserRepository, User


@pytest.fixture
def repo(tmp_path):
    """创建临时数据库"""
    db_path = str(tmp_path / "test.db")
    return UserRepository(db_path)


class TestUserRepository:
    def test_create(self, repo):
        user = User(name="Alice", email="[email protected]", age=25)
        user_id = repo.create(user)
        assert user_id > 0

    def test_find_by_id(self, repo):
        user = User(name="Bob", email="[email protected]", age=30)
        user_id = repo.create(user)
        found = repo.find_by_id(user_id)
        assert found.name == "Bob"
        assert found.email == "[email protected]"

    def test_find_all(self, repo):
        for i in range(5):
            repo.create(User(name=f"User{i}", email=f"user{i}@test.com"))
        users = repo.find_all()
        assert len(users) == 5

    def test_update(self, repo):
        user = User(name="Charlie", email="[email protected]", age=20)
        user_id = repo.create(user)
        user.id = user_id
        user.name = "Updated Charlie"
        repo.update(user)
        found = repo.find_by_id(user_id)
        assert found.name == "Updated Charlie"

    def test_delete(self, repo):
        user = User(name="ToDelete", email="[email protected]")
        user_id = repo.create(user)
        repo.delete(user_id)
        assert repo.find_by_id(user_id) is None

14.5 GTK Python 测试 / GTK Python Testing

# test_gtk_app.py
"""GTK 应用测试"""

import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, GLib
import pytest


@pytest.fixture
def gtk_app():
    """创建 GTK 应用"""
    from myapp import MyApp
    app = MyApp()
    yield app


def test_window_creation(gtk_app):
    """测试窗口创建"""
    window = None

    def on_activate(app):
        nonlocal window
        window = Gtk.ApplicationWindow(application=app)
        window.present()

    gtk_app.connect("activate", on_activate)

    # 运行应用直到 activate
    GLib.idle_add(gtk_app.quit)
    gtk_app.run([])

    assert window is not None


def test_button_signal(qtbot):
    """测试按钮信号"""
    clicked = False

    def on_click(btn):
        nonlocal clicked
        clicked = True

    btn = Gtk.Button(label="Test")
    btn.connect("clicked", on_click)
    btn.emit("clicked")

    assert clicked

14.6 测试最佳实践 / Testing Best Practices

实践 / Practice说明 / Description
测试隔离每个测试独立,不依赖其他测试状态
使用内存数据库测试用 SQLite :memory:
模拟外部依赖使用 mock 替代网络/文件
测试边界条件空输入、超长文本、特殊字符
测试用户流程完整的操作序列
持续集成GitHub Actions / GitLab CI
# mock 示例
from unittest.mock import MagicMock, patch

def test_network_request(qapp):
    """模拟网络请求"""
    with patch('myapp.HttpClient.get') as mock_get:
        mock_get.return_value = {"status": "ok"}
        # 测试使用 mock 网络的代码

注意事项 / Important Notes

⚠️ QTest 需要 QApplication / QTest Needs QApplication 所有 GUI 测试都需要 QApplication 实例。 All GUI tests need a QApplication instance.

⚠️ 异步测试 / Async Testing 使用 QTest::qWait()qtbot.wait() 等待异步操作完成。 Use QTest::qWait() or qtbot.wait() for async operations.

⚠️ CI 环境无显示器 / Headless CI 使用 QT_QPA_PLATFORM=offscreen 或 Xvfb 在无显示器的 CI 上运行测试。 Use QT_QPA_PLATFORM=offscreen or Xvfb for headless CI testing.


扩展阅读 / Further Reading

资源 / Resource链接 / Link
QTest 文档https://doc.qt.io/qt-6/qtest-overview.html
pytest-qthttps://pytest-qt.readthedocs.io/
dogtail (GTK)https://gitlab.com/dogtail/dogtail
Squish (Qt 商业)https://www.froglogic.com/squish/

13 - 数据库集成 | 15 - Docker 容器化