четверг, 28 ноября 2013 г.

Что я ещё хочу сказать о TDD (не закончено)

Что я ещё хочу сказать о TDD

Предыдущая серия была тут - http://programmingmindstream.blogspot.ru/2013/11/tdd.html

Многие люди с которыми я общался насчёт тестов вообще и TDD в частности "разбивались" о следующее препятствие:

TestFirst. TestDriven.

Я не хочу "спорить", а тем более опровергать или критиковать никого из уважаемых "теоретиков TDD". 

Я лишь хочу описать свою трактовку. Ну или - как я это "для себя" понял.


TDD говорит нам примерно следующее:

1. Запустите ВСЕ тесты, убедитесь, что они прошли.
2. Добавьте новый тест для новой функциональности. Убедитесь, что он не прошёл.
3. Добавьте функциональность. Убедитесь, что тест прошёл.

Какой вопрос сразу возникает у людей?

А вот какой: "Как я могу написать тест к тому, чего НЕТ?"?

И люди - ПРАВЫ.

Я сам когда впервые смотрел на TTD - думал - "вот чушь собачья". (Это - "эпитет", а не "ругательство")

"Как можно писать тест к тому чего НЕТ? Пусть даже и НЕ проходящий.

"Пойди туда не знаю куда..."

Я долго думал и вот, что я для себя надумал:

НЕТУ TestFirst.

НЕТУ TestFirst.

В ПЕРВУЮ очередь - есть "набросок" АРХИТЕКТУРЫ. Не код, ни тесты, ни что-то ещё. А именно - "набросок" АРХИТЕКТУРЫ.

И TestFirst делается не для чего-то "абстрактного", что "будет когда-то потом". А для ВПОЛНЕ КОНКРЕТНОГО.

 Описанного в архитектуре и растущими ногами из ТЗ.

Простейший пример:

 Пусть нам надо сделать "кнопку", которая зовёт новую функциональность.

Тогда тест может быть такой:

 AssureThatButtonExists('SomeButton');

И он КОНЕЧНО не пройдёт - пока мы не добавим кнопку и пройдёт - КОГДА мы её таки ДОБАВИМ.

Но! Тест "ЗНАЕТ" о существовании кнопки. Он знает хотя бы её имя. Т.е. он тестирует не "непонятно что", а вполне "понятно что". Часть концепций архитектуры. КНОПКА, описанная в ТЗ и архитектуре.

Идём дальше.

Например нам надо написать класс TmyIntegerList (я его разберу подробнее ниже).

И что мы делаем?

Мы пишем:

"Дизайн архитектуры" и заготовку проектного класса.

ИМЕННО их, а не ТЕСТ.

Ещё раз:

Мы пишем: "Дизайн архитектуры" и заготовку проектного класса.

ИМЕННО их, а не ТЕСТ.

Как-то так:

type
 TmyIntegerList = class
  public
   procedure Add(anItem: Integer);
 end;//TmyIntegerList

procedure TmyIntegerList.Add(anItem: Integer);
begin
 Assert(false, 'Не реализовано');
end;

А ПОТОМ только, мы пишем ТЕСТ.

Ещё РАЗ - ТОЛЬКО потом пишем ТЕСТ.

Примерно такой:

procedure TmyIntegerListTest.ListAdd;
var
 l_List : TmyIntegerList;
begin
 l_List := TmyIntegerList.Create;
 try
  l_List.Add(47879);
 finally
  FreeAndNil(l_List);
 end;
end;

И этот тест - КОНЕЧНО же - НЕ ПРОЙДЁТ. А вот ПОТОМ, когда мы добавим РЕАЛИЗАЦИЮ метода TmyIntegerList.Add - он пройдёт.

Итак.

Нету никакого TestFirst.

Если уж говорить, про "first" - то ПЕРВИЧНЫ архитектура и дизайн, а лишь потом - ТЕСТЫ. А лишь потом - реализация.

