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 = "test@example.com";
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("test@example.com"));
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 = "delete@example.com";
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" << "alice@test.com" << true;
QTest::newRow("empty name") << "" << "alice@test.com" << 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, "test@example.com")
# 点击按钮
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="alice@test.com", age=25)
user_id = repo.create(user)
assert user_id > 0
def test_find_by_id(self, repo):
user = User(name="Bob", email="bob@test.com", age=30)
user_id = repo.create(user)
found = repo.find_by_id(user_id)
assert found.name == "Bob"
assert found.email == "bob@test.com"
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="charlie@test.com", 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="del@test.com")
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()等待异步操作完成。 UseQTest::qWait()orqtbot.wait()for async operations.
⚠️ CI 环境无显示器 / Headless CI 使用
QT_QPA_PLATFORM=offscreen或 Xvfb 在无显示器的 CI 上运行测试。 UseQT_QPA_PLATFORM=offscreenor Xvfb for headless CI testing.
扩展阅读 / Further Reading
| 资源 / Resource | 链接 / Link |
|---|---|
| QTest 文档 | https://doc.qt.io/qt-6/qtest-overview.html |
| pytest-qt | https://pytest-qt.readthedocs.io/ |
| dogtail (GTK) | https://gitlab.com/dogtail/dogtail |
| Squish (Qt 商业) | https://www.froglogic.com/squish/ |
← 13 - 数据库集成 | 15 - Docker 容器化 →