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

Разработка через тестирование в 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, то смысл есть. Ведь если вы выявили какую-то ошибку в коде взаимодействия с БД, то вполне вероятно, что она когда-нибудь может повториться вновь. В этом случае у вас три варианта: либо тестировать ее вручную после каждого изменения; либо надеяться, что она больше никогда не произойдет; либо автоматизировать ее проверку с помощью соответствующих модульных тестов. Первый вариант плох тем, что вы будете делать лишнюю работу, то есть повторяться. Второй вариант делает ваш код уязвимым. Последний же вариант потребует довольно много дополнительной работы. На мой взгляд, в этом случае все зависит от масштабов и критичности проекта. Если ошибки недопустимы (например, авиационное или медицинское ПО), то тестирование обязано быть исчерпывающим, а поэтому автоматизированным. С другой стороны, если вы пишите приложение для среднестатистического пользователя, то вполне можете рассчитывать на некий уровень толерантности. Вспомните те же продукты из серии Microsoft Office. Не знаю как у вас, но у меня они регулярно падают и глючат (при этом я пользуюсь лицензионными версиями).

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

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

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

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

TDDemo.pro:

TDDemoLib.pro:

TDDemoTest.pro:

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

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

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

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

Для организации наших тестов мы будем использовать класс 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 для получения возможности добавления слотов.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Заключение

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

Понравилась статья? Поделиться с друзьями:
Комментарии: 4
  1. neyro_non

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

    1. Алекс (автор)

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

  2. Anonymous

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

  3. Zivit

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

Добавить комментарий