Итак цепочка разработки выглядит так:
 ТЗ -> Набросок архитектуры -> Тест -> Код

А дальше возможны все разные варианты:
 ТЗ -> Набросок архитектуры -> Тест -> Код -> Тест -> Код
 ТЗ -> Набросок архитектуры -> Тест -> Код -> Архитектура -> Тест -> Код
 ТЗ -> Набросок архитектуры -> Тест -> Код -> Архитектура -> Тест -> Код -> ТЗ -> Архитектура -> Тест -> Код

и т.д. и т.п.

First - это - ТЗ и набросок архитектуры - дальше начинается - "итерационная разработка".

И что - особенно ВКУСНО - это то, что как только мы написали тест - нам НЕ НАДО думать - "а где проверять работоспособность нашего класса".

Мы УЖЕ настроили всю инфраструктуру для его тестирования и проверки.

ИМЕННО в этом и заключается слово Driven в TDD.

В том, что ТЕСТЫ - ПОМОГАЮТ, а не МЕШАЮТ процессу разработки.

Тесты - "ведут" за собой разработчика.

Тесты влияют на код, а код на тесты.

А они в свою очередь ВСЕ ВМЕСТЕ влияют на архитектуру и ТЗ.

А главное, что раз тесты "ведут" за собой, то они всё же так или иначе - написаны и проверяют работоспособность проектных классов. А значит - нет НИ МАЛЕЙШЕГО резона их не использовать.

И даже если вы напишете что-то вроде:

unit myListTests;

interface

uses
 TestFramework
 ;

type
 TmyIntegerListTests = class(TTestCase)
  published
   procedure ListAdd;
 end;//TmyIntegerListTests

implementation

procedure TmyIntegerListTests.ListAdd;
begin
 Assert(false, 'Не реализовано');
end;

initialization
  TestFramework.RegisterTest(TmyIntegerListTests.Suite);

end.

И "типа" не использовали класс TmyIntegerList, то это не значит, что класса TmyIntegerList - "не существует в природе". Он есть как минимум "у вас в голове". Раз вы написали что-то вроде TmyIntegerListTests.ListAdd, то это - ОЗНАЧАЕТ, что есть нечто, на чём есть операция Add.

Если вы скажете мне "фуу.. список целых чисел... как банально... протестируйте нам ядерный реактор..."

Отвечу - "да банально". Но! "Дорога из тысячи ли начинается с одного шага". И "ядерные реакторы" где-то внутри "состоят из списков целых".

А если захотите РЕАЛЬНЫЕ примеры тестов - так их "есть у меня". ЦЕЛЫЙ вагон. Готов показать, "в обмен" на ваш "хотя бы один тест". Чтобы "РАЗГОВАРИВАТЬ НА РАВНЫХ". А не лить воду на неработаущую мельницу.

Теперь ремарка о том откуда по моему скромному пониманию взялось понятие TestFirst и "протестируй сначала то, чего - нету".

Мне кажется, что тут всё дело, что всё ногами уходит в Java и JUnit, где очень сильно используется рефлексия, duck-typing, инъекции и прочие подобные "кунштюки".

И там можно написать "примерно" такой тест:

procedure TmyIntegerListTest.ListAdd;
var
 l_List : Object;
begin
 l_List := Framework.GetClassByName('TmyIntegerList').Create;
 try
  l_List.MethodByName.Execute('Add', [47879]);
 finally
  FreeAndNil(l_List);
 end;
end;

Я совсем не большой знаток Java, но надеюсь, что идея - понятна.

И тут получается такой момент - "хвост виляет собакой". Мы "вроде бы" НИЧЕГО не знаем про тестируемый класс, но однако можем "протестировать" его не написав его сначала. TestFirst. Типа...

Но давайте подумаем - "а действительно ли мы не знаем про тестируемый класс"? НЕТ КОНЕЧНО. Мы знаем его имя и знаем, что у него есть метод Add.

TestFirst? Да - хрена лысого! ArchitectureFirst!!!

