Руководство по тестированию QML-приложений: От unit-тестов до интеграционного тестирования UI

Автор: | 22 января, 2026

Введение: Почему тестирование QML требует особого подхода

QML (Qt Modeling Language) представляет собой уникальный гибрид декларативного и императивного программирования, что создает особые вызовы для тестирования. В отличие от традиционных UI-фреймворков, QML-приложения сочетают в себе:

  1. Декларативный UI-слой с реактивными привязками данных
  2. Императивную логику на JavaScript, встроенную прямо в UI-компоненты
  3. C++ бэкенд, экспортированный в QML через систему мета-объектов Qt
  4. Сложную систему сигналов и слотов, работающую между слоями

Такая архитектура требует многоуровневого подхода к тестированию, который мы детально рассмотрим в этом руководстве. Мы не только рассмотрим инструменты, но и погрузимся в философию тестирования 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. Ключевые выводы:

  1. Многоуровневый подход: Сочетайте unit-тесты для логики, интеграционные тесты для взаимодействия компонентов и end-to-end тесты для полных сценариев.
  2. Изоляция и скорость: Unit-тесты должны быть быстрыми и изолированными. Используйте моки и стабы для внешних зависимостей.
  3. Тестирование асинхронности: QML сильно зависит от асинхронных операций. Всегда используйте tryCompare, tryVerify и другие асинхронные проверки.
  4. Производительность имеет значение: Регулярно запускайте performance-тесты, особенно для списков и сложных анимаций.
  5. Автоматизация: Интегрируйте тесты в CI/CD pipeline. Автоматические тесты должны запускаться при каждом коммите.
  6. Качество кода тестов: Тестовый код должен соответствовать тем же стандартам качества, что и продакшен-код. Рефакторите тесты, удаляйте дублирование, давайте понятные имена.
  7. Покрытие важных сценариев: Фокус на тестировании критического функционала и основных пользовательских сценариев. 100% покрытие не всегда достижимо или необходимо.