Введение: Почему тестирование QML требует особого подхода
QML (Qt Modeling Language) представляет собой уникальный гибрид декларативного и императивного программирования, что создает особые вызовы для тестирования. В отличие от традиционных UI-фреймворков, QML-приложения сочетают в себе:
- Декларативный UI-слой с реактивными привязками данных
- Императивную логику на JavaScript, встроенную прямо в UI-компоненты
- C++ бэкенд, экспортированный в QML через систему мета-объектов Qt
- Сложную систему сигналов и слотов, работающую между слоями
Такая архитектура требует многоуровневого подхода к тестированию, который мы детально рассмотрим в этом руководстве. Мы не только рассмотрим инструменты, но и погрузимся в философию тестирования QML-приложений, антипаттерны и лучшие практики, выработанные сообществом за годы.
Фундаментальные принципы тестирования QML-приложений
Пирамида тестирования для QML

Особенности QML, влияющие на тестируемость
Реактивные привязки:
Text {
// Эта привязка автоматически обновляется при изменении user.name
text: user.name + " (" + user.age + " лет)"
// Это вычисляемое свойство
property bool isAdult: user.age >= 18
// Цвет зависит от состояния
color: isAdult ? "green" : "red"
}
Проблема для тестирования: Привязки выполняются асинхронно, что требует специальных подходов для проверки.
Состояния и переходы:
Item {
states: [
State { name: "normal" },
State {
name: "expanded"
PropertyChanges { target: content; height: 200 }
}
]
transitions: [
Transition {
from: "normal"; to: "expanded"
NumberAnimation { properties: "height"; duration: 300 }
}
]
}
Проблема для тестирования: Анимации требуют ожидания завершения или их мокирования.
Динамическая загрузка компонентов:
Loader {
id: pageLoader
source: "Page" + pageId + ".qml"
}
Проблема для тестирования: Зависимость от внешних файлов требует управления контекстом загрузки.
Unit-тестирование C++ бэкенда (Углубленно)
Организация тестов для классов, экспортируемых в QML
Полный пример тестируемого класса:
// userprofile.h
#pragma once
#include <QObject>
#include <QString>
#include <QDateTime>
#include <QVariantMap>
class UserProfile : public QObject
{
Q_OBJECT
Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged)
Q_PROPERTY(int age READ age WRITE setAge NOTIFY ageChanged)
Q_PROPERTY(QString email READ email WRITE setEmail NOTIFY emailChanged)
Q_PROPERTY(Status status READ status NOTIFY statusChanged)
Q_PROPERTY(QVariantMap metadata READ metadata NOTIFY metadataChanged)
Q_ENUMS(Status)
public:
enum Status {
Inactive = 0,
Active = 1,
Suspended = 2,
Banned = 3
};
explicit UserProfile(QObject* parent = nullptr);
~UserProfile();
// Q_PROPERTY методы
QString name() const;
void setName(const QString& name);
int age() const;
void setAge(int age);
QString email() const;
void setEmail(const QString& email);
Status status() const;
QVariantMap metadata() const;
// Q_INVOKABLE методы
Q_INVOKABLE bool validate() const;
Q_INVOKABLE void activate();
Q_INVOKABLE void suspend(const QString& reason);
Q_INVOKABLE QVariantMap toJson() const;
Q_INVOKABLE bool fromJson(const QVariantMap& json);
// Сигналы
signals:
void nameChanged(const QString& name);
void ageChanged(int age);
void emailChanged(const QString& email);
void statusChanged(Status status);
void metadataChanged(const QVariantMap& metadata);
void validationFailed(const QString& field, const QString& error);
private:
class Private;
QScopedPointer<Private> d;
};
Тест с различными техниками:
// test_userprofile.cpp
#include <QtTest>
#include <QSignalSpy>
#include <QJsonDocument>
#include <QMetaType>
// Для тестирования приватных данных (альтернативный подход)
#define private public
#include "userprofile.h"
#undef private
// Регистрируем метатипы для QSignalSpy
Q_DECLARE_METATYPE(UserProfile::Status)
Q_DECLARE_METATYPE(QVariantMap)
class TestUserProfile : public QObject
{
Q_OBJECT
private slots:
// Тестирование жизненного цикла
void test_construction();
void test_copy_semantics();
void test_move_semantics();
// Тестирование свойств
void test_property_name();
void test_property_name_validation();
void test_property_age();
void test_property_age_boundaries();
void test_property_email();
void test_property_email_format();
void test_property_status_transitions();
void test_property_metadata();
// Тестирование инвокабельных методов
void test_validate_method();
void test_activate_method();
void test_suspend_method();
void test_toJson_method();
void test_fromJson_method();
// Тестирование сигналов
void test_signals_emission();
void test_signal_ordering();
void test_signal_throttling();
// Тестирование потокобезопасности
void test_thread_safety();
// Интеграционные тесты с QML-контекстом
void test_qml_integration();
// Тестирование граничных случаев
void test_edge_cases();
// Тестирование производительности
void benchmark_property_changes();
void benchmark_json_serialization();
private:
// Вспомогательные методы
UserProfile* createDefaultProfile();
bool waitForSignal(QSignalSpy& spy, int timeout = 1000);
void verifyPropertyChange(QObject* obj, const char* property,
const QVariant& newValue);
};
void TestUserProfile::test_construction()
{
// Тест 1: Конструктор по умолчанию
UserProfile profile1;
QVERIFY(profile1.parent() == nullptr);
QCOMPARE(profile1.name(), QString());
QCOMPARE(profile1.age(), 0);
QCOMPARE(profile1.status(), UserProfile::Inactive);
// Тест 2: Конструктор с родителем
QObject parent;
UserProfile profile2(&parent);
QCOMPARE(profile2.parent(), &parent);
// Тест 3: Проверка инициализации через свойства
UserProfile profile3;
profile3.setName("John Doe");
profile3.setAge(30);
profile3.setEmail("john@example.com");
QCOMPARE(profile3.name(), QString("John Doe"));
QCOMPARE(profile3.age(), 30);
QCOMPARE(profile3.email(), QString("john@example.com"));
// Тест 4: Проверка, что метаданные инициализированы
QVERIFY(profile3.metadata().contains("createdAt"));
QVERIFY(!profile3.metadata().isEmpty());
}
void TestUserProfile::test_property_name()
{
UserProfile profile;
// Подписываемся на сигнал изменения имени
QSignalSpy nameSpy(&profile, &UserProfile::nameChanged);
QSignalSpy metadataSpy(&profile, &UserProfile::metadataChanged);
// Тест 1: Установка корректного имени
profile.setName("Alice");
QCOMPARE(profile.name(), QString("Alice"));
// Проверяем, что сигнал был испущен
QCOMPARE(nameSpy.count(), 1);
QCOMPARE(nameSpy.takeFirst().at(0).toString(), QString("Alice"));
// Проверяем, что метаданные обновились
QCOMPARE(metadataSpy.count(), 1);
QVERIFY(profile.metadata().contains("lastModified"));
// Тест 2: Установка того же имени (сигнал не должен испускаться)
profile.setName("Alice");
QCOMPARE(nameSpy.count(), 0);
// Тест 3: Установка пустого имени (разрешено ли?)
profile.setName("");
QCOMPARE(profile.name(), QString(""));
QCOMPARE(nameSpy.count(), 1);
// Тест 4: Установка имени с пробелами
profile.setName(" John Doe ");
// Должно ли тримиться? Проверяем документацию/требования
// QCOMPARE(profile.name(), QString("John Doe"));
}
void TestUserProfile::test_property_age_boundaries()
{
UserProfile profile;
// Тест граничных значений
QTest::ignoreMessage(QtWarningMsg,
"Age cannot be negative. Setting to 0.");
// Тест 1: Отрицательный возраст
profile.setAge(-5);
QCOMPARE(profile.age(), 0); // Должно стать 0
// Тест 2: Слишком большой возраст
QTest::ignoreMessage(QtWarningMsg,
"Age seems unrealistic. Maximum allowed is 150.");
profile.setAge(200);
QCOMPARE(profile.age(), 150); // Ограничение 150
// Тест 3: Корректный возраст
profile.setAge(25);
QCOMPARE(profile.age(), 25);
// Тест 4: Граничные корректные значения
profile.setAge(0);
QCOMPARE(profile.age(), 0);
profile.setAge(150);
QCOMPARE(profile.age(), 150);
}
void TestUserProfile::test_validate_method()
{
// Тест 1: Пустой профиль
UserProfile profile1;
QVERIFY(!profile1.validate());
// Проверяем, что сигнал validationFailed был испущен
QSignalSpy validationSpy(&profile1, &UserProfile::validationFailed);
profile1.validate();
QVERIFY(validationSpy.count() > 0);
// Тест 2: Частично заполненный профиль
UserProfile profile2;
profile2.setName("John");
QVERIFY(!profile2.validate());
// Тест 3: Полностью заполненный корректный профиль
UserProfile profile3;
profile3.setName("John Doe");
profile3.setAge(30);
profile3.setEmail("john@example.com");
QVERIFY(profile3.validate());
// Тест 4: Некорректный email
UserProfile profile4;
profile4.setName("John Doe");
profile4.setAge(30);
profile4.setEmail("invalid-email");
QVERIFY(!profile4.validate());
// Проверяем, какой именно field вызвал ошибку
QSignalSpy spy4(&profile4, &UserProfile::validationFailed);
profile4.validate();
QCOMPARE(spy4.count(), 1);
QCOMPARE(spy4.takeFirst().at(0).toString(), QString("email"));
}
void TestUserProfile::test_signal_ordering()
{
// Важный тест: порядок испускания сигналов имеет значение
// при использовании в QML привязках
UserProfile profile;
// Создаем шпионы для всех сигналов
QSignalSpy nameSpy(&profile, &UserProfile::nameChanged);
QSignalSpy ageSpy(&profile, &UserProfile::ageChanged);
QSignalSpy statusSpy(&profile, &UserProfile::statusChanged);
QSignalSpy metadataSpy(&profile, &UserProfile::metadataChanged);
// Изменяем несколько свойств
// Порядок должен быть: nameChanged, ageChanged, metadataChanged
// statusChanged не должен испускаться
profile.blockSignals(false);
profile.setName("New Name");
profile.setAge(35);
// Проверяем порядок сигналов
QList<QByteArray> expectedSignals = {
"nameChanged(QString)",
"ageChanged(int)",
"metadataChanged(QVariantMap)"
};
// Можно использовать более сложную проверку порядка
// В реальном проекте может потребоваться кастомный сигнальный спай
}
void TestUserProfile::test_thread_safety()
{
// Тестируем, можно ли безопасно изменять свойства из разных потоков
UserProfile profile;
const int threadCount = 10;
const int iterations = 1000;
QVector<QThread*> threads;
QAtomicInt successCount(0);
for (int i = 0; i < threadCount; ++i) {
QThread* thread = QThread::create([&profile, iterations, &successCount]() {
for (int j = 0; j < iterations; ++j) {
// Пытаемся изменить свойства из другого потока
bool ok = false;
QMetaObject::invokeMethod(&profile, [&profile, j, &ok]() {
profile.setName(QString("Thread-%1").arg(j));
ok = true;
}, Qt::BlockingQueuedConnection);
if (ok) {
successCount.fetchAndAddRelaxed(1);
}
}
});
threads.append(thread);
thread->start();
}
// Ждем завершения всех потоков
for (QThread* thread : threads) {
thread->wait();
delete thread;
}
// Проверяем, что все операции выполнены успешно
QCOMPARE(successCount.load(), threadCount * iterations);
// Проверяем конечное состояние
QVERIFY(!profile.name().isEmpty());
}
void TestUserProfile::test_qml_integration()
{
// Тест интеграции с QML-движком
QQmlEngine engine;
// Регистрируем тип в QML
qmlRegisterType<UserProfile>("Test", 1, 0, "UserProfile");
// Создаем объект через QML
QQmlComponent component(&engine);
component.setData(
"import Test 1.0\n"
"UserProfile { id: profile; name: 'QML User'; age: 25 }",
QUrl());
QScopedPointer<QObject> object(component.create());
QVERIFY(!object.isNull());
UserProfile* profile = qobject_cast<UserProfile*>(object.data());
QVERIFY(profile != nullptr);
QCOMPARE(profile->name(), QString("QML User"));
QCOMPARE(profile->age(), 25);
// Тестируем привязки QML
QQmlComponent component2(&engine);
component2.setData(
"import Test 1.0\n"
"import QtQuick 2.15\n"
"Item {\n"
" UserProfile { id: profile; name: 'Initial' }\n"
" property string displayName: profile.name + ' (' + profile.age + ')'\n"
"}",
QUrl());
QScopedPointer<QObject> root(component2.create());
QVERIFY(!root.isNull());
QCOMPARE(root->property("displayName").toString(),
QString("Initial (0)"));
// Меняем свойство и проверяем привязку
UserProfile* profile2 = root->findChild<UserProfile*>("profile");
profile2->setName("Changed");
profile2->setAge(30);
// Даем время для обновления привязок
QCoreApplication::processEvents();
QCOMPARE(root->property("displayName").toString(),
QString("Changed (30)"));
}
void TestUserProfile::benchmark_property_changes()
{
UserProfile profile;
// Бенчмарк: скорость изменения свойств
QBENCHMARK {
for (int i = 0; i < 10000; ++i) {
profile.setName(QString("Name %1").arg(i));
profile.setAge(i % 100);
}
}
// Проверяем, что после бенчмарка состояние корректное
QVERIFY(profile.name().startsWith("Name "));
}
// Регистрация тестов
QTEST_MAIN(TestUserProfile)
#include "test_userprofile.moc"
Продвинутые техники мокирования для зависимостей
Пример комплексной системы моков для тестирования:
// mock_networkmanager.h
#pragma once
#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QTimer>
class MockNetworkReply : public QNetworkReply
{
Q_OBJECT
public:
MockNetworkReply(QObject* parent = nullptr)
: QNetworkReply(parent)
, m_delay(0)
{
setOpenMode(QIODevice::ReadOnly);
}
void setResponseData(const QByteArray& data,
QNetworkReply::NetworkError error = QNetworkReply::NoError)
{
m_data = data;
setError(error, errorString());
if (m_delay > 0) {
QTimer::singleShot(m_delay, this, [this]() {
emit readyRead();
emit finished();
});
} else {
emit readyRead();
emit finished();
}
}
void setDelay(int ms) { m_delay = ms; }
// Переопределение виртуальных методов
qint64 readData(char* data, qint64 maxSize) override
{
if (m_offset >= m_data.size())
return -1;
qint64 bytesToRead = qMin(maxSize, m_data.size() - m_offset);
memcpy(data, m_data.constData() + m_offset, bytesToRead);
m_offset += bytesToRead;
return bytesToRead;
}
qint64 writeData(const char*, qint64) override { return -1; }
void abort() override
{
m_data.clear();
m_offset = 0;
emit finished();
}
private:
QByteArray m_data;
qint64 m_offset = 0;
int m_delay = 0;
};
class MockNetworkManager : public QNetworkAccessManager
{
Q_OBJECT
public:
enum Scenario {
Success,
Timeout,
ServerError,
NetworkError,
InvalidJson
};
MockNetworkManager(QObject* parent = nullptr)
: QNetworkAccessManager(parent)
, m_scenario(Success)
, m_defaultDelay(0)
{}
void setScenario(Scenario scenario) { m_scenario = scenario; }
void setDefaultDelay(int ms) { m_defaultDelay = ms; }
void registerEndpoint(const QString& url,
const QByteArray& response,
QNetworkReply::NetworkError error = QNetworkReply::NoError)
{
m_endpoints[url] = qMakePair(response, error);
}
protected:
QNetworkReply* createRequest(Operation op,
const QNetworkRequest& request,
QIODevice* outgoingData = nullptr) override
{
Q_UNUSED(op);
Q_UNUSED(outgoingData);
MockNetworkReply* reply = new MockNetworkReply(this);
// Задержка для эмуляции сетевой задержки
reply->setDelay(m_defaultDelay);
// Проверяем зарегистрированные endpoint'ы
QString url = request.url().toString();
if (m_endpoints.contains(url)) {
auto endpoint = m_endpoints[url];
reply->setResponseData(endpoint.first, endpoint.second);
return reply;
}
// Используем сценарий по умолчанию
switch (m_scenario) {
case Success:
reply->setResponseData(
QJsonDocument::fromVariant(QVariantMap{
{"status", "success"},
{"data", QVariantMap{{"id", 1}, {"name", "Test Item"}}}
}).toJson()
);
break;
case Timeout:
reply->setDelay(10000); // 10 секунд для эмуляции таймаута
break;
case ServerError:
reply->setResponseData("Internal Server Error",
QNetworkReply::InternalServerError);
break;
case NetworkError:
reply->setResponseData("", QNetworkReply::NetworkError);
break;
case InvalidJson:
reply->setResponseData("{invalid json");
break;
}
return reply;
}
private:
Scenario m_scenario;
int m_defaultDelay;
QMap<QString, QPair<QByteArray, QNetworkReply::NetworkError>> m_endpoints;
};
// Использование в тестах
void TestApiClient::test_with_mock_network()
{
MockNetworkManager mockManager;
ApiClient client(&mockManager);
// Тест 1: Успешный запрос
mockManager.setScenario(MockNetworkManager::Success);
QSignalSpy successSpy(&client, &ApiClient::dataReceived);
QSignalSpy errorSpy(&client, &ApiClient::errorOccurred);
client.fetchData("https://api.example.com/data");
QTRY_COMPARE(successSpy.count(), 1);
QCOMPARE(errorSpy.count(), 0);
// Тест 2: Таймаут
mockManager.setScenario(MockNetworkManager::Timeout);
mockManager.setDefaultDelay(15000); // Больше, чем timeout клиента
client.fetchData("https://api.example.com/data");
QTRY_COMPARE(errorSpy.count(), 1);
QCOMPARE(successSpy.count(), 1); // Не изменилось
// Тест 3: Специфичные endpoint'ы
mockManager.registerEndpoint(
"https://api.example.com/user/1",
QJsonDocument::fromVariant(QVariantMap{
{"id", 1},
{"name", "John Doe"},
{"email", "john@example.com"}
}).toJson()
);
client.fetchUser(1);
// ... проверки
}
Unit-тестирование JavaScript логики в QML
Организация тестов для встроенной JavaScript логики
Расширенный пример с различными типами тестов:
// TestMathUtils.qml
import QtQuick 2.15
import QtTest 1.4
import "../src/utils/MathUtils.js" as MathUtils
TestCase {
name: "MathUtilsTests"
// Тестирование чистых функций
function test_add() {
compare(MathUtils.add(2, 3), 5);
compare(MathUtils.add(-1, 1), 0);
compare(MathUtils.add(0, 0), 0);
compare(MathUtils.add(2.5, 3.5), 6.0);
}
function test_subtract() {
compare(MathUtils.subtract(5, 3), 2);
compare(MathUtils.subtract(0, 5), -5);
compare(MathUtils.subtract(-2, -3), 1);
}
// Тестирование функций с обработкой ошибок
function test_divide() {
// Нормальное деление
compare(MathUtils.divide(10, 2), 5);
compare(MathUtils.divide(5, 2), 2.5);
// Деление на ноль должно выбрасывать исключение
try {
MathUtils.divide(5, 0);
fail("Должно было выбросить исключение при делении на ноль");
} catch (e) {
verify(e.message.includes("Division by zero"));
}
// Проверка типов
try {
MathUtils.divide("invalid", 2);
fail("Должно было выбросить исключение при нечисловых аргументах");
} catch (e) {
verify(e.message.includes("Invalid arguments"));
}
}
// Тестирование функций с побочными эффектами
function test_calculateStatistics() {
var data = [1, 2, 3, 4, 5];
var stats = MathUtils.calculateStatistics(data);
compare(stats.sum, 15);
compare(stats.average, 3);
compare(stats.min, 1);
compare(stats.max, 5);
compare(stats.count, 5);
// Граничные случаи
var emptyStats = MathUtils.calculateStatistics([]);
compare(emptyStats.sum, 0);
compare(emptyStats.average, 0);
compare(emptyStats.min, Infinity);
compare(emptyStats.max, -Infinity);
compare(emptyStats.count, 0);
}
// Тестирование асинхронных функций
function test_asyncOperation() {
var done = false;
var result = null;
MathUtils.asyncOperation("test", function(data) {
done = true;
result = data;
});
// Ждем завершения асинхронной операции
tryCompare(function() { return done; }, true, 2000);
verify(result !== null);
compare(result.status, "success");
}
// Бенчмарки
function benchmark_factorial() {
// Тест производительности
skip("Бенчмарк: вычисление факториала");
var start = new Date().getTime();
var result = MathUtils.factorial(1000);
var end = new Date().getTime();
console.log("Факториал 1000 вычислен за " + (end - start) + "ms");
verify(end - start < 1000, "Слишком медленно: " + (end - start) + "ms");
}
// Тестирование с моками
function test_with_mock_dependencies() {
// Сохраняем оригинальную функцию
var originalLog = console.log;
var logMessages = [];
// Мокаем console.log
console.log = function(message) {
logMessages.push(message);
};
try {
MathUtils.logOperation("test operation");
compare(logMessages.length, 1);
verify(logMessages[0].includes("test operation"));
} finally {
// Восстанавливаем оригинальную функцию
console.log = originalLog;
}
}
}
// TestValidationUtils.qml
import QtQuick 2.15
import QtTest 1.4
import "../src/utils/ValidationUtils.js" as ValidationUtils
TestCase {
name: "ValidationUtilsTests"
function test_email_validation() {
// Валидные email'ы
verify(ValidationUtils.isValidEmail("test@example.com"));
verify(ValidationUtils.isValidEmail("user.name@domain.co.uk"));
verify(ValidationUtils.isValidEmail("user+tag@example.com"));
verify(ValidationUtils.isValidEmail("user@sub.domain.example.com"));
// Невалидные email'ы
verify(!ValidationUtils.isValidEmail(""));
verify(!ValidationUtils.isValidEmail("invalid"));
verify(!ValidationUtils.isValidEmail("@example.com"));
verify(!ValidationUtils.isValidEmail("user@"));
verify(!ValidationUtils.isValidEmail("user@.com"));
verify(!ValidationUtils.isValidEmail("user@domain..com"));
verify(!ValidationUtils.isValidEmail("user@domain.c"));
}
function test_password_validation() {
var result;
// Слишком короткий
result = ValidationUtils.validatePassword("short");
verify(!result.valid);
verify(result.errors.includes("length"));
// Без цифр
result = ValidationUtils.validatePassword("NoDigitsHere");
verify(!result.valid);
verify(result.errors.includes("digits"));
// Без букв в верхнем регистре
result = ValidationUtils.validatePassword("nouppercase123");
verify(!result.valid);
verify(result.errors.includes("uppercase"));
// Без спецсимволов
result = ValidationUtils.validatePassword("NoSpecial123");
verify(!result.valid);
verify(result.errors.includes("special"));
// Валидный пароль
result = ValidationUtils.validatePassword("ValidPass123!");
verify(result.valid);
compare(result.errors.length, 0);
compare(result.strength, "strong");
}
function test_phone_number_validation() {
// Российские номера
verify(ValidationUtils.isValidPhoneNumber("+7 (900) 123-45-67"));
verify(ValidationUtils.isValidPhoneNumber("89001234567"));
verify(ValidationUtils.isValidPhoneNumber("+7-900-123-45-67"));
// Международные форматы
verify(ValidationUtils.isValidPhoneNumber("+1 (234) 567-8900"));
verify(ValidationUtils.isValidPhoneNumber("+44 20 7946 0958"));
// Невалидные номера
verify(!ValidationUtils.isValidPhoneNumber("not-a-phone"));
verify(!ValidationUtils.isValidPhoneNumber("123"));
verify(!ValidationUtils.isValidPhoneNumber("+7 (900) 123-45-6"));
}
}
Тестирование QML-компонентов как unit-тесты
// TestButton.qml
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtTest 1.4
TestCase {
name: "CustomButtonTests"
width: 400
height: 400
CustomButton {
id: button
text: "Test Button"
width: 100
height: 40
}
SignalSpy {
id: clickedSpy
target: button
signalName: "clicked"
}
SignalSpy {
id: pressedSpy
target: button
signalName: "pressed"
}
SignalSpy {
id: releasedSpy
target: button
signalName: "released"
}
function init() {
clickedSpy.clear();
pressedSpy.clear();
releasedSpy.clear();
button.enabled = true;
button.checked = false;
}
function test_button_click() {
// Симулируем клик
mouseClick(button);
// Проверяем сигналы
compare(clickedSpy.count, 1);
compare(pressedSpy.count, 1);
compare(releasedSpy.count, 1);
}
function test_button_disabled() {
button.enabled = false;
// Пытаемся кликнуть на отключенную кнопку
mouseClick(button);
// Сигналы не должны быть испущены
compare(clickedSpy.count, 0);
compare(pressedSpy.count, 0);
compare(releasedSpy.count, 0);
}
function test_button_toggle() {
button.checkable = true;
// Первый клик - включаем
mouseClick(button);
verify(button.checked);
// Второй клик - выключаем
mouseClick(button);
verify(!button.checked);
}
function test_button_visual_feedback() {
// Проверяем изменение цвета при нажатии
var normalColor = button.color;
// Нажимаем, но не отпускаем
mousePress(button);
// Должен измениться цвет
wait(50); // Даем время для анимации
verify(button.color !== normalColor);
// Отпускаем - должен вернуться исходный цвет
mouseRelease(button);
tryCompare(button, "color", normalColor, 200);
}
function test_button_keyboard_navigation() {
// Тест навигации с клавиатуры
button.forceActiveFocus();
// Нажимаем пробел
keyPress(Qt.Key_Space);
compare(pressedSpy.count, 1);
keyRelease(Qt.Key_Space);
compare(releasedSpy.count, 1);
compare(clickedSpy.count, 1);
// Нажимаем Enter
keyPress(Qt.Key_Return);
compare(pressedSpy.count, 2);
keyRelease(Qt.Key_Return);
compare(releasedSpy.count, 2);
compare(clickedSpy.count, 2);
}
function test_button_accessibility() {
// Проверяем accessibility свойства
verify(button.Accessible.name.length > 0);
compare(button.Accessible.role, Accessible.Button);
verify(button.Accessible.description.length > 0);
}
function benchmark_click_performance() {
skip("Бенчмарк: скорость обработки кликов");
var start = new Date().getTime();
for (var i = 0; i < 1000; i++) {
mouseClick(button);
}
var end = new Date().getTime();
var duration = end - start;
console.log("1000 кликов обработано за " + duration + "ms");
verify(duration < 2000, "Слишком медленно: " + duration + "ms");
}
}
Интеграционные тесты для QML интерфейса
Комплексное тестирование форм и пользовательского ввода
// TestLoginForm.qml
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtTest 1.4
Item {
width: 800
height: 600
// Тестируемая форма
LoginForm {
id: loginForm
anchors.centerIn: parent
}
TestCase {
name: "LoginFormIntegrationTests"
when: windowShown
function initTestCase() {
console.log("Начало тестирования LoginForm");
}
function cleanupTestCase() {
console.log("Завершение тестирования LoginForm");
}
function init() {
// Сброс формы перед каждым тестом
loginForm.reset();
loginForm.errorMessage = "";
wait(100); // Даем время на сброс
}
function test_successful_login() {
// Устанавливаем валидные данные
loginForm.usernameField.text = "testuser";
loginForm.passwordField.text = "SecurePass123!";
// Симулируем нажатие кнопки
mouseClick(loginForm.loginButton);
// Проверяем, что форма обрабатывает ввод
tryCompare(loginForm, "loginStatus", "authenticating");
// Эмулируем успешный ответ от сервера
loginForm.onLoginSuccess({
token: "fake-jwt-token",
user: { name: "Test User", role: "admin" }
});
tryCompare(loginForm, "loginStatus", "success");
compare(loginForm.errorMessage, "");
// Проверяем, что произошел переход
verify(loginForm.loginComplete);
}
function test_failed_login() {
// Устанавливаем неверные данные
loginForm.usernameField.text = "wronguser";
loginForm.passwordField.text = "wrongpass";
mouseClick(loginForm.loginButton);
tryCompare(loginForm, "loginStatus", "authenticating");
// Эмулируем ошибку от сервера
loginForm.onLoginError({
code: 401,
message: "Invalid credentials"
});
tryCompare(loginForm, "loginStatus", "error");
verify(loginForm.errorMessage.length > 0);
verify(loginForm.errorMessage.includes("Invalid credentials"));
// Проверяем, что поля подсвечены
verify(loginForm.usernameField.hasError);
verify(loginForm.passwordField.hasError);
}
function test_form_validation() {
// Тест 1: Пустые поля
loginForm.usernameField.text = "";
loginForm.passwordField.text = "";
mouseClick(loginForm.loginButton);
verify(loginForm.usernameField.hasError);
verify(loginForm.passwordField.hasError);
verify(loginForm.errorMessage.includes("required"));
// Тест 2: Слишком короткий пароль
loginForm.usernameField.text = "user";
loginForm.passwordField.text = "short";
mouseClick(loginForm.loginButton);
verify(loginForm.passwordField.hasError);
verify(loginForm.errorMessage.includes("6 characters"));
// Тест 3: Валидные данные (должны пройти валидацию)
loginForm.passwordField.text = "ValidPass123";
// Ошибок быть не должно
verify(!loginForm.usernameField.hasError);
verify(!loginForm.passwordField.hasError);
compare(loginForm.errorMessage, "");
}
function test_keyboard_navigation() {
// Проверяем навигацию Tab'ом
loginForm.usernameField.forceActiveFocus();
// Переход к полю пароля
keyPress(Qt.Key_Tab);
verify(loginForm.passwordField.activeFocus);
// Переход к кнопке
keyPress(Qt.Key_Tab);
verify(loginForm.loginButton.activeFocus);
// Shift+Tab возвращает назад
keyPress(Qt.Key_Shift);
keyPress(Qt.Key_Tab);
keyRelease(Qt.Key_Tab);
keyRelease(Qt.Key_Shift);
verify(loginForm.passwordField.activeFocus);
}
function test_remember_me_functionality() {
// Проверяем работу "Запомнить меня"
verify(!loginForm.rememberMeCheckbox.checked);
// Ставим галочку
mouseClick(loginForm.rememberMeCheckbox);
verify(loginForm.rememberMeCheckbox.checked);
// Заполняем форму и логинимся
loginForm.usernameField.text = "user";
loginForm.passwordField.text = "password123";
mouseClick(loginForm.loginButton);
loginForm.onLoginSuccess({});
// Сбрасываем форму
loginForm.reset();
// При повторном открытии должны заполниться данные
loginForm.loadSavedCredentials();
tryCompare(loginForm.usernameField, "text", "user");
// Пароль не должен показываться в открытом виде
verify(loginForm.passwordField.text.length > 0);
verify(loginForm.passwordField.echoMode === TextInput.Password);
}
function test_password_visibility_toggle() {
// По умолчанию пароль скрыт
verify(loginForm.passwordField.echoMode === TextInput.Password);
// Нажимаем кнопку показать пароль
mouseClick(loginForm.showPasswordButton);
// Теперь пароль виден
verify(loginForm.passwordField.echoMode === TextInput.Normal);
// Еще раз нажимаем - снова скрыт
mouseClick(loginForm.showPasswordButton);
verify(loginForm.passwordField.echoMode === TextInput.Password);
}
function test_reset_password_flow() {
// Проверяем переход на экран восстановления пароля
mouseClick(loginForm.forgotPasswordLink);
// Должен появиться диалог или перейти на другой экран
verify(loginForm.forgotPasswordDialog.visible);
// Заполняем email для восстановления
var emailField = findChild(loginForm.forgotPasswordDialog, "emailField");
emailField.text = "user@example.com";
mouseClick(loginForm.forgotPasswordDialog.submitButton);
tryCompare(loginForm.forgotPasswordDialog, "status", "sent");
}
function test_concurrent_login_attempts() {
// Защита от множественных одновременных запросов
loginForm.usernameField.text = "user";
loginForm.passwordField.text = "password123";
// Делаем несколько быстрых кликов
for (var i = 0; i < 5; i++) {
mouseClick(loginForm.loginButton);
}
// Должен быть отправлен только один запрос
verify(loginForm.loginRequestsCount <= 1);
}
function test_network_error_handling() {
// Эмулируем отключение сети
loginForm.networkAvailable = false;
loginForm.usernameField.text = "user";
loginForm.passwordField.text = "password123";
mouseClick(loginForm.loginButton);
// Должна быть соответствующая ошибка
tryCompare(loginForm, "loginStatus", "error");
verify(loginForm.errorMessage.toLowerCase().includes("network"));
verify(loginForm.errorMessage.toLowerCase().includes("offline"));
}
function benchmark_form_rendering() {
skip("Бенчмарк: рендеринг формы");
var start = new Date().getTime();
// Многократно показываем/скрываем форму
for (var i = 0; i < 100; i++) {
loginForm.visible = false;
wait(10);
loginForm.visible = true;
wait(10);
}
var end = new Date().getTime();
console.log("100 циклов показа/скрытия: " + (end - start) + "ms");
}
// Вспомогательная функция для поиска дочерних элементов
function findChild(parent, objectName) {
for (var i = 0; i < parent.children.length; i++) {
var child = parent.children[i];
if (child.objectName === objectName) {
return child;
}
var found = findChild(child, objectName);
if (found) return found;
}
return null;
}
}
}
Тестирование навигации и маршрутизации
// TestAppNavigation.qml
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtTest 1.4
import "../src" // Импорт основных компонентов
Item {
width: 1024
height: 768
// Главное окно приложения
MainWindow {
id: mainWindow
anchors.fill: parent
}
TestCase {
name: "AppNavigationTests"
when: windowShown
function init() {
// Сбрасываем навигацию к начальному состоянию
mainWindow.navigation.reset();
waitForRendering();
}
function test_initial_navigation_state() {
// Проверяем начальный экран
compare(mainWindow.currentPage, "Login");
verify(mainWindow.loginPage.visible);
verify(!mainWindow.mainPage.visible);
}
function test_successful_login_navigation() {
// Логинимся
mainWindow.loginPage.username = "testuser";
mainWindow.loginPage.password = "password123";
mainWindow.loginPage.login();
// Проверяем навигацию на главный экран
tryCompare(mainWindow, "currentPage", "Main");
verify(!mainWindow.loginPage.visible);
verify(mainWindow.mainPage.visible);
// Проверяем, что в стеке навигации корректное состояние
compare(mainWindow.navigationStack.depth, 1);
compare(mainWindow.navigationStack.currentItem.objectName, "MainPage");
}
function test_navigation_to_settings() {
// Сначала логинимся
test_successful_login_navigation();
// Переходим в настройки
mouseClick(mainWindow.mainPage.settingsButton);
tryCompare(mainWindow, "currentPage", "Settings");
verify(mainWindow.settingsPage.visible);
// Проверяем стек навигации
compare(mainWindow.navigationStack.depth, 2);
}
function test_back_navigation() {
// Переходим в настройки
test_navigation_to_settings();
// Нажимаем кнопку "Назад"
mouseClick(mainWindow.settingsPage.backButton);
// Должны вернуться на главный экран
tryCompare(mainWindow, "currentPage", "Main");
verify(mainWindow.mainPage.visible);
compare(mainWindow.navigationStack.depth, 1);
}
function test_deep_navigation() {
// Сложный сценарий навигации
test_successful_login_navigation();
// Главная → Настройки → Профиль → Редактирование профиля
mouseClick(mainWindow.mainPage.settingsButton);
mouseClick(mainWindow.settingsPage.profileButton);
mouseClick(mainWindow.profilePage.editButton);
// Проверяем текущий экран
tryCompare(mainWindow, "currentPage", "EditProfile");
compare(mainWindow.navigationStack.depth, 4);
// Возвращаемся на главный экран
mainWindow.navigation.popToRoot();
tryCompare(mainWindow, "currentPage", "Main");
compare(mainWindow.navigationStack.depth, 1);
}
function test_navigation_with_parameters() {
// Навигация с передачей параметров
mainWindow.navigation.navigateTo("ProductDetails", {
productId: 123,
productName: "Test Product"
});
tryCompare(mainWindow, "currentPage", "ProductDetails");
// Проверяем, что параметры переданы
var productPage = mainWindow.productDetailsPage;
compare(productPage.productId, 123);
compare(productPage.productName, "Test Product");
}
function test_navigation_guards() {
// Попытка перехода на защищенный экран без авторизации
mainWindow.navigation.navigateTo("Profile");
// Должны перенаправить на логин
tryCompare(mainWindow, "currentPage", "Login");
// После логина должны автоматически перейти на запрошенный экран
test_successful_login_navigation();
// Должны быть на экране профиля, а не главном
tryCompare(mainWindow, "currentPage", "Profile");
}
function test_navigation_history() {
// Проверяем историю навигации
test_deep_navigation();
var history = mainWindow.navigation.history;
compare(history.length, 4);
// Проверяем порядок
compare(history[0].page, "Login");
compare(history[1].page, "Main");
compare(history[2].page, "Settings");
compare(history[3].page, "EditProfile");
// Возвращаемся на два шага назад
mainWindow.navigation.goBack(2);
tryCompare(mainWindow, "currentPage", "Settings");
}
function test_url_based_navigation() {
// Навигация по URL
mainWindow.navigation.handleUrl("/products/123/details");
tryCompare(mainWindow, "currentPage", "ProductDetails");
compare(mainWindow.productDetailsPage.productId, 123);
// Изменяем URL через браузерные кнопки
mainWindow.navigation.handleUrl("/settings/profile");
tryCompare(mainWindow, "currentPage", "Profile");
verify(mainWindow.profilePage.visible);
}
function test_navigation_performance() {
skip("Бенчмарк: производительность навигации");
var start = new Date().getTime();
// Многократная навигация
for (var i = 0; i < 50; i++) {
mainWindow.navigation.navigateTo("Settings");
waitForRendering();
mainWindow.navigation.navigateTo("Main");
waitForRendering();
}
var end = new Date().getTime();
var duration = end - start;
console.log("50 циклов навигации: " + duration + "ms");
verify(duration < 5000, "Слишком медленно: " + duration + "ms");
}
function test_navigation_memory_usage() {
// Проверяем утечки памяти при навигации
var initialMemory = getMemoryUsage();
// Много навигаций
for (var i = 0; i < 100; i++) {
mainWindow.navigation.navigateTo("Page" + (i % 5));
waitForRendering();
mainWindow.navigation.goBack();
waitForRendering();
}
// Принудительный сбор мусора (если доступен)
if (typeof gc !== 'undefined') {
gc();
}
var finalMemory = getMemoryUsage();
var memoryDiff = finalMemory - initialMemory;
console.log("Использование памяти: начальное=" + initialMemory +
"KB, конечное=" + finalMemory + "KB, разница=" + memoryDiff + "KB");
// Утечки не должно быть
verify(memoryDiff < 1024, "Возможная утечка памяти: " + memoryDiff + "KB");
}
// Вспомогательные функции
function waitForRendering() {
wait(50); // Даем время на рендеринг
}
function getMemoryUsage() {
// Упрощенный способ оценки использования памяти
// В реальном проекте могут быть более точные методы
return 0;
}
}
}
Продвинутые техники и инструменты
Тестирование производительности и нагрузочное тестирование
// PerformanceTests.qml
import QtQuick 2.15
import QtTest 1.4
import QtQuick.Controls 2.15
Item {
width: 800
height: 600
ListView {
id: listView
anchors.fill: parent
model: 1000
delegate: Rectangle {
width: listView.width
height: 50
color: index % 2 ? "lightgray" : "white"
Text {
text: "Item " + index
anchors.centerIn: parent
font.pixelSize: 16
}
}
}
TestCase {
name: "PerformanceTests"
when: windowShown
function benchmark_list_scrolling() {
skip("Бенчмарк: скроллинг списка");
var startTime = new Date().getTime();
var frames = 0;
// Функция для измерения FPS
function checkFrame() {
frames++;
var currentTime = new Date().getTime();
if (currentTime - startTime < 2000) { // 2 секунды
requestAnimationFrame(checkFrame);
} else {
var fps = frames / 2; // FPS за 2 секунды
console.log("FPS при скроллинге: " + fps);
verify(fps >= 30, "Низкий FPS: " + fps);
}
}
// Начинаем скроллинг
listView.contentY = 0;
var scrollAnimation = Qt.createQmlObject('
import QtQuick 2.15
NumberAnimation {
target: listView
property: "contentY"
to: listView.contentHeight - listView.height
duration: 2000
running: true
}', listView, "scrollAnimation");
// Запускаем измерение FPS
requestAnimationFrame(checkFrame);
// Ждем завершения анимации
tryCompare(scrollAnimation, "running", false, 3000);
}
function benchmark_list_rendering() {
skip("Бенчмарк: рендеринг списка");
// Измеряем время первоначального рендеринга
var start = new Date().getTime();
// Увеличиваем количество элементов
listView.model = 5000;
waitForRendering();
var end = new Date().getTime();
var renderTime = end - start;
console.log("Рендеринг 5000 элементов: " + renderTime + "ms");
verify(renderTime < 1000, "Слишком медленный рендеринг: " + renderTime + "ms");
// Восстанавливаем
listView.model = 1000;
}
function test_memory_consumption() {
// Тест потребления памяти при динамическом добавлении
var initialItemCount = listView.model;
var memoryReadings = [];
function takeMemoryReading() {
// В реальном проекте используйте системные вызовы
memoryReadings.push(estimateMemoryUsage());
}
takeMemoryReading();
// Добавляем элементы
for (var i = 0; i < 10; i++) {
listView.model += 1000;
waitForRendering();
takeMemoryReading();
}
// Анализируем рост памяти
var memoryGrowth = memoryReadings[memoryReadings.length - 1] - memoryReadings[0];
var growthPerItem = memoryGrowth / (listView.model - initialItemCount);
console.log("Потребление памяти на элемент: " + growthPerItem + "KB");
verify(growthPerItem < 10, "Высокое потребление памяти на элемент: " + growthPerItem + "KB");
}
function estimateMemoryUsage() {
// Упрощенная оценка использования памяти
// В реальном проекте используйте:
// - QML Profiler
// - Системные инструменты
// - MemoryInfo из Qt
return listView.model * 0.5; // Примерная оценка
}
function waitForRendering() {
wait(100);
}
}
}
Интеграция с CI/CD и отчеты о покрытии
Полная конфигурация CMake с поддержкой тестирования:
# Основной CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyQMLApp VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
# Находим Qt
find_package(Qt6 REQUIRED COMPONENTS
Core
Quick
QuickControls2
QuickTest
Test
Network
)
# Опция для включения тестов
option(BUILD_TESTS "Build tests" ON)
option(ENABLE_COVERAGE "Enable code coverage" OFF)
option(ENABLE_ASAN "Enable Address Sanitizer" OFF)
# Настройки для coverage
if(ENABLE_COVERAGE)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fprofile-arcs -ftest-coverage")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -lgcov")
endif()
# Настройки для Address Sanitizer
if(ENABLE_ASAN)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")
endif()
# Главное приложение
add_executable(myapp
src/main.cpp
src/backend/models/UserModel.cpp
src/backend/services/ApiService.cpp
)
target_link_libraries(myapp
PRIVATE
Qt6::Core
Qt6::Quick
Qt6::QuickControls2
Qt6::Network
)
# QML модуль
qt_add_qml_module(myapp
URI MyApp
VERSION 1.0
QML_FILES
src/qml/main.qml
src/qml/components/Button.qml
src/qml/screens/LoginScreen.qml
)
# Тесты
if(BUILD_TESTS)
# Unit-тесты для C++
add_executable(unit_tests
tests/unit/TestUserModel.cpp
tests/unit/TestApiService.cpp
tests/unit/TestUtils.cpp
)
target_link_libraries(unit_tests
PRIVATE
Qt6::Test
Qt6::Core
)
# Интеграционные тесты QML
add_executable(integration_tests
tests/integration/main.cpp
)
qt_add_qml_module(integration_tests
URI IntegrationTests
VERSION 1.0
QML_FILES
tests/integration/TestLoginScreen.qml
tests/integration/TestMainScreen.qml
tests/integration/TestNavigation.qml
)
target_link_libraries(integration_tests
PRIVATE
Qt6::QuickTest
Qt6::Quick
Qt6::Core
)
# Performance тесты
add_executable(performance_tests
tests/performance/main.cpp
)
qt_add_qml_module(performance_tests
URI PerformanceTests
VERSION 1.0
QML_FILES
tests/performance/ListPerformanceTest.qml
tests/performance/RenderingTest.qml
)
target_link_libraries(performance_tests
PRIVATE
Qt6::QuickTest
Qt6::Quick
)
# Добавляем цели для запуска тестов
add_custom_target(run_unit_tests
COMMAND unit_tests -o results/unit_results.xml,xunitxml
DEPENDS unit_tests
COMMENT "Running unit tests..."
)
add_custom_target(run_integration_tests
COMMAND xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24"
integration_tests -input tests/integration -o results/integration_results.xml,xunitxml
DEPENDS integration_tests
COMMENT "Running integration tests..."
)
add_custom_target(run_all_tests
DEPENDS run_unit_tests run_integration_tests
COMMENT "Running all tests..."
)
# Генерация отчетов о покрытии
if(ENABLE_COVERAGE)
find_program(GCOVR_EXECUTABLE gcovr)
if(GCOVR_EXECUTABLE)
add_custom_target(coverage_report
COMMAND ${GCOVR_EXECUTABLE}
--html
--html-details
--output coverage_report.html
--exclude ".*_test.cpp"
--exclude ".*moc_.*"
--exclude "/usr/*"
DEPENDS run_all_tests
COMMENT "Generating coverage report..."
)
endif()
endif()
endif()
GitHub Actions конфигурация с полным пайплайном:
name: QML CI/CD Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
schedule:
- cron: '0 2 * * 0' # Еженедельно в воскресенье в 2:00
jobs:
build-and-test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
qt-version: ['6.5.0', '6.6.0']
include:
- os: ubuntu-latest
qt-target: desktop
qt-arch: gcc_64
- os: windows-latest
qt-target: desktop
qt-arch: win64_msvc2019_64
- os: macos-latest
qt-target: desktop
qt-arch: clang_64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Qt ${{ matrix.qt-version }}
uses: jurplel/install-qt-action@v3
with:
version: ${{ matrix.qt-version }}
target: ${{ matrix.qt-target }}
arch: ${{ matrix.qt-arch }}
install-deps: true
- name: Configure CMake
run: |
cmake -B build \
-DBUILD_TESTS=ON \
-DENABLE_COVERAGE=${{ matrix.os == 'ubuntu-latest' }} \
-DENABLE_ASAN=${{ matrix.os == 'ubuntu-latest' }} \
-DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build --config Release --parallel 4
- name: Run Unit Tests
run: |
cd build
./unit_tests -o ../results/unit_results.xml,xunitxml
continue-on-error: true
- name: Run Integration Tests
if: runner.os != 'Windows' # xvfb-run не нужен на Windows
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" \
./build/integration_tests -input tests/integration \
-o results/integration_results.xml,xunitxml
- name: Run Integration Tests (Windows)
if: runner.os == 'Windows'
run: |
./build/integration_tests -input tests/integration \
-o results/integration_results.xml,xunitxml
- name: Run Performance Tests
run: |
./build/performance_tests -input tests/performance \
-o results/performance_results.xml,xunitxml
- name: Generate Coverage Report
if: matrix.os == 'ubuntu-latest' && success()
run: |
gcovr --html --html-details \
--output coverage_report.html \
--exclude ".*_test.cpp" \
--exclude ".*moc_.*" \
--exclude "/usr/*" \
--root .
- name: Upload Test Results
uses: actions/upload-artifact@v3
with:
name: test-results-${{ matrix.os }}-qt${{ matrix.qt-version }}
path: |
results/*.xml
coverage_report.html
- name: Upload Coverage to Codecov
if: matrix.os == 'ubuntu-latest'
uses: codecov/codecov-action@v3
with:
files: coverage_report.html
flags: unittests
security-scan:
runs-on: ubuntu-latest
needs: build-and-test
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Semgrep Scan
uses: returntocorp/semgrep-action@v1
with:
config: p/qt
- name: QML Lint
run: |
pip install qmllint
find . -name "*.qml" -exec qmllint {} \;
deploy:
runs-on: ubuntu-latest
needs: [build-and-test, security-scan]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Download artifacts
uses: actions/download-artifact@v3
- name: Deploy to Test Server
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
SERVER_HOST: ${{ secrets.SERVER_HOST }}
run: |
# Скрипт деплоя
./scripts/deploy.sh
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: |
build/myapp
coverage_report.html
generate_release_notes: true
Антипаттерны
Распространенные ошибки при тестировании QML
Антипаттерн 1: Тестирование реализации, а не поведения
// ПЛОХО: Тестирует детали реализации
function test_button_implementation() {
// Эти детали могут измениться
compare(button.children[0].color, "#ff0000");
compare(button.children[1].x, 10);
}
// ХОРОШО: Тестирует публичное поведение
function test_button_behavior() {
mouseClick(button);
compare(clickedSpy.count, 1);
verify(button.checked);
}
Антипаттерн 2: Отсутствие изоляции тестов
// ПЛОХО: Тесты зависят от глобального состояния
var globalCounter = 0;
function test_increment() {
globalCounter++;
compare(globalCounter, 1);
}
function test_decrement() {
globalCounter--; // Зависит от предыдущего теста!
compare(globalCounter, 0);
}
// ХОРОШО: Каждый тест изолирован
function test_increment() {
var counter = new Counter();
counter.increment();
compare(counter.value, 1);
}
function test_decrement() {
var counter = new Counter();
counter.decrement();
compare(counter.value, -1);
}
Антипаттерн 3: Игнорирование асинхронности
// ПЛОХО: Не ожидает асинхронных операций
function test_async_operation() {
component.startAsyncOperation();
// Может упать, если операция не завершилась мгновенно
compare(component.result, "expected");
}
// ХОРОШО: Использует tryCompare для асинхронных операций
function test_async_operation() {
component.startAsyncOperation();
tryCompare(component, "result", "expected", 2000);
}
Заключение
Тестирование QML-приложений — это комплексная дисциплина, требующая понимания как самого QML, так и принципов тестирования UI. Ключевые выводы:
- Многоуровневый подход: Сочетайте unit-тесты для логики, интеграционные тесты для взаимодействия компонентов и end-to-end тесты для полных сценариев.
- Изоляция и скорость: Unit-тесты должны быть быстрыми и изолированными. Используйте моки и стабы для внешних зависимостей.
- Тестирование асинхронности: QML сильно зависит от асинхронных операций. Всегда используйте
tryCompare,tryVerifyи другие асинхронные проверки. - Производительность имеет значение: Регулярно запускайте performance-тесты, особенно для списков и сложных анимаций.
- Автоматизация: Интегрируйте тесты в CI/CD pipeline. Автоматические тесты должны запускаться при каждом коммите.
- Качество кода тестов: Тестовый код должен соответствовать тем же стандартам качества, что и продакшен-код. Рефакторите тесты, удаляйте дублирование, давайте понятные имена.
- Покрытие важных сценариев: Фокус на тестировании критического функционала и основных пользовательских сценариев. 100% покрытие не всегда достижимо или необходимо.