Пусть мы даже и не написали НИ ОДНОЙ строчки нашего класса - мы УЖЕ начали ПРОЕКТИРОВАТЬ его АРХИТЕКТУРУ, а ТОЛЬКО потом - НАЧАЛИ писать тест.

Пусть даже не написано НИ ОДНОЙ строчки кода, кроме теста, но! "Дизайн архитектуры" - ЕСТЬ и при подходе с рефлексией и duck-typing'ом. Он ЕСТЬ - ХОТЯ БЫ "у нас в голове".

Но он - ЕСТЬ!

ArchitectureFirst!!!

Надеюсь, что моя мысль - понятна и развеявает сомнения насчёт "Как я могу написать тест к тому, чего НЕТ?"?

Не то "чего нет"! А то "что находится в процессе разработки и дизайна". Надеюсь это - понятно.
И это - СОВСЕМ не значит, что я "критикую" TDD, СОВСЕМ наоборот - я пытаюсь сделать всё возможное, чтобы как можно больше программистов прониклись этой идеей.

И пытаюсь "развеять сомнения" и показать, что "это совсем несложно".

Теперь позволю себе привести пример - "как я обычно делаю".

В примере я буду придерживаться САМОГО плохого и пессимистичного сценария - когда ни заказчики, ни "Группа Качества" не реагируют на вопросы разработчика.

И разработчику приходится либо "придумывать от себя", либо писать Assert'ы - http://18delphi.blogspot.ru/2013/04/blog-post.html

Весь код примера доступен в SVN тут - https://sourceforge.net/p/rumtmarc/code-0/HEAD/tree/trunk/Blogger/TDD/Chapter0/

Пусть нам надо написать всё тот же класс TmyIntegerList. 

И пусть его спецификация такова:

TmyIntegerList - список целых чисел.

Поддерживает операции:

1. Вставки элемента.
2. Добавления элемента.
3. Удаления элемента.
4. Получения количества элементов.
5. Получения значения элемента по его порядковому номеру.

Это "типа" - ТЗ.

Напишем "рыбу" теста:

program TDD;

uses
  Vcl.Forms,
  GUITestRunner,
  myListTests in 'Tests\myListTests.pas';

{$R *.res}

begin
 Application.Initialize;
 GUITestRunner.RunRegisteredTests;
end.

unit myListTests;

interface

uses
 TestFramework
 ;

type
 TmyIntegerListTests = class(TTestCase)
  published
   procedure ListAdd;
   procedure ListInsert;
   procedure ListDelete;
   procedure ListCount;
   procedure ListItem;
 end;//TmyIntegerListTests

implementation

procedure TmyIntegerListTests.ListAdd;
begin
 Assert(false, 'Не реализовано');
end;

procedure TmyIntegerListTests.ListInsert;
begin
 Assert(false, 'Не реализовано');
end;

procedure TmyIntegerListTests.ListDelete;
begin
 Assert(false, 'Не реализовано');
end;

procedure TmyIntegerListTests.ListCount;
begin
 Assert(false, 'Не реализовано');
end;

procedure TmyIntegerListTests.ListItem;
begin
 Assert(false, 'Не реализовано');
end;

initialization
  TestFramework.RegisterTest(TmyIntegerListTests.Suite);

end.

-- в ней мы "типа не знаем" о том, ЧТО тестируем, но на самом деле - "набросок архитектуры" УЖЕ сидит "у нас в голове". Мы знаем про методы Add, Insert, Delete, Count и Item.

И тест - конечно же не пройдёт.

Теперь зафиксируем "набросок архитектуры" в проектном классе.

А именно - перенесём то, что "сидело в нашем мозгу" когда мы писали первый тест, на "бумагу".

Опишем "прототип" нашего класса:

unit myIntegerList;

interface

