28 сентября 2011 в 16:19

О Test Driven Development (TDD) из личного опыта

На хабре есть моя заметка, посвященная чтению книги А. Александреску «Современное проектирование на C++» и неудачному опыту реализации идей, почерпнутых из нее. Надо сказать, что проблема была не в книге, а в плохо контролируемом энтузиазме и несоответствии целей и средств. Мной было написано еще несколько вещей в духе той, что описана в заметке (значительно меньше по объему), и в какой-то момент пришла мысль, что так жить нельзя. Одновременно с этим мой товарищ, работающий в том же отделе, что и я, передал мне книгу М. Физерса «Эффективная работа с наследованным кодом» (прежде от него же я узнал и об Александреску). Сам он ее прочитал несколько раньше, но широко в работе TDD не применял. И тут, надо сказать, она пришлась очень вовремя и к месту.

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

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

Книга сама по себе довольно объемная и рассказывает преимущественно о работе с уже написанным кодом, но в ней дается определение наследованного кода несколько отличное от того, которое может быть интуитивно воспринято новичком в этом деле. Наследованный код — это такой, который написан без тестирования. Следует сказать пару слов о тестировании в понимании Физерса. Тесты могут производиться непосредственно после написания программы, и заниматься этим должен специальный отдел тестирования. По прохождению тестов уже написанное ПО передается разработчикам с замечаниями или без. Это полезно и нужно. Но, даже, пожалуй, более важное тестирование — блочное, которое делается самим же разработчиком во время написания программы. То есть программа, которая пишется на компилируемом языке может проверяться как программа на интерпретируемом. Проверяются при этом отдельные классы и методы внутри классов. Каждый тест выполняется за 0.1 секунды и не обращается к файловой системе или прочим ресурсам, реакция на него почти моментальная. В этом и заключается «блочность» таких тестов. Все это я говорю для людей, которые, возможно, не знакомы с технологией TDD, для программистов, использующих его в работе, это не новость.

Основной моралью большинства глав книги является то, что нужно писать блочные тесты. Особенно это касается вновь разрабатываемого кода. Как пишут код новички, да и многие из тех, кто делает это долго и за деньги? И в особенности, отлаживают его? В худшем случае, код пишется хаотично на основе каких-то мыслей, но эту ситуацию мы опускаем. В лучшем, когда человек старается сделать нормальный код, он набрасывает в голове план того, что хочет видеть, сначала словесно, потом в виде классов, функций, модулей — не суть важно. Затем это все начинает писаться в любой последовательности, и в лучшем случае, отдельные части функциональности проверяются в той или иной мере, а в худшем — написав пару тысяч строк кода, человек вынужден ковыряться с отладчиком и править наудачу. Любому программисту хочется последствия своей деятельности как-то детерменизировать, а само написание кода сделать внятным и без неожиданных задержек на два-три дня в обществе gdb или чего-то аналогичного.

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

Пример будет чрезвычайно простым, и, откровенно говоря, я его придумал, пока писал эту статью. Пусть мы создаем калькулятор командной строки на python'е (что само по себе бред, потому что интерпретатор python'а уже калькулятор командной строки), но мы его делаем не для реального проекта, а чтобы освоиться в замечательной среде unittest, предоставляемой вместе с python'ом. Итак, как известно из беглого курса теории компиляторов, в простейшем случае калькулятор описывается грамматикой вида:
  1. E = T {+ T} {- T}
  2. T = M {* M} {/ M}
  3. M = (E) | number
  4. number = digit {digit} [.] {digit}
  5. digit = "0"|"1"|"2"|"3"|"4"|"5"|"6"|"7"|"8"|"9"

Следуя обычной практике, нам необходимы:
  • функция E
  • функция T
  • функция M
  • лексер

Прежде всего, не хотелось бы в класс калькулятора помещать их все, во-первых, в python нету секции private, во-вторых, это неудобно. В классе калькулятора будет только
  1. class Calc:
  2.   """ Calculator itself
  3.   """
  4.  
  5.   ci = CalcImpl() 
  6.   # Сделать из строки список токенов
  7.   def tokenize(self,s):
  8.     ...
  9.   # Головная функция
  10.   def ex(self, s):
  11.     """ Main function
  12.     s -- string to ex
  13.     """
  14.  
  15.     l = self.tokenize(s)
  16.     self.ci.stream = deque(l)
  17.     self.ci.token = l[0]
  18.     return self.ci.e()
  19.  

Я не претендую здесь на хороший код на python, равно как и на хорошее ООП, повторяюсь, просто демонстрирую технологию TDD. В CalcImpl я положил все, что касается синтаксического разбора. Итак, какие-то мазки будущего кода есть, теперь хотелось бы иметь живые E, M и T. Пишем их посредством TDD внутри класса CalcImpl.
  1. # Модульные тесты
  2. if __name__ == "__main__":
  3.   import unittest
  4.  
  5.   class TestCalcImpl(unittest.TestCase):
  6.     ci = CalcImpl()
  7.  
  8.     def test_t(self):
  9.       self.ci.stream = deque(['12','/','10','+'])
  10.       self.ci.token = '12'
  11.       self.assertEqual(self.ci.t(),1.2)
  12.       self.assertEqual(self.ci.token,'+')
  13.  
  14.     def test_m(self):
  15.       self.ci.stream = deque(['12','*'])
  16.       self.ci.token = '12'
  17.       self.assertEqual(self.ci.m(),12.0)
  18.       self.assertEqual(self.ci.token,'*')
  19.  
  20.     def test_e(self):
  21.       self.ci.stream = deque(['12','*','10','-','10'])
  22.       self.ci.token = '12'
  23.       self.assertEqual(self.ci.e(),110.0)
  24.       self.assertEqual(self.ci.token,'')
  25.  
  26.     def test_e_par(self):
  27.       self.ci.stream = deque(['1','+','2','*','(','10','+','10',')'])
  28.       self.ci.token = '1'
  29.       self.assertEqual(self.ci.e(),41.0)
  30.       self.assertEqual(self.ci.token,'')
  31.  
  32.   unittest.main()
  33.  

Далее, для тех, кто не работал еще с TDD, или не делал этого вплотную. Вначале создается сам блочный тест. Он пишется, когда функции или класса, который мы тестируем, еще нет. В данном случае, впрочем, я наметил две внутренние переменные класса CalcImpl — stream (поток токенов) и token — текущий токен и присвоил им начальные значения. Тем, кто читал книги по компиляторам, хорошо известна практика написания подобных приложений, поэтому я не останавливаюсь подробно на этом. Таким образом, я определил будущий вид класса, который хочу получить. В процессе работы он может измениться, но сейчас я имею что-то вроде ТЗ на выполнение модуля, то, что хочу видеть в конце. Этот вариант идеально подходит для работы в команде, когда программист «выдает ТЗ» своему напарнику и получает класс, удовлетворяющий некоторым свойствам. Итак, «будущее» мы видим более-менее явно, правда, оно не компилируется (не интерпретируется в нашем случае). Это и есть первый этап — не компилируется и блочные тесты не проходят.

Переходим ко второму этапу — компилируется, но блочные тесты не проходят. Здесь можно написать заглушку функции, которую мы хотим реализовать (в данном случае, пишутся заглушки всех функций). Я не буду приводить код, соответствующий этому шагу, поскольку он очевиден, и сразу перейду к третьему этапу — компилируется и тест проходит
  1. class CalcImpl:
  2.   """ Class to implement grammar functions
  3.   """
  4.  
  5.   stream = deque ([""])
  6.   token = ""
  7.  
  8.   # Получить token
  9.   def get(self):
  10.     try:
  11.       self.stream.popleft()
  12.       self.token = self.stream[0]
  13.       return True
  14.     except IndexError:
  15.       self.token = ''
  16.       return False 
  17.  
  18.   # Множитель
  19.   def m(self):
  20.     result = 0.0
  21.     if self.token == "("
  22.       self.get()
  23.       result = self.e()
  24.       if not self.token == ")":
  25.         raise ParError(self.token)
  26.       else:
  27.         self.get()
  28.     else
  29.       result = float(self.token)
  30.       self.get()
  31.     return result

Там мелькает, правда, еще уродливая функция get(), на которой я останавливаться не буду в силу ее тривиальности. Итак, теперь мы получаем в связке наших блочных тестов один выполняющийся и два не выполняющихся. Уже неплохо. Теперь наступает момент истины. Мы получили работающую функцию и можем двигаться дальше. Причем о ней мы действительно можем на некоторое время забыть и с чистой совестью делать приложение. Обратите внимание, что не пришлось реализовывать даже функцию tokenize, которая была в классе Calc. Мы ясно знаем, что нужно функции m() для работы и предоставляем ей это. К сожалению, она оставляет за собой два побочных эффекта в виде модификации внутренних переменных, ну на то они и внутренние.

Теперь я попробую сформулировать, что именно достигнуто всеми вышеописанными действиями.
  • Четко в виде кода сформулирована задача, и точно так же получен результат
  • В каждый отдельный момент времени, создавая функции посредством данного подхода, мы можем ответить на вопрос:«Что я сейчас делаю?» однозначно. Это устраняет колебания, потерю времени и сил в процессе кодирования
  • Написание кода становится внятным детерменированным процессом

В заметке рассмотрен пример на python с использованием его среды блочного тестирования. Это просто для разнообразия. В основном, код я пишу на C++ и пользуюсь CppUnitLite. Хорошим тоном в этом случае является создание отдельной цели (или отдельного Makefile'а) для того, чтобы собрать блочные тесты и выполнить их. Таким образом, сеанс работы становится следующим:
  • Написать блочный тест
  • Написать класс (функцию)
  • Добиться его компилируемссти
  • Добиться прохождения теста

Это подробно описано в книге М. Физерса. Далее следует повторять это итеративно, устраняя дублирование кода.

По мере использования блочного тестирования повышается самодисциплина разработчика. Кроме того, объекты, которые требуют тестирования, автоматически становятся меньше и интерфейсы их продуманнее. Лично для себя я закрепил несколько правил, которых стараюсь придерживаться:
  • В функциях должно быть небольшое число аргументов. Чем более узким типом они обладают, тем лучше. Поясню это. Часто новички передают функции аргумент в виде структуры, в которой хранятся какие-то значения, в то время, как из них ей нужно от силы одно-два. Это не очень хорошо, потому что абстракция в этом случае становится размытой и с неявным интерфейсом. Самое лучшее пояснение работы функции — ее сигнатура.
  • Необходимо строго следовать принципу единственной ответственности, описанному во многих книгах по ООП. Маленькие классы с четко обозначенными входами и выходами облегчают как их тестирование и сопровождение, так и понимание принципа работы системы.
  • Не следует создавать много внутренних переменных класса — это запутывает код. Сразу ясно, что уже в блочном тестировании их не достанешь, если они в секции private, так что нужно делать #define private public в файле блочного теста, и, самое главное, они усложняют понимание работы класса.
  • Там, где это нужно, следует выделять интерфейсы (в виде абстрактных классов в C++). Это позволяет, во-первых, подменить объекты их фейками в блочных тестах, а во-вторых, выделить абстракцию, которая на самом деле нужна в данном месте.
  • Не надо раздувать существующие классы и функции. Для расширения функциональности следует пользоваться почкованием класса или метода, то есть писать новые и обращаться к ним в старых, в этом случае новые пишутся посредством TDD.
  • Необходимо дробить развесистые действия, выделяя в них логические куски. Тут пояснений не требуется. Точно так же как и:
  • Устранять дублирование кода.

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

Добавлю лишь ложку дегтя в бочку меда. Разработка «железных» приложений и GUI с использованием TDD фактически невозможна. Ну, не все коту масленица.


Литература:
М. Физерс, «Эффективная работа с унаследованным кодом»
@kir19890817
карма
29,0
рейтинг 0,0
Самое читаемое Разработка

Комментарии (29)

  • +3
    Немножко оффтоп. Касательно литературы хотел бы настоятельно посоветовать читать Физерса в оригинале. Я вообще не очень придирчив к переводам, но тот, который встретил в варианте от издательства Williams — просто ужасен и отвратителен.
  • +12
    Очередной топик о преимуществах TDD на примере калькулятора — таких топиков написано уже сотни (без преувеличения), если не больше, даже на хабре таких топиков полно. Было бы гораздо полезнее топик о применении TDD в боевых условиях — для приложений, который работают с БД и/или у которых основной источник данных — различные сервисы с многоуровненвой древовидной разнообразной структурой данных и т.п.

    • +2
      Пожалуйста, я применяю TDD в многопоточной программе под QNX, которая является, к тому же, менеджером ресурсов. Просто я думал, что это неинтересно.
      • +5
        Как раз это и интересно — использование в жизни.
        Сферических коней в вакууме в любой книге можно найти. К тому же иногда неработающих )
    • +1
      Там, кстати, очень распространенная практика — подмена реальных объектов (например, класса управления приводами антенны), фейками. Также — объектов синхронизации (мутексов в моем случае) и алгоритмов (обертка POSIX-потоков). Ну и, конечно, обращения к файлам, которые и не файлы вовсе, а ноды менеджеров ресурсов. Да, и считывание из xml, который я в модульном тесте создаю в памяти (pugixml поддерживает это). Я пишу вначале чисто алгоритмическую часть некоторых вычислительных модулей, например, алгоритмов, управляющих формированием заданий аппаратуре посредством TDD с фейковыми объектами, а затем профилирую. Я просто не думал, что это у кого-то вызовет интерес.
      • +4
        Это как раз и было бы интересно, действительно, тестов на калькулятор миллион, а вот что-то сложнее, какая-либо бизнес-логика, и уже все — таких примеров не найдешь
        • +2
          Может, потому что в большенстве случаев сложновато туда применить TDD?
          • 0
            Можно, тем не менее.
            Но написание нормальных тестов на бизнес-методы может занять в 3-5 (!) раз больше времени, чем ручное тестирование. Так что нужно все-таки взвешивать, нужна ли польза в перспективе по сравнению с существенными затратами времени сейчас.
          • 0
            TDD в этих случаях работает точно так же.
            Эмуляция поведения внешних ресурсов, конечно, требует несколько больших затрат времени, но принцип TDD никак не меняется.
    • 0
      Есть и такие топики: habrahabr.ru/blogs/tdd/127114/
  • +2
    было бы интересно послушать как узнать как тестировать скажем потоки и как оттестировать класс для работы с файлом(бд): если обращаться к файловой системе нельзя, то получается что такой класс нельзя тестировать впринципе? Как тогда обходиться, ассертами?
    • 0
      Moq вам в помощь, с БД справляется вполне хорошо.
      • 0
        моки нужны как я понимаю для того чтобы тестировать модули использующие классы БД и файла, а как сам класс БД тестить? Какой смысл тестить сам мок объект?
      • +1
        mock*
    • +1
      По поводу работы с файлами: можно создать несколько методов с разными аргументами, которые вызывают друг друга. Например: processData(String filepath) -> processData(InputStream is) или processData(String data) в зависимости от характера работы и объёма данных. И далее уже в модульном автотесте вызывать processData(is/string) куда передавать тестовые данные / потоки которые читают тестовые данные.

      Если под работой с файлами подразумевается манипуляция с файловой системой — то тут тут надо смотреть по месту. Протестировать ту часть, которая формирует пути для манипуляции можно без самой манипуляции с файлами, протестировать «исполнительную» часть, если это необходимо (например, что выбрасывается исключение если нет прав доступа к файлу), можно «заменив» пути на тестовые файлы (впрочем, ели мы в коде не проглатываем исключения, то система точно нам сообщит, что у нас нет доступа к файлам. А то что мы не проглатываем исключения можно автоматически контролировать другими способами).

      Работа с базой данных. Тут 2 стороны: OLTP и OLAP.
      OLTP: т.к. у нас в каком либо виде выделены DAO/Repository — то, если у нас внутри них ORM которому можно подсунуть файловые субд — мы делаем это, если нет — создаём в своём СУБД пустую тестовую схему.
      А затем юниттесты для нашего ORM/DAO пишем в стиле проверки прямого действия обратным. (Как в школе — проверяем умножение делением). Вариации на тему:
      SomeDomainObject obj
      Assert(dao.get(objId)==null)
      dao.save(obj)
      Assert(dao.get(objId)==obj)
      obj.changeField
      dao.save(obj)
      Assert(dao.get(objId)==obj)
      dao.remove(objId);
      Assert(dao.get(objId)==null)

      OLAP — тут к сожалению всё немного сложней. Тут мы проверяем что правильно написали сложный многоэкранный SQL запрос, который что-то такое считает, аггрегирует и прочее.
      Если в запросе не использовались SQL-конструкции спецефичные для сервера, то иногда можно Oracle/DB2 заменить на embeded вроде h2 и derby. Если же нам не повезло — то никуда не денешься — только тестовые схемы.
      Тестовые схемы наполняются выборкой тестовых данных, результат работы запроса на которых нам известен, и на этих данных он исполняется. Если у нас Java — хорошо бы посмотреть ubitils и то, что в него входит — прекрасный инструмент для подготовки данных.

      «Оттестировать потоки»: тут 2 составляющих: правильная многопоточность и правильная обработка данных. 2е тестируется тривиально и без потоков, а первое автотестами невозможно оттестировать и тут я придерживаюсь мнения, что или мы можем формально доказать, что синхронизировались мы правильно или всё настолько сложно, что мы скорей всего ошиблись.
      Впрочем — есть небольшая помощь сравни гадалке: есть утилиты, которые модифицируют код программы таким образом, чтобы после каждой инструкции в исходном коде спровоцировать переключение контекста и создавать множество различных трасс исполнения. Если написать автотест с многопоточностью внутри и обработать такой программой — то количество итераций, необходимое, чтобы увидеть «гайзенбаг» уменьшается на порядки до вполне земных чисел (впрочем, всё-равно нельзя быть увернным: если всё работает — всё действительно правильно или мы просто мало ждём)
    • +1
      Тестирование работы с файлами проиходит достаточно просто. В питоне можно подсунять любой объект ведущий себя как файл тот же StringIO или самому накатать класс с необходимыми методами.
      Допустим у нас есть функция которая на входе получает файл, на выходе некий результат. В юниттесте можно передать либо специальный тестовый файл ли файло-подобный объект и сверить результат.

      Есть очень удобная штука flexmock, позоляет в процессе тестирования подменять различные методы, проверять получаемые аргументы, проверять сколько раз вызван метод и т.п. Напрмер наш класс обращается к некоему сервису в интернете и запрашивает данные. Для тестов мы не можем обратиться к внешнему сервису, зато можем подменить подмеить возвращяемое значение для urllib.urlopen и т.п.

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

      Для тестирования БД используется тестовая БД, в которую перед выполнением теста загружаются тестовые данные. Фреймворки умеют убивать и наполнять БД для каждого теста.

      К сожалению я тоже долго не могу воткнуть в TDD, в подобных статьях рассматриваются лишь простые малополезные на практике примеры. Я пытался писать какие-то тесты, но у меня не получалось. Мне это «тайное знание» передали на новой работе. Чтобы нормально вникнуть в TDD мне потребовалось 2 недели.
    • 0
      Потоки обычно тестируются большим числом вызовов, пропорционально квадрату (лучше — кубу) числа потоков. В итоге если есть race condition, то вероятность слома теста один раз в пятилетку повышается.
      • 0
        P.S. Также необходимо добавить, что время выполняемой потоком операции должно быть много больше, чем время создания и запуска потока. Это само собой разумеющееся, поэтому об этом легко забыть :-)
      • 0
        P.S. Не забывайте, что это не даст гарантии отказоустойчивости, только лишь повысит ее вероятность. Победить race condition можно только формальным доказательством, а это, увы, очень трудоемкий процесс.
  • +3
    А у меня как-то не сложилось с TDD, несколько раз пытался, но пришел к тому, что не для всех проектов подходит. Когда надо протестировать функцию или класс, которые получают на вход какой-то небольшой набор параметров, все хорошо. А вот если на вход идет много разных параметров и большие объемы данных, то написание тестов становится эквивалентно написанию самого проекта, так как надо сгенерировать все эти данные.
    • 0
      Если у вас есть время, и нет ограничений связаный с NDA и проч, не могли бы вы, например в ЛС, описать задачу которую решал этот класс, ну в крупную клетку описать как вас этот класс был устроен (какие методы и какие функции каким другим классам делигировались). Я изучаю ситуации в которых люди столкнулись с «невозможностью» написания автотестов. Вдруг мы оба вынесем из этого что-то полезное)
      • 0
        Ок, чуть чуть попозже отпишусь в личку и попробую описать.
      • 0
        В общем напишу пример сюда, авось кому пригодится.
        Проект связан с автоматизированной генерацией программ для ЧПУ станков и симуляции обработки, например была задача написать проверку столкновения инструмента с материалом заготовки. Если говорить просто, то есть 3д модель инструмента и есть результат симуляции обработки материала, тоже некая 3д модель в некотором формате. Чтобы проверить класс, который занимается проверкой столкновений, ему на вход надо подать результат обработки материала, но чтобы получить этот самый результат, его надо симулировать, а процесс этот не сильно быстрый, да еще и код для симуляции должен быть готов. Т.е. есть несколько вариантов: 1) при каждом тесте генерировать какие-то входные данные (собственно производить симуляцию), тогда это получается весьма накладно с точки зрения производительности; 2) хранить результаты где-то и скармливать их тесту, но тут оказывается, что надо хранить весьма немалый объем данных да еще и как-то генерировать/передавать их на разные рабочие компьютеры; 3) использовать какие-то простые модели для тестов, казалось бы самый правильный подход, но в нем как раз нет особого смысла, реальные входные данные отличаются от простых очень и очень сильно, а тестировать на каких-то абстрактных данных, которые не имеют ничего общего с реальностью смысла, имхо, нет.
        Вот такой пример, возможно я и не прав, но как-то тут слишком много проблем возникает.
    • +1
      в книге описаны способы решения подобных проблем
  • +1
    а еще я советую Фаулера Рефакторинг http://www.books.ru/books/refaktoring-uluchshenie-sushchestvuyushchego-koda-30436/ обязательную к прочтению для каждого программиста
  • 0
    что касается TDD — то это хорошая методология, которая дает положительный эффект особенно при командной разработке.
    • 0
      Быстро находится то место, где один муд@к испортил, что делал второй…
      • 0
        Сложнее найти того №;%, кто это придумал так сделать… Это не всегда автор кода.
  • 0
    Рекомендую добавить\изменить:
    0) Читатели разные бывают новички и подготовленные и даже гуру! Рекомендую сразу обозначить целевую аудиторию более явно.
    1) Хотелось бы видеть в «доп. ссылки» названия проектов, инструментов того что вы используете на практике. А то вроде как в тексте «CppUnitLite», а что это? Без доп. действий с гуглом не поймешь.
    2) Если вы что-то рекомендуете, не следует это рекомендовать 3-4 раза подряд в статье, лучше вынести в «доп. литература» и пометить надписью «рекомендую» с bold-шрифтом.

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.