Главная > opinion, programming > Создание высококачественного ПО или программирование на основе тестов

Создание высококачественного ПО или программирование на основе тестов

Задачей индустрии является создание полезных для определенных групп пользователей приложений, что отображается в так называемых функциональных требованиях к ним. При этом обычно негласно подразумевается, что создаваемые системы должны быть безошибочными, безопасными и расширяемыми. Эти пожелания относятся к нефункциональным требованиям и характеризуют качество. К сожалению, создание качественного ПО является скорее исключением, нежели правилом и объясняется программистами молодостью их индустрии. Для решения указанных проблем за последние несколько десятилетий была придумана масса методологий, нотаций и практик, частично устранивших хаус в программировании. При этом принцип написания программ фактически остался ровно таким же, как и был полувека назад: код пишется перед тестами, которые его проверяют. В данном реферате предпринята попытка опровергнуть это древнее по меркам индустрии правило и убедить, что только тесты могут направлять разработку ПО. Будет показано, что этот подход коренным образом изменяет мышление программиста и сам процесс программирования.

Начни с теста или небольшой пример

Обратимся к примеру: допустим надо написать приложение для сложения двух чисел. Обычно программист просто напишет функцию, принимающую два целочисленных параметра и возвращающую их сумму. Но сразу появляется несколько вопросов. А почему эта функция работает? Какие параметры она принимает? Собственно, почему она вообще делает то, что нам надо, где гарантии? И вообще, что нам надо? Получается, что при непосредственном программировании на выходе получается чистый код без описания сценариев его использования.

Попробуем другой подход. Для начала поймем, что нам надо от создаваемой функции, т.е. ответим на вопрос “что?”. А надо нам сложить два целочисленных числа, например, 2+2 или 3+7 и получить в итоге 4 и 10 соответственно. Примем это в качестве того, что мы ожидаем от функции и напишем тест, который два раза вызывает ее с параметрами 2, 2 и 3,7 и сверит результаты с числами 4 и 10. При совпадении будем считать, что функция делает именно то, что нам надо. Кстати, если вычисления производить в восьмеричной коде 3+7 может ровняться 12.

Четко осознавая, какую функцию надо создать мы можем перейти к ответу на вопрос “как?”. Существенный момент – не важно как именно мы будем реализовывать функцию, важно чтобы проходили тесты! Фактически можно реализовать только два тестовых случая и это будет считаться правильным. Только при этом быстро выяснится, например, что 1+8 вовсе не равно 9, при этом эту ошибку можно будет реализовать в виде “заваленного” теста и только после его успешного прохождения считать ее устраненной.

В описанном простом примере видны основные преимущества, получаемые от программирования на основе тестов:

  1. Программист первым делом задумывается, что должна делать программа, а не какой код надо писать и возможные сценарии использования кода описываются в виде тестов;
  2. В коде теста недвусмысленно документируется и верифицируется функциональность системы, на что неспособны никакие “неисполняемые” комментарии;
  3. Намного проще писать код, критерий – прохождение тестов;
  4. При модификациях легко проследить моменты неосознанного изменения функционала приложения;
  5. Все возникающие ошибки фиксируются в виде дополнительных тестовых случаев и способствуют увеличению качества приложения;
  6. Намного меньше приходится прибегать к отладки приложения и поиска источника ошибки – они выявляются тестами автоматически.

Жизненный цикл кода и подход TDD

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

Родоначальником этого подхода является Кент Бек, автор методологии экстремального программирования XP (Extreme Programming, www.xprogramming.com). Им развивается идея о разработке ПО на основе тестов под названием TDD (Test-Driven Development, groups.yahoo.com/group/testdrivendevelopment/). Пожалуй, единственный пункт, по которому описанный выше пример не соответствует практике TDD, является отсутствие в нем процесса рефакторинга (refactoring), заключающегося в улучшении структуры кода при оставлении неизменной его функциональности (www.extremeprogramming.org/rules/refactor.html). Обычно рефакторинг проводится в самом конце жизненного цикла кода.

Необратимое изменение мышления программиста

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

Как показывает практика, практически нет случаев, когда нельзя было бы протестировать тот или иной аспект программной системы. Обычно затруднения возникают для распределенных приложений, СУБД, многопоточных вычислений, но и они решаемы. Более того, такие проблемы встают всегда и процесс их решения обычно приводит к более гибкой и прозрачной архитектуре, чем задумывалось изначально. Это касается создаваемого ПО. Другое дело, когда приходится писать тесты для уже существующих систем. Там архитектура фиксирована и ограничивает возможности для тестирования. Только путем постепенного написания тестов и проведения рефакторинга можно переходить к более гибкой и приспособленной к тестам архитектуре.

Постепенно смещая аспект с кода на его описание – тесты, программист тем самым необратимо изменяет свое мышление. При этом принципиально меняется отношение к коду и самой системе – ПО становится самотестирующимся и управляемым. У программиста это вызывает ощущение уверенности в системе, что дорогого стоит. Идея коренного изменения образа мышления программиста является ключевой мыслью реферата.