type
 TmyIntegerList = class
  public
   type
    IndexType = Integer;
    ItemType = Integer;
  protected
   function pm_GetCount: IndexType;
   function pm_GetItem(anIndex: IndexType): ItemType;
  public
   procedure Add(anItem: ItemType);
   procedure Insert(anIndex: IndexType; anItem: ItemType);
   procedure Delete(anIndex: IndexType);
   property Count: IndexType
    read pm_GetCount;
   property Items[anIndex: IndexType]: ItemType
    read pm_GetItem;
 end;//TmyIntegerList

implementation

// TmyIntegerList

function TmyIntegerList.pm_GetCount: IndexType;
begin
 Result := -1;
 Assert(false, 'Не реализовано');
end;

function TmyIntegerList.pm_GetItem(anIndex: IndexType): ItemType;
begin
 Result := -1;
 Assert(false, 'Не реализовано');
end;

procedure TmyIntegerList.Add(anItem: ItemType);
begin
 Assert(false, 'Не реализовано');
end;

procedure TmyIntegerList.Insert(anIndex: IndexType; anItem: ItemType);
begin
 Assert(false, 'Не реализовано');
end;

procedure TmyIntegerList.Delete(anIndex: IndexType);
begin
 Assert(false, 'Не реализовано');
end;

end.

И видоизменим тесты:

unit myIntegerListTests;

interface

uses
 TestFramework
 ;

type
 TmyIntegerListTests = class(TTestCase)
  published
   procedure ListAdd;
   procedure ListInsert;
   procedure ListDelete;
   procedure ListCount;
   procedure ListItem;
 end;//TmyIntegerListTests

implementation

uses
 System.SysUtils,
 myIntegerList
 ;

procedure TmyIntegerListTests.ListAdd;
var
 l_List : TmyIntegerList;
begin
 l_List := TmyIntegerList.Create;
 try
  l_List.Add(Random(1000));
 finally
  FreeAndNil(l_List);
 end;//try..finally
end;

procedure TmyIntegerListTests.ListInsert;
var
 l_List : TmyIntegerList;
begin
 l_List := TmyIntegerList.Create;
 try
  l_List.Insert(0, Random(1000));
 finally
  FreeAndNil(l_List);
 end;//try..finally
end;

procedure TmyIntegerListTests.ListDelete;
var
 l_List : TmyIntegerList;
begin
 l_List := TmyIntegerList.Create;
 try
  l_List.Delete(0);
 finally
  FreeAndNil(l_List);
 end;//try..finally
end;

procedure TmyIntegerListTests.ListCount;
var
 l_List : TmyIntegerList;
begin
 l_List := TmyIntegerList.Create;
 try
  l_List.Count;
 finally
  FreeAndNil(l_List);
 end;//try..finally
end;

procedure TmyIntegerListTests.ListItem;
var
 l_List : TmyIntegerList;
begin
 l_List := TmyIntegerList.Create;
 try
  l_List.Item[0];
 finally
  FreeAndNil(l_List);
 end;//try..finally
end;

initialization
  TestFramework.RegisterTest(TmyIntegerListTests.Suite);

end.

-- теперь в тестах мы уже ЗНАЕМ про РЕАЛЬНЫЙ проектный класс и используем его.

Тесты в таком виде - БЕЗУСЛОВНО не пройдут.

Пойдём дальше.

Реализуем хотя бы один из методов нашего проектного класса.

А лучше - ДВА. Самых простых - Add и Count.

Вот как это выглядит:

unit myIntegerList;

interface

type
 TmyIntegerList = class
  public
   type
    IndexType = Integer;
    ItemType = Integer;
  private
   type
    ItemsArray = array of ItemType;
  private
   f_Items : ItemsArray;
  protected
   function pm_GetCount: IndexType;
   function pm_GetItem(anIndex: IndexType): ItemType;
  public
   procedure Add(anItem: ItemType);
   procedure Insert(anIndex: IndexType; anItem: ItemType);
   procedure Delete(anIndex: IndexType);
   property Count: IndexType
    read pm_GetCount;
   property Item[anIndex: IndexType]: ItemType
    read pm_GetItem;
 end;//TmyIntegerList

implementation

// TmyIntegerList

function TmyIntegerList.pm_GetCount: IndexType;
begin
 Result := Length(f_Items);
end;

function TmyIntegerList.pm_GetItem(anIndex: IndexType): ItemType;
begin
 Result := -1;
 Assert(false, 'Не реализовано');
end;

procedure TmyIntegerList.Add(anItem: ItemType);
begin
 SetLength(f_Items, Length(f_Items) + 1);
 f_Items[High(f_Items)] := anItem;
end;

procedure TmyIntegerList.Insert(anIndex: IndexType; anItem: ItemType);
begin
 Assert(false, 'Не реализовано');
end;

procedure TmyIntegerList.Delete(anIndex: IndexType);
begin
 Assert(false, 'Не реализовано');
end;

end.

-- увидим, что два теста - ПРОШЛИ. Не ФАКТ, что ПРАВИЛЬНО, но ПРОШЛИ.

Вот тут мы сталкиваемся именно с тем, что называется - Driven.

Наполнили "скелет" прототипа "мясом" и сразу получили отклик от тестов.

Пойдём дальше.

Реализуем остальные методы нашего проектного класса:

unit myIntegerList;

interface

type
 TmyIntegerList = class
  public
   type
    IndexType = Integer;
    ItemType = Integer;
  private
   type
    ItemsArray = array of ItemType;
  private
   f_Items : ItemsArray;
  protected
   function pm_GetCount: IndexType;
   function pm_GetItem(anIndex: IndexType): ItemType;
  public
   procedure Add(anItem: ItemType);
   procedure Insert(anIndex: IndexType; anItem: ItemType);
   procedure Delete(anIndex: IndexType);
   property Count: IndexType
    read pm_GetCount;
   property Item[anIndex: IndexType]: ItemType
    read pm_GetItem;
 end;//TmyIntegerList

implementation

// TmyIntegerList

function TmyIntegerList.pm_GetCount: IndexType;
begin
 Result := Length(f_Items);
end;

function TmyIntegerList.pm_GetItem(anIndex: IndexType): ItemType;
begin
 Result := f_Items[anIndex];
end;

procedure TmyIntegerList.Add(anItem: ItemType);
begin
 SetLength(f_Items, Length(f_Items) + 1);
 f_Items[High(f_Items)] := anItem;
end;

procedure TmyIntegerList.Insert(anIndex: IndexType; anItem: ItemType);
begin
 if (anIndex = Self.Count) then
  Add(anItem)
 else
  Assert(false, 'Не реализовано');
  // - ну не знаю я что тут делать, ТЗ типа - НЕПОЛНОЕ
end;

procedure TmyIntegerList.Delete(anIndex: IndexType);
begin
 if (anIndex < 0) OR (anIndex >= Self.Count) then
 // - ну нечего тут удалять
  Exit
 else
  Assert(false, 'Не реализовано');
end;

end.

Запускаем тесты и что мы видим?

ОДИН тест - НЕ прошёл - TmyIntegerListTests.ListItem - там случилось AV.

А причина в методе TmyIntegerList.pm_GetItem.

Что делать?

Давайте перепишем метод TmyIntegerList.pm_GetItem так:

function TmyIntegerList.pm_GetItem(anIndex: IndexType): ItemType;
begin
 if (Self.Count = 0) then
 // - ну не знаю я тут, ЧТО делать - в ТЗ - НЕ оговорено
  Result := Random(5676)
 else
  Result := f_Items[anIndex];
end;

Запускаем тесты и они - ПРОШЛИ!

Сделали ли мы свою работу? "Наверное". Но не факт.

Давайте предположим, что мы отправили нашу разработку в "Группу Качества" и она нашла следующее:

var
 l_List : TmyIntegerList;
begin
 l_List := TmyIntegerList.Create;
 try
  l_List.Add(Random(54365));
  l_List.Delete(0);
 finally
  FreeAndNil(l_List);
 end;//try..finally
