IT Notes

Разработка через тестирование в Qt

Введение

С концепцией разработки через тестирование (TDD - test-driven development) я познакомился по книгам Роберта Мартина:

  1. Чистый код. Создание, анализ и рефакторинг;
  2. Идеальный программист. Как стать профессионалом разработки ПО.

Это действительно хорошие книги, которые следует прочитать, если вы намереваетесь серьезно заниматься программированием или уже это делаете, но по какой-то причине обошли эти книги стороной. В какой-то мере отношение к TDD у Роберта М. доведено до фанатизма. Однако нельзя не согласиться, что преимущества от применения этой техники весьма существенны. Но есть и свои недостатки, которые во многом связаны с ограничениями разработки через тестирование. Об этих преимуществах и недостатках в контексте использования Qt мы и поговорим.

Коротко о TDD

Идея создания программ с помощью TDD достаточно проста. Ее можно описать с помощью следующего алгоритма:

  1. Создайте тест, который не будет проходить;
  2. Напишите минимальное количество кода, который заставит тест проходить. Этот пункт самый важный и к нему сложнее всего привыкнуть. Более подробно мы обсудим его при рассмотрении примера;
  3. Проведите рефакторинг (то есть реорганизуйте структуру кода для его улучшения без изменения функциональности), если это возможно;
  4. Повторяйте шаги 1-3 до тех пор, пока разрабатываемая программа не будет делать то, что нужно :)

Звучит достаточно легко, но нужно приобрести некую сноровку, чтобы пользоваться этим приемом правильно.

Преимущества TDD

Перечислим главные преимущества, которые вы получите от использования TDD:

  1. При строгом соблюдении весь код окажется протестированным. Поскольку на каждой итерации разработки новую логику и функционал мы добавляем лишь после того, как напишем соответствующий тест, то покрытие кода окажется 100%-ым;
  2. Получившийся код будет иметь достаточно простую структуру. Это связано с тем, что мы просто не сможем нормально протестировать неудачно построенный модуль. Если мы сначала будем писать код, то вполне вероятно, что появится много зависимостей, из-за которых модульное тестирование может выйти либо неоправданно сложным, либо вообще практически невозможным. TDD же исключает этот вариант, потому что код пишется на основе тестов, а не наоборот;
  3. Сюда же следует отнести все преимущества от применения модульного тестирования в целом. Это просто следствие, но оно оказывает не менее серьезное влияние на процесс разработки. Например, бонусом к TDD вы получаете мощный инструмент регрессионного тестирования. С ним вы всегда можете быть уверенны, что внесенные изменения не нарушили работу уже написанного кода, а если что-то и произошло, то вы сразу узнаете об этом по тестам, которые не прошли.

Недостатки TDD

Очень часто приверженцы TDD умалчивают о его недостатках, но они есть. И с ними нужно считаться. Вот наиболее важные из них:

  1. Не для любого кода можно написать модульные тесты. Например, графический пользовательский интерфейс. Если речь идет о разработке виджета, то вполне можно разработать модульные тесты для проверки его функциональных возможностей. Но как вы скажете, что виджет выглядит правильно или нет? Конечно, можно задуматься о применении систем распознавания образов и искусственного интеллекта, но гораздо дешевле и результативнее проводить юзабилити тестирование с потенциальными пользователями. А как вы будете тестировать функцию Random()? Подумайте об этом в свободное время;
  2. Тестирование не гарантирует правильность. Хорошее покрытие кода тестами не обеспечивает его корректность. Это означает лишь то, что код соответствует тем ожиданиям, которые вы учли при разработке тестов. Если же вы что-то упустили или поняли требования не так, то код может оказаться некорректным, причем вместе с тестами для него;
  3. На сопровождение самих тестов уходит не мало времени. В случае строгого применения TDD вы не имеете права вносить изменения в основной код без добавления соответствующего теста. Это означает, что тестов будет много, и они сами могут стать источником ошибок. То есть вам придется заниматься не только отладкой и рефакторингом кода приложения, но и кода тестов, что может оказаться не менее трудным.

Пример использования TDD с Qt