Виды тестов

Пока мы говорили о тестах в общем, пришла пора провести их классификацию. Обычно выделяют модульные, нагрузочные и функциональные или приемочные тесты. Приемочные тесты рассматривают программную систему или какую-то ее часть как “черный ящик” и проверяют ее функциональность. Существующие в крупных компаниях отделы тестирования, специализируются именно на этих видах тестов, и именно эти тесты описывают границы применения создаваемого ПО.

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

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

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

Тесты в методологиях программирования

Важно понимать, что методики в духе TDD не способны заменить методологии, полностью описывающие процесс создания программных систем. Сегодня существует ряд широко применяемых методологий, таких как RUP, ICONIX, OPEN, FDD, MSF, XP и многие другие. Каждая из них уделяет отдельное внимание тестированию, но, пожалуй, только в XP, практике программирования на основе тестирования отведена ведущая роль. Остальные же методологии ограничиваются функциональными тестами на предмет выполнения приложением тех функций, которые заявлены. По сравнению с подходом, принятым в XP, это имеет целый ряд недостатков. В частности, прохождение функциональных тестов является необходимым, но недостаточным условием качественного ПО.

Часто структура кода настолько сложна и труднопонимаемой для тех программистов, кто не создавал систему, что добавление в будущем модификаций является существенной проблемой. Они влекут к так называемому эффекту постепенного умирания системы и увеличения энтропии в ней, о чем писал Фредерик Брукс в книге “Мифический человек месяц”[1]: “Фундаментальная проблема при сопровождении программ состоит в том, что исправление одной ошибки с большей вероятностью (20-50 %) влечет появление новой. Поэтому весь процесс идет по принципу «Два шага вперед, шаг назад»… Все исправления имеют тенденцию к разрушению структуры, увеличению энтропии и дезорганизации системы”.

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

Разоблачение предрассудков

У людей, которые впервые сталкиваются с идеей программирования на основе тестов, часто возникают одни и те же сомнения и вопросы:

Невозможно тестировать несуществующий код. Невозможно запустить тест для несуществующего кода, но написать его можно. При этом надо думать о том, что должен делать код, а не как. Это очень помогает впоследствии при написании самого кода.

Написание тестов является прерогатива отдела тестирования. Прерогативой отдела тестирования или контроля качества всегда являлось написание и запуск функциональных и нагрузочных тестов, которые рассматривают систему как “черный” ящик. Но помимо них существуют модульные тесты, которые не могут в принципе создаваться отделом тестирования – они должны писаться самими программистами, направлять разработку кода, его связанность и контролируемую модифицируемость.

На написание тестов уходит неоправданно много времени. Не намного больше, а часто и меньше времени, затрачиваемого на отладку, которая редко занимает менее 50% от времени написания кода.

Далеко не любой код можно протестировать. Конечно, особенно если он писался до тестов, которые признаны его тестировать. Как показывает практика, только хорошо структурированный код подлежит модульному тестированию. Фактически, если для кода сложно написать тест, то это означает только одно – его необходимо подвергнуть рефакторингу и сделать более понятным и прозрачным.

Мой код и так интуитивно понятен и в тестировании не нуждается. Человеческий мозг в кратковременной памяти может хранить информацию примерно по семи темам. Если информации становится больше, то ему становится трудно с нею справится. Так и в программировании. Когда пишешь код, то он изначально интуитивно понятен. Но при разрастании системы и усложнению связей между различными программными компонентами в ней, уследить за всеми ними просто невозможно. Изменяя код в одном месте практически невозможно предсказать его влияние на функционирование всей системы. Часто приходится видеть системы, которые не модифицируют только из боязни того, что все окончательно перестанет работать.

А есть ли минусы?

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

Инструментальные средства и библиотеки

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

Немного особняком стоят утилиты для функционального тестирования. В этой ниши можно выделить трех крупных игроков: Mercury Interactive и Rational Software (недавно куплена корпорацией IBM).

Преимущества скриптовых языков

Компьютерная индустрия не стоит на месте и постоянно эволюционирует. Уменьшаются габариты компьютеров и увеличивается их производительность. Вместе с этим неизбежно изменяются языки программирования и парадигмы на которых они основаны. В свое время машинные языки были заменены языками высокого уровня со строгой типизацией типов и необходимой перед исполнением на целевой машине компиляцией исходного текста программы в машинный код. К этим языкам относятся Pascal, C, C++, Java и др. Сегодня наблюдается тенденция их замещения скриптовыми языками с динамической типизацией во времени выполнения и межплатформенной интерпретацией. Использование скриптовых языков программирования приводит к убыстрению цикла разработки и упрощению этого процесса, при этом меньшая скорость их исполнения окупается возросшей производительностью компьютеров.