end;

И "открыла тикет в QC с номером 1".

После "выяснения подробностей" и "переговоров с ГК" - УБЕЖДАЕМСЯ, что ошибка - таки есть.

Пишем НОВЫЙ ТЕСТ:

procedure TmyIntegerListTests.QCTicket1;
var
 l_List : TmyIntegerList;
begin
 l_List := TmyIntegerList.Create;
 try
  l_List.Add(Random(54365));
  l_List.Delete(0);
 finally
  FreeAndNil(l_List);
 end;//try..finally
end;

И убеждаемся, что он таки НЕ проходит!

Что делать?

Правим код проектного класса:

procedure TmyIntegerList.Delete(anIndex: IndexType);
begin
 if (anIndex < 0) OR (anIndex >= Self.Count) then
 // - ну нечего тут удалять
  Exit
 else
 if (anIndex = Self.Count - 1) then
  SetLength(f_Items, Self.Count - 1)
 else
  Assert(false, 'Ну не знаю я что тут делать');
end;

Запускаем тесты - они - проходят. Сделали ли мы свою работу? НЕ ЗНАЮ. Наверное...

Теперь давайте предположим, что ГК нашла ещё одну ошибку:

... to be continued ...

Продолжать? Или "идея и так понятна"?

8 комментариев:

  1. Интересная статья, спасибо!

    ОтветитьУдалить
  2. В некотором смысле юнит-тесты решают абсолютно те же задачи, что и статическая типизация. Возможно, поэтому они наиболее прижились как раз там, где статической типизации нет.

    Что такое статическая типизация? Это некое описание требований к коду, на соответствие котором компилятор будет наш код проверять. Что такое юнит-тесты? Это опять требования к коду, на соответствие которым код будет проверяться.

    Если воспринимать юнит-тесты именно так, то исчезают все противоречия. С чего начинают разработку программы в языках со статической типизацией? С описания типов! С чего нужно начинать разработку программы в методологии TDD? С написания тестов! Так что это именно architecture first в обоих случаях.

    Это выглядит не очевидно и разобщено, но во всем виноваты недостаточно развитые языковые средства, которыми мы пользуемся. Но тем не менее, в каком бы виде мы бы не описывали требования к входным и выходным данным некоторой функции, мы описываем ее ТИП и ничего более.

    ОтветитьУдалить
    Ответы
    1. Роман, ты ВО МНОГОМ ПРАВ!

      Ты даже заставил меня ПО ИНОМУ взглянуть на процесс разработки и тестирования.

      СПАСИБО!

      Пред-условий и пост-условия - это - КРУТО!

      И они - ВАЖНЫ и РАБОТАЮТ.

      Но они НЕ ЗАМЕНЯЮТ тестов.

      Скажу так - пред-условие и пост-условие - это КОНТРАКТ. А ТЕСТ - это "точка входа" для проверки контракта.

      БЕЗ "точки входа" КОНТРАКТ может оказаться НЕ ПРОВЕРЕННЫМ.

      Ну ведь не на "реальных же пользователях тестировать".

      Если "не донёс" - могу развить тему. Если интересно.

      Удалить
    2. Не, я понимаю разницу между контрактами и тестами. Это безусловно разные вещи, но тем не менее между ними много общего :)

      Удалить
  3. "Не, я понимаю разницу между контрактами и тестами. Это безусловно разные вещи, но тем не менее между ними много общего :)"

    БЕЗУСЛОВНО! :-)

    ОтветитьУдалить
    Ответы
    1. Есть, кстати, системы, позволяющие автоматически генерировать юнит-тесты по контактам. Но все это очень экспериментально пока.

      Удалить
  4. ну да... ну да... Только я лично склоняюсь к выведению контрактов и тестов из модели и требований при помощи кодогенерации... И расширений к DUnit с использованием атрибутов.

    ОтветитьУдалить