А теперь попробуем создать несложный Qt-модуль с помощью TDD.

Прежде чем начать

Если вы поищите примеры использования TDD в интернете или литературе, то увидите, что практически всегда они сводятся к разработке модулей без внешних зависимостей. И в какой-то мере такая ситуация вполне оправдана, поскольку TDD для этого и предназначен (мое личное мнение, хотя вы можете быть не согласны). Например, с помощью TDD легко можно разработать алгоритм разложения целого числа на множители. Вполне понятно, как должна выглядеть соответствующая функция, какие она должна принимать аргументы, и какие тесты для ее проверки понадобятся.

А что вы скажете о разработке сетевого модуля? Или модуля для взаимодействия с базами данных? В этом случае мы имеет внешние зависимости, контроль над которыми ограничен. В большинстве случаев мы не можем воспроизвести редкие ошибки сторонних систем по нашему желанию. И как же поступить? - Для этого используют Mock-объекты. Идея заключается в том, что для тестирования можно использовать не настоящую внешнюю систему, а некую фиктивную реализацию. В простейшем случае такой Mock-объект может возвращать константу. В других же случаях он может использовать упрощенную реализацию функционала реальной подсистемы (например, сохранять данные не в БД, а в ассоциативном массиве в памяти). Еще существуют разновидности Mock-фреймворков, которые позволяют создавать Mock-объекты с произвольными последовательностями возвращаемых значений (например, вас может заинтересовать проект googlemock).

Как я уже отмечал, мое мнение заключается в том, что TDD эффективнее применять для разработки систем без внешних зависимостей. Конечно, Mock-объекты - это выход, но так ли они полезны? С одной стороны, если задуматься о принципе DRY (см. Принцип DRY в действии), то смысл есть. Ведь если вы выявили какую-то ошибку в коде взаимодействия с БД, то вполне вероятно, что она когда-нибудь может повториться вновь. В этом случае у вас три варианта: либо тестировать ее вручную после каждого изменения; либо надеяться, что она больше никогда не произойдет; либо автоматизировать ее проверку с помощью соответствующих модульных тестов. Первый вариант плох тем, что вы будете делать лишнюю работу, то есть повторяться. Второй вариант делает ваш код уязвимым. Последний же вариант потребует довольно много дополнительной работы. На мой взгляд, в этом случае все зависит от масштабов и критичности проекта. Если ошибки недопустимы (например, авиационное или медицинское ПО), то тестирование обязано быть исчерпывающим, а поэтому автоматизированным. С другой стороны, если вы пишите приложение для среднестатистического пользователя, то вполне можете рассчитывать на некий уровень толерантности. Вспомните те же продукты из серии Microsoft Office. Не знаю как у вас, но у меня они регулярно падают и глючат (при этом я пользуюсь лицензионными версиями).

А теперь к делу

Давайте реализуем с помощью TDD алгоритм для поворота матрицы на 90 градусов по часовой стрелке. Он должен делать примерно следующее:

То есть можно представить, что берется некая матрица и просто переворачивается на бок. Выглядит достаточно легко, поэтому думаю, что мы справимся.

И первым делом подготовим Qt-проект со следующей структурой:

.
├── app.pri
├── bin/
├── common.pri
├── include/
│   ├── tdddemolib_global.h
│   └── tdddemolib.h
├── lib.linux/
├── lib.pri
├── src/
│   └── TDDDemoLib/
│       ├── tdddemolib.cpp
│       └── TDDDemoLib.pro
├── TDDDemo.pro
├── TDDDemo.pro.user
└── tests/
    └── TDDDemoTest/
        ├── TDDDemoTest.pro
        └── tst_tdddemotest.cpp

Как вы можете видеть, проект состоит из двух модулей: TDDDemoLib и TDDDemoTest. Как понятно из названия, первый модуль представляет собой библиотеку, в которой мы определим и реализуем нашу функцию, а во второй модуль мы поместим наши тесты. Более подробно про организацию Qt-проектов вы можете прочитать в моей заметке Структура Qt-проекта на C++, поэтому здесь я лишь приведу содержимое pro-файлов без дополнительных комментариев.

TDDemo.pro:

TEMPLATE = subdirs

SUBDIRS += \
    src/TDDDemoLib \
    tests/TDDDemoTest

TDDemoLib.pro:

QT       -= gui

include( ../../common.pri )
include( ../../lib.pri )

TARGET = TDDDemoLib$${LIB_SUFFIX}
TEMPLATE = lib

DEFINES += TDDDEMOLIB_LIBRARY

SOURCES += tdddemolib.cpp

HEADERS += ../../include/tdddemolib.h\
        ../../include/tdddemolib_global.h

TDDemoTest.pro:

QT       += testlib

QT       -= gui

TARGET = tst_tdddemotest
CONFIG   += console
CONFIG   -= app_bundle

TEMPLATE = app


SOURCES += tst_tdddemotest.cpp

include( ../../common.pri )
include( ../../app.pri )

LIBS += -lTDDDemoLib$${LIB_SUFFIX}

Здесь мы лишь обратим внимание на то, что в подпроекте TDDDemoTest мы подключили Qt-модуль testlib. С его помощью мы и будем проводить тестирование.

В tdddemolib.h сейчас мы определим лишь тип матрицы:

#ifndef TDDDEMOLIB_H
#define TDDDEMOLIB_H

#include "tdddemolib_global.h"

#include <QMetaType>
#include <QVector>

typedef QVector< QVector< int > > Matrix;

Q_DECLARE_METATYPE( Matrix )

#endif // TDDDEMOLIB_H

Обратите внимание, что в этом фрагменте мы воспользовались Q_DECLARE_METATYPE. Это сделано для того, чтобы в коде тестов задействовать одну весьма удобную возможность, о который мы скоро поговорим.

Но раз используем TDD, то в первую очередь займемся тестами. Посмотрим на содержимое tst_tdddemotest.cpp:

#include <QString>
#include <QtTest>

#include <tdddemolib.h>

class TDDDemoTest : public QObject {
    Q_OBJECT

public:
    TDDDemoTest();

private slots:
    void rotate90DegreesTest();
    void rotate90DegreesTest_data();
};

TDDDemoTest::TDDDemoTest() {
}

void TDDDemoTest::rotate90DegreesTest() {
    QFETCH( Matrix, matrix );
    QFETCH( Matrix, result );
}

void TDDDemoTest::rotate90DegreesTest_data() {
    QTest::addColumn< Matrix >( "matrix" );
    QTest::addColumn< Matrix >( "result" );
}

QTEST_APPLESS_MAIN( TDDDemoTest )

#include "tst_tdddemotest.moc"

Для организации наших тестов мы будем использовать класс TDDDemoTest. Он наследует QObject и имеет два слота: rotate90DegreesTest() и rotate90DegreesTest_data(). Это позволит нам строить тесты, управляемые данными. То есть мы всего один раз определим процедуру проверки в rotate90DegreesTest(), а все данные будем компоновать в соответствующем слоте rotate90DegreesTest_data(). Определение структур данных, которые будут использоваться в тесте, осуществляется с помощью QTest::addColumn(). А получение этих данных выполняется с помощью макроса QFETCH. По сути этот механизм работает на основе QVariant, поэтому нам и понадобилось объявление мета-типа для Matrix.

Модульный тест в Qt является исполняемым приложением (в нашем случае консольным), поэтому в нем предусмотрен макрос для определения функции main(). Этим макросом мы и воспользовались: QTEST_APPLESS_MAIN( TDDDemoTest ). Кроме того, обратите внимание на последнюю строку. Поскольку весь код теста мы пишем в одном cpp-файле, то нам нужно явно указать инструкцию #include "tst_tdddemotest.moc". Это нужно лишь из-за того, что мы унаследовали класс QObject и использовали макрос Q_OBJECT для получения возможности добавления слотов.

Проверку корректности прохождения теста мы будем проверять следующим образом:

void TDDDemoTest::rotate90DegreesTest() {
    QFETCH( Matrix, matrix );
    QFETCH( Matrix, result );

    QCOMPARE( rotate90Degrees( matrix ), result );
}

Это тот же слот, который мы уже рассматривали. Но в него добавлена одна строка. В ней мы используем макрос QCOMPARE. Он обеспечивает сравнение фактического и ожидаемого результатов работы функции на основе переданных данных. Если они будут отличаться, то тест будет засчитан как не пройденный.

Теперь добавим первые тестовые данные:

void TDDDemoTest::rotate90DegreesTest_data() {
    QTest::addColumn< Matrix >( "matrix" );
    QTest::addColumn< Matrix >( "result" );

    QTest::newRow( "Empty matrix" ) << Matrix { { } } << Matrix { { } };
}

Мы делаем это с помощью QTest::newRow(). В качестве простейшего теста мы выполняем поворот пустой матрицы. Для краткости записи я использовал синтаксис C++11 при инициализации матриц (то есть векторов). Конечно, можно обойтись и без них, но тогда кода вышло бы существенно больше.

Отлично. Первый тест готов. Но мы не можем даже собрать его, поскольку у нас еще нет функции rotate90Degrees(). Добавим ее объявление и минимальную реализацию:

// Добавили в tdddemolib.h:
Matrix rotate90Degrees( const Matrix& matrix );

// Добавили в tdddemolib.cpp:
Matrix rotate90Degrees( const Matrix& matrix ) {
    return matrix;
}

И вот что мы можем увидеть на консоли после запуска теста:

********* Start testing of TDDDemoTest *********
Config: Using QTest library 4.8.6, Qt 4.8.6
PASS   : TDDDemoTest::initTestCase()
PASS   : TDDDemoTest::rotate90DegreesTest()
PASS   : TDDDemoTest::cleanupTestCase()
Totals: 3 passed, 0 failed, 0 skipped
********* Finished testing of TDDDemoTest *********

Прекрасно. Пора добавлять новые тестовые данные:

void TDDDemoTest::rotate90DegreesTest_data() {
    // …

    QTest::newRow( "2x1 single value matrix" ) <<
                                     Matrix { { 0, 0 } } <<
                                     Matrix { { 0 },
                                              { 0 } };
}

Этот вариант соответствует случаю матрицы, состоящей из одной строки, включающей два одинаковых символа. В результате должна получиться матрица-столбец. Попробуем запустить тест:

********* Start testing of TDDDemoTest *********
Config: Using QTest library 4.8.6, Qt 4.8.6
PASS   : TDDDemoTest::initTestCase()
FAIL!  : TDDDemoTest::rotate90DegreesTest(2x1 single value matrix) Compared values are not the same
   Loc: [tst_tdddemotest.cpp(24)]
PASS   : TDDDemoTest::cleanupTestCase()
Totals: 2 passed, 1 failed, 0 skipped
********* Finished testing of TDDDemoTest *********

Тест не прошел. Нужно это исправить:

Matrix rotate90Degrees( const Matrix& matrix ) {
    if( matrix[ 0 ].isEmpty() ) {
        return matrix;
    }

    return Matrix { { 0 },
                    { 0 } };
}

Если вы не знакомы с TDD, то представленный промежуточный алгоритм может показаться вам необычным. Но в этом и заключается идея разработки через тестирование. Не усложняйте реализацию раньше времени. Пишите минимальное количество кода, которое способно обеспечить прохождение всех имеющихся к текущему моменту тестов. Убедимся, что это так, и тесты проходят:

********* Start testing of TDDDemoTest *********
Config: Using QTest library 4.8.6, Qt 4.8.6
PASS   : TDDDemoTest::initTestCase()
PASS   : TDDDemoTest::rotate90DegreesTest()
PASS   : TDDDemoTest::cleanupTestCase()
Totals: 3 passed, 0 failed, 0 skipped
********* Finished testing of TDDDemoTest *********

Отлично. Добавляем новый набор данных:

void TDDDemoTest::rotate90DegreesTest_data() {
    // …

    QTest::newRow( "2x1 different values matrix" ) <<
                                     Matrix { { 1, 2 } } <<
                                     Matrix { { 1 },
                                              { 2 } };
}

Теперь у нас матрица 2x1, состоящая из разных элементов. Тест провален. Исправляем код:

Matrix rotate90Degrees( const Matrix& matrix ) {
    if( matrix[ 0 ].isEmpty() ) {
        return matrix;
    }

    return Matrix { { matrix[ 0 ][ 0 ] },
                    { matrix[ 0 ][ 1 ] } };
}

Теперь тест проходит. Пора добавить новые тестовые данные:

void TDDDemoTest::rotate90DegreesTest_data() {
    // …

    QTest::newRow( "1x2 different values matrix" ) <<
                                     Matrix { { 1 },
                                              { 2 } } <<
                                     Matrix { { 2, 1 } };

Теперь в тестовых данных мы используем матрицу 1x2 с разными элементами. Тест вновь не проходит, сообщая об ошибке "index out of range". Займемся доработкой функции:

Matrix rotate90Degrees( const Matrix& matrix ) {
    if( matrix[ 0 ].isEmpty() ) {
        return matrix;
    } else if( matrix[ 0 ].size() == 2 ) {
        return Matrix { { matrix[ 0 ][ 0 ] },
                        { matrix[ 0 ][ 1 ] } };
    }

    return Matrix { { matrix[ 1 ][ 0 ], matrix[ 0 ][ 0 ] } };
}

Пока что реализация выглядит весьма неуклюже, но тест проходит. Большего нам пока что и не требуется. Пора писать новый набор тестовых данных:

void TDDDemoTest::rotate90DegreesTest_data() {
    // …

    QTest::newRow( "2x2 different values matrix" ) <<
                                     Matrix { { 1, 2 },
                                              { 3, 4 } } <<
                                     Matrix { { 3, 1 },
                                              { 4, 2 } };
}

Теперь мы имеем дело с матрицей 2x2 из разных элементов. Разумеется, тест не проходит. Исправим это:

Matrix rotate90Degrees( const Matrix& matrix ) {
    if( matrix[ 0 ].isEmpty() ) {
        return matrix;
    } else if( matrix[ 0 ].size() == 2 ) {
        if( matrix.size() == 2  ) {
            return Matrix { { matrix[ 1 ][ 0 ], matrix[ 0 ][ 0 ] },
                            { matrix[ 1 ][ 1 ], matrix[ 0 ][ 1 ] } };
        }
        return Matrix { { matrix[ 0 ][ 0 ] },
                        { matrix[ 0 ][ 1 ] } };
    }

    return Matrix { { matrix[ 1 ][ 0 ], matrix[ 0 ][ 0 ] } };
}

Опять мы использовали самое прямое решение возникшей проблемы. Однако оно работает и теперь тест проходит. Пора снова все испортить и добавить еще один набор тестовых данных:

void TDDDemoTest::rotate90DegreesTest_data() {
    // …

    QTest::newRow( "3x1 different values matrix" ) <<
                                     Matrix { { 1, 2, 3 } } <<
                                     Matrix { { 1 },
                                              { 2 },
                                              { 3 } };
}

В этот раз мы взяли матрицу большей размерности с разными элементами. Наша текущая реализация функции с этим не справится. Требуются доработки:

Matrix rotate90Degrees( const Matrix& matrix ) {
    if( matrix[ 0 ].isEmpty() ) {
        return matrix;
    } else if( matrix[ 0 ].size() == 2 ) {
        if( matrix.size() == 2  ) {
            return Matrix { { matrix[ 1 ][ 0 ], matrix[ 0 ][ 0 ] },
                            { matrix[ 1 ][ 1 ], matrix[ 0 ][ 1 ] } };
        }
        return Matrix { { matrix[ 0 ][ 0 ] },
                        { matrix[ 0 ][ 1 ] } };
    } else if( matrix[ 0 ].size() == 3 ) {
        return Matrix { { matrix[ 0 ][ 0 ] },
                        { matrix[ 0 ][ 1 ] },
                        { matrix[ 0 ][ 2 ] } };
    }

    return Matrix { { matrix[ 1 ][ 0 ], matrix[ 0 ][ 0 ] } };
}

Необходимость в рефакторинге уже достаточно очевидна. Но давайте сначала добавим еще один набор тестовых данных:

void TDDDemoTest::rotate90DegreesTest_data() {
    // …

    QTest::newRow( "3x2 different values matrix" ) <<
                                     Matrix { { 1, 2, 3 },
                                              { 4, 5, 6 } } <<
                                     Matrix { { 4, 1 },
                                              { 5, 2 },
                                              { 6, 3 } };
}

Мы опять увеличили размер матрицы, добавив к предыдущему случаю еще один ряд. Тест не проходит. Вновь беремся за код функции:

Matrix rotate90Degrees( const Matrix& matrix ) {
    if( matrix[ 0 ].isEmpty() ) {
        return matrix;
    } else if( matrix[ 0 ].size() == 2 ) {
        if( matrix.size() == 2  ) {
            return Matrix { { matrix[ 1 ][ 0 ], matrix[ 0 ][ 0 ] },
                            { matrix[ 1 ][ 1 ], matrix[ 0 ][ 1 ] } };
        }
        return Matrix { { matrix[ 0 ][ 0 ] },
                        { matrix[ 0 ][ 1 ] } };
    } else if( matrix[ 0 ].size() == 3 ) {
        if( matrix.size() == 2 ) {
            return Matrix { { matrix[ 1 ][ 0 ], matrix[ 0 ][ 0 ] },
                            { matrix[ 1 ][ 1 ], matrix[ 0 ][ 1 ] },
                            { matrix[ 1 ][ 2 ], matrix[ 0 ][ 2 ] } };
        }

        return Matrix { { matrix[ 0 ][ 0 ] },
                        { matrix[ 0 ][ 1 ] },
                        { matrix[ 0 ][ 2 ] } };
    }

    return Matrix { { matrix[ 1 ][ 0 ], matrix[ 0 ][ 0 ] } };
}

Теперь тесты проходят, но закономерности настолько очевидны, что структуру кода без особых сложностей можно упростить. Давайте сделаем это. Но начнем проводить изменения постепенно:

Matrix rotate90Degrees( const Matrix& matrix ) {
    const int rowCount = matrix[ 0 ].size();
    const int columnCount = matrix.size();

    if( rowCount == 0 ) {
        return matrix;
    } else if( rowCount == 2 ) {
        if( columnCount == 2  ) {
            return Matrix { { matrix[ 1 ][ 0 ], matrix[ 0 ][ 0 ] },
                            { matrix[ 1 ][ 1 ], matrix[ 0 ][ 1 ] } };
        }
        return Matrix { { matrix[ 0 ][ 0 ] },
                        { matrix[ 0 ][ 1 ] } };
    } else if( rowCount == 3 ) {
        if( columnCount == 2 ) {
            return Matrix { { matrix[ 1 ][ 0 ], matrix[ 0 ][ 0 ] },
                            { matrix[ 1 ][ 1 ], matrix[ 0 ][ 1 ] },
                            { matrix[ 1 ][ 2 ], matrix[ 0 ][ 2 ] } };
        }

        return Matrix { { matrix[ 0 ][ 0 ] },
                        { matrix[ 0 ][ 1 ] },
                        { matrix[ 0 ][ 2 ] } };
    }

    return Matrix { { matrix[ 1 ][ 0 ], matrix[ 0 ][ 0 ] } };
}

В приведенном коде мы просто заметили, что размеры матрицы, которую нужно перевернуть, используются многократно, поэтому определили соответствующие константы rowCount и columnCount. Обратите внимание, что они определены для перевернутой, а не исходной матрицы. То есть количество строк стало количеством столбцов и наоборот.

Теперь обратим внимание, что во всех случаях (кроме rowCount == 0) на i-ой строке в j-ом столбце перевернутой матрицы стоит элемент исходной матрицы вида: matrix[ matrix.size() - 1 - j ][ i ]. Проведем соответствующий рефакторинг:

Matrix rotate90Degrees( const Matrix& matrix ) {
    const int rowCount = matrix[ 0 ].size();
    const int columnCount = matrix.size();

    if( rowCount == 0 ) {
        return matrix;
    }

    Matrix rotatedMatrix( rowCount );
    for( int i = 0; i < rowCount; ++i ) {
        rotatedMatrix[ i ].resize( columnCount );
        for( int j = 0; j < columnCount; ++j ) {
            rotatedMatrix[ i ][ j ] = matrix[ columnCount - 1 - j ][ i ];
        }
    }
    return rotatedMatrix;
}

Еще раз запустим тесты и убедимся, что ничего не сломали. Отлично. Все работает. Но мы еще не закончили. Ведь до сих пор мы рассматривали лишь корректные тестовые данные. Теперь необходимо написать тесты для случаев ошибочного использования нашей функции. Добавим в тестовый класс еще два слота:

void rotate90DegreesExceptionTest();
void rotate90DegreesExceptionTest_data();

Организуем их по тому же принципу, что был использован при компоновке тестов на основе допустимых данных. Слот для проверки выполнения условия реализуем следующим образом:

void TDDDemoTest::rotate90DegreesExceptionTest() {
    QFETCH( Matrix, matrix );
    QFETCH( QString, errorMsg );

    try {
        rotate90Degrees( matrix );
        QFAIL( "Must not reach this place" );
    } catch( const std::invalid_argument& e ) {
        QCOMPARE( QString::fromStdString( e.what() ), errorMsg );
    }
}

В данном случае мы ожидаем, что строка QFAIL( "Must not reach this place" ) не должна достигаться, поскольку произойдет исключение std::invalid_argument и мы перейдем в соответствующий обработчик, где проверим полученное сообщение об ошибке. Добавим первые тестовые данные:

void TDDDemoTest::rotate90DegreesExceptionTest_data() {
    QTest::addColumn< Matrix >( "matrix" );
    QTest::addColumn< QString >( "errorMsg" );

    QTest::newRow( "Matrix with no columns" ) << Matrix { } << "Matrix must have at least one column";
}

В качестве первого тестового набора данных мы передаем матрицу без столбцов. В этом случае тест не только не проходит, но и завершается с ошибкой выхода за границы массива. Опять возвращаемся к нашей реализации:

Matrix rotate90Degrees( const Matrix& matrix ) {
    const int columnCount = matrix.size();
    if( columnCount == 0 ) {
        throw std::invalid_argument( "Matrix must have at least one column" );
    }

    const int rowCount = matrix[ 0 ].size();

    if( rowCount == 0 ) {
        return matrix;
    }

    Matrix rotatedMatrix( rowCount );
    for( int i = 0; i < rowCount; ++i ) {
        rotatedMatrix[ i ].resize( columnCount );
        for( int j = 0; j < columnCount; ++j ) {
            rotatedMatrix[ i ][ j ] = matrix[ columnCount - 1 - j ][ i ];
        }
    }
    return rotatedMatrix;
}

Тест проходит. Добавим еще тестовых данных:

void TDDDemoTest::rotate90DegreesExceptionTest_data() {
    // …

    QTest::newRow( "Matrix with rows of different sizes" ) << Matrix { { 1 },
                                                                       { 2, 3 } } <<
                                                              "Matrix must have the rows of the same size";
}

В этом случае мы хотим, чтобы в случае получения матрицы со строками разных размеров наша функция возвращала исключение. Убедимся, что тест не проходит:

********* Start testing of TDDDemoTest *********
Config: Using QTest library 4.8.6, Qt 4.8.6
PASS   : TDDDemoTest::initTestCase()
PASS   : TDDDemoTest::rotate90DegreesTest()
FAIL!  : TDDDemoTest::rotate90DegreesExceptionTest(Matrix with rows of different sizes) Must not reach this place
   Loc: [tst_tdddemotest.cpp(77)]
PASS   : TDDDemoTest::cleanupTestCase()
Totals: 3 passed, 1 failed, 0 skipped
********* Finished testing of TDDDemoTest *********

Внесем поправки в реализацию нашей функции:

Matrix rotate90Degrees( const Matrix& matrix ) {
    const int columnCount = matrix.size();
    if( columnCount == 0 ) {
        throw std::invalid_argument( "Matrix must have at least one column" );
    }

    const int rowCount = matrix[ 0 ].size();

    if( rowCount == 0 ) {
        return matrix;
    }

    Matrix rotatedMatrix( rowCount );
    for( int i = 0; i < rowCount; ++i ) {
        rotatedMatrix[ i ].resize( columnCount );
        for( int j = 0; j < columnCount; ++j ) {
            if( matrix[ columnCount - 1 - j ].size() != rowCount ) {
                throw std::invalid_argument( "Matrix must have the rows of the same size" );
            }
            rotatedMatrix[ i ][ j ] = matrix[ columnCount - 1 - j ][ i ];
        }
    }
    return rotatedMatrix;
}

Здесь мы просто добавили соответствующую проверку, поэтому если во входной матрице обнаружится строка другого размера, то будет выброшено исключение. Запустим тесты еще раз:

********* Start testing of TDDDemoTest *********
Config: Using QTest library 4.8.6, Qt 4.8.6
PASS   : TDDDemoTest::initTestCase()
PASS   : TDDDemoTest::rotate90DegreesTest()
PASS   : TDDDemoTest::rotate90DegreesExceptionTest()
PASS   : TDDDemoTest::cleanupTestCase()
Totals: 4 passed, 0 failed, 0 skipped
********* Finished testing of TDDDemoTest *********

Вот теперь можно считать, что мы закончили с реализацией функции поворота матрицы на 90 градусов по часовой стрелке. Но у вас мог возникнуть вопрос: "А зачем было все так усложнять, ведь можно было реализовать алгоритм сразу?". В какой-то мере я с вами согласен. С другой стороны, поскольку мы добавляли тесты постепенно, то в качестве бонуса получили следующее:

  1. Набор регрессионных тестов, покрывающих все основные классы эквивалентности. То есть на каждый принципиально различный случай у нас есть тесты, которые мы можем запускать после любого изменения кода реализации. Конечно, мы бы могли продумать все тесты и без этого, но с помощью TDD мы пришли к получившемуся набору естественным путем. При этом у нас нет лишних тестов, поскольку на каждом шаге мы добавляли только такие тестовые данные, которые точно не проходят;
  2. Мы получили алгоритм не сразу, а по частям. А это всегда проще. То есть мы посмотрели, что будет происходить для частных случаев. Выявили для них закономерности. И лишь затем обобщили алгоритм. Возможно, рассмотренная функция для поворота матрицы не настолько сложна, чтобы это было существенным преимуществом. Но в более запутанных случаях охватить сразу всю логику работы алгоритма может оказаться проблематичным. Поэтому вы в любом случае будете рассматривать некие варианты входных наборов параметров. Так почему бы заодно не автоматизировать их проверку?

Материала получилось достаточно много, поэтому думаю, что на этом мы сейчас и закончим. Однако не могу не упомянуть о том, что в QTestLib входит довольно много полезных инструментов. Например, с помощью QSignalSpy вы можете тестировать сигналы; для оценки производительности вы можете использовать QBENCHMARK; кроме того, не забывайте о возможностях эмуляции действий пользователя (щелчки мышью и нажатие клавиш), чтобы иметь возможность организовать тестирование виджетов.

Заключение

В качестве вывода могу сказать, что TDD имеет свои преимущества. Но лично для меня недостатки все же весьма ощутимы, чтобы не применять его в своей работе постоянно. Конечно, иногда удобно таким способом написать и отладить какой-то сложный алгоритм. Но я считаю, что для более тривиальных случаев это уже избыточно. К тому же, код взаимодействия с оборудованием, сетью или БД писать через тестирование довольно накладно. Приходится задумываться о подставных объектах и прочих побочных элементах. Таким образом, модульные тесты полезны, но все должно быть в меру и по назначению. Главное - выбрать правильный момент и инструмент для реализации ваших задумок.

Похожие публикации

Комментарии

Хорошая статья. Давно интересовал вопрос: как заставить тест отображать разноцветный текст в консоли? У вас кажется именно это так и происходит?

Текст в статье цветной из-за подсветки синтаксиса на самом сайте :) У меня в консоли он выводится в черно-белом виде.

А в гугл тесте текст цветной сразу.

В Qt Creator есть вкладка "Результаты тестирования", там упорядоченный вывод.