Сегодня очень популярен perl, который скрывает многие, несущественные в ряде случаев, детали программирования и позволяет намного быстрее создавать те или иные приложения. Правда, он не позволяет писать большие программные системы, так как не поддерживает объектно-ориентированного подхода в программировании и имеет трудно читаемый синтаксис. Во многом, это связано с его Unix-родословной и обратной совместимости с такими утилитами, как awk, sed и grep. В повседневной программисткой деятельности также полезны скрипты для оболочек в духе bash, но они имеют аналогичные ограничения, что и perl.

Все эти ограничения отсутствуют во все набирающих популярность языках Python (www.python.org) и Ruby (www.rubycentral.com). Оба они интерпретируемые, объектно-ориентированные высокоуровневые языки программирования с динамической семантикой. Встроенные высокоуровневые структуры данных вкупе с динамическим управлением типов и поздним связыванием делают их очень привлекательными для быстрой разработки приложений, а также в качестве сценарного или “склеивающего” языка для связи существующих программных компонент. Стоит заметить, что почти все исходники и существующие библиотеки для них распространяются в рамках открытых исходных текстов.

В частности, очень удобно использовать скриптовые языки Python и Ruby для тестирования кода, так как они позволяют более гибко осуществлять этот процесс. Например, можно посылать сообщения любого вида и любому объекту, а уже на этапе выполнения теста интерпретатор проследит за тем, чтобы объект был способен понять эти сообщения. Для Java существуют специальные версии этих языков: Jython (www.jython.org) и JRuby (jruby.sourceforge.net). Благодаря им, легко использовать библиотеки Java из приложений, написанных на Python и Ruby соответственно.

Дополнительные две полезных практики

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

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

Тем временем, парное программирование по мнению Алистэра Коуберна приводит к уменьшению времени разработки на 15%, улучшению дизайна системы, уменьшению количества дефектов, снижения риска, связанного с занятостью в проекте определенных сотрудников, росту технического уровня команды, так как внутри нее происходит интенсивный обмен знаниями, улучшению взаимодействия и коммуникации, а кроме того, сам процесс работы в целом доставляет гораздо больше удовольствия. В итоге стоимость разработки при парном программировании составляет вовсе не 100%, а также приблизительно 15%, что легко окупается за счет более высокого качества кода и намного более меньшего времени его отладки.

Те программисты, которые уже привыкли к “парному” стилю работы, говорят, что так работается “как минимум, вдвое быстрее”. Даже новички-программисты, находящиеся в паре с опытным специалистом, вносят в его код много полезных дополнений. Снижается риск потери ключевых разработчиков, так как многие их коллеги хорошо знают каждую из частей системы. Главное, о чем не стоит забывать – по возможности пары надо “мешать”, т.е. они не должны быть фиксированы, а естественным образом формироваться под конкретные задачи.

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

Несмотря на это, очень часто роль непрерывной сборки проекта в успехе всего проекта существенно занижена. Роковая ошибка! Скорее изначально слабая проработка архитектуры или требований пользователей меньше влияет на провал проекта, чем игнорирование автоматической сборки. Она в купе с разработкой приложений на основе тестов и парным программированием естественным и прозрачным образом стимулирует программистов создавать качественное программное обеспечение, при этом разработка становится более контролируемой и предсказуемой.

Заключительное слово

В сфере программирования как не было “серебряной пули” так и не будет. Всегда приходится идти на компромиссы, только при этом нужно знать выработанные за многие годы компьютерной индустрии полезные практики, знать их положительные и отрицательные стороны и применять в подходящих ситуациях. А TDD, как нам кажется, является больше чем практикой – это эволюционное изменение мировоззрения программиста.

Приложения

Пример написания модульного теста на языке Java

В данном приложении приведены исходные тексты описанного в основном тексте примера приложение для сложения двух чисел. В классе SumFuncTest реализовано модульное тестирование создаваемой функции на основе использования библиотеки JUnit, а в классе SumFunc ее реализация.

Листинг 1. Класс SumFuncTest

//SumFuncTest.java
import junit.framework.TestCase;

public class SumFuncTest extends TestCase {

public void testSum() {
assertEquals(4,SumFunc.execute(2,2));

assertEquals(10,SumFunc.execute(3,7));

}

}

Листинг 2. Класс SumFunc

//SumFunc.java
abstract public class SumFunc {

public static int execute(int a, int b) {
return
a + b;
}

}

В реферате использовалась информация с сайта http://www.maxkir.com.


[1] Брукс Ф. “Мифический человек-месяц или как создаются программные системы”, Изд. “Символ-Плюс”, 2001 – 304 с.

Реклама
Рубрики:opinion, programming
  1. Комментариев нет.
  1. No trackbacks yet.

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

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход / Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход / Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход / Изменить )

Google+ photo

Для комментария используется ваша учётная запись Google+. Выход / Изменить )

Connecting to %s

%d такие блоггеры, как: