среда, 11 июня 2014 г.

Тестируем калькулятор №6.2.1. Применяем "классическое TDD"


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



Вопросы которые появились у меня:
- Что появляется нового в приложении ?
Ну тут вроде всё понятно, необходимо реализовать операцию div.


- Есть ли граничные условия у функции ?
Так как тут нам ничего не говорили про прошлые функции тоже, а заказчик вроде доволен, делаем по аналогии с предыдущими.


- Есть ли необходимость в обработке “больших” чисел, больше BigInt например ?
Ответ такой же как и на предыдущий вопрос.


- Изменяется ли поведение других функций ?
Так как нам ничего не говорили об этом, пока будем считать что ничего не изменяется.


- Что изменяется в интерфейсе программы, как будет выглядеть кнопка с новой операцией, и где будет находиться ?
Тут мы решили просто добавить кнопку рядом и подписать её DivInt.


Далее зафиксируем наше ТЗ. Мы создадим папку в Google Docs, куда дадим доступ заказчику. Так как Google фиксирует изменения внесенные в документе, мы всегда сможем отследить кто и что менял. Можно было бы ещё добавить файл в GIT.


Ну а теперь приступим непосредственно к кодированию.


Давайте для начала вспомним немного о TDD, В "классическом TDD" шаги описаны так:
1. Написать новый тест. Убедиться что он не проходит
2. Написать код. Убедиться что тест проходит.
3. Запустить все тесты. Убедиться что не поломались старые тесты.
4. Рефакторинг, если он необходим.




Приступим.
Шаг 1. Пишем новый тест. Новый тест добавляем в TCalculatorOperationViaLogicTest, почему именно сюда, потому что этот класс отвечает за проверку бизнес-логики приложения.
  
procedure TCalculatorOperationViaLogicTest.TestDivInt;
begin
  Assert(false, 'Not Implemented')
end;

Запускаем наши тесты, и соответственно видим красный тест.

Теперь добавляем новую операцию DivInt в наш класс TCalculator куда переносим наш Assert в класс бизнес-логики, а в тесте соответственно делаем вызов нашей новой операции.
  
...
class function TCalculator.DivInt(const A, B: string): string;
begin
  Assert(false, 'Not Implemented');
end;
...
procedure TCalculatorOperationViaLogicTest.TestDivInt;
var
  x1, x2 : string;
begin
  x1:= cA;
  x2:= cB;
  CheckTrue(2 = StrToFloat(TCalculator.DivInt(x2, x1)));
end;

Что мы сделали на этом этапе.
1. Мы определили ГДЕ мы пишем тест.
2. Мы определили ГДЕ мы будем реализовывать бизнес-логику для новой операции.

Шаг 2. Пишем бизнес-логику в нашем классе TCalculator
  
class function TCalculator.DivInt(const A, B: string): string;
var
  x1, x2, x3  : Integer;
begin
  x1 := StrToInt(A);
  x2 := StrToInt(B);
  x3 := x1 div x2;
  Result := FloatToStr(x3);
end;

Запускаем наши тесты, и видим зелёный тест:

И вот тут мы и допустил первую но очень важную ошибку. Которая вылезет чуть позже. Пока хочу обратить внимание читателей на тип переменных x1, x2, x3 : Integer;

Следующим шагом мы решили добавить проверку операции в наши остальные тесты, первым делом в класс TCalculatorOperationViaEtalonTest, то есть зафиксировать реализацию операции в эталон.

procedure TCalculatorOperationViaEtalonTest.TestDivInt;
var
  x1, x2  : string;
begin
  x1:= cA;
  x2:= cB;

  CheckOperation(g_Logger, x1, x2, TCalculator.DivInt);
end;

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


procedure TCalculatorOperationRandomSequenceTest.TestDivInt;
begin
  CheckOperationSeq(g_Logger, TCalculator.DivInt);
end;



Как видим наш тест благополучно упал, в следствии того что наша процедура везде передает Float, а для процедуры TCalculator.DivInt я выбрал Integer. Следующим глупым решением было "поковеркать" наши тесты так, что бы всё "сходилось". Из чего родилось "это".

Было: 
procedure TCalculatorOperationRandomSequenceTest.CheckOperationSeq(
  aLogger: TLogger;
  anOperation: TCalcOperation);
var
  l_Index : Integer;
begin
  RandSeed := 40000;
  aLogger.OpenTest(Self);
  for l_Index := 0 to 10000 do
    CheckOperation(aLogger,
                   1000 * Random,
                   2000 * Random + 1, anOperation);
  CheckTrue(aLogger.CheckWithEtalon);
end;

Стало:
procedure TCalculatorOperationRandomSequenceTest.CheckOperationSeq(
  aLogger: TLogger;
  anOperation: TCalcOperation);
var
  l_Index : Integer;
begin
  RandSeed := 40000;
  aLogger.OpenTest(Self);
  for l_Index := 0 to 10000 do
  begin
    if Self.GetName = 'TestDivInt' then
      CheckOperation(aLogger,
                     Int(2000 * Random),
                     Int(1000 * Random + 1), anOperation)
    else
      CheckOperation(aLogger,
                     1000 * Random,
                     2000 * Random + 1, anOperation)
  end;
  CheckTrue(aLogger.CheckWithEtalon);
end;



Как видим проблему мы решили. Однако допустив фундаментальную ошибку при реализации бизнес-логики, мы полезли "коверкать" тестирование ради "зеленых лампочек". Не забываем добавить в гит эталоны.

Что же делать нам надо было сразу. А надо было выяснить у клиента что он хочет видеть когда мы вносим 2 вещественных числа, и нажимаем нашу новую кнопку ?
Так как сроки горят а его всё нет и нет, то принимаем решение исправить бизнес-логику просто округляя числа перед операцией div.

class function TCalculator.DivInt(const A, B: string): string;
var
  x1, x2, x3  : Integer;
begin
  x1 := round(StrToFloat(A));
  x2 := round(StrToFloat(B));
  x3 := x1 div x2;
  Result := FloatToStr(x3);
end;

И вернём нашу процедуру проверки последовательности в первоначальный вид.
procedure TCalculatorOperationRandomSequenceTest.CheckOperationSeq(
  aLogger: TLogger;
  anOperation: TCalcOperation);
var
  l_Index : Integer;
begin
  RandSeed := 40000;
  aLogger.OpenTest(Self);
  for l_Index := 0 to 10000 do
    CheckOperation(aLogger,
                   1000 * Random,
                   2000 * Random + 1, anOperation);
  CheckTrue(aLogger.CheckWithEtalon);
end;

Удалим наш эталон. Убедимся что тесты проходят. И переходим к работе с GUI нашей программы. Добавим кнопку на форму. Параллельно приведем в порядок имена контролов:

procedure TfmMain.btnDivIntClick(Sender: TObject);
begin
 edtResult.Text := TCalculator.DivInt(edtFirstArg.Text, edtSecondArg.Text);
end;

Последним шагом нам осталось проверить как работает наша форма, мы конечно можем просто покликать на нашу кнопочку, учитывая что бизнес-логика уже проверилась 10к вариантами, однако мы как настоящие "тру программисты" добавим ещё один тест для нашей операции:
...
type
  TOperation = (opAdd, opMinus, opMul, opDiv, opDivInt);
...
procedure TOperationTest.VisitForm(aForm: TfmMain);
var
 aA, aB : Single;
begin
 aA := GetFirstParam;
 aB := GetSecondParam;
 aForm.edtFirstArg.Text := FloatToStr(aA);
 aForm.edtSecondArg.Text := FloatToStr(aB);
 case GetOp of
  opAdd:
  begin
   aForm.btnAdd.Click;
   Check((aForm.edtResult.Text) = TCalculator.FloatToStr(aA + aB));
  end;
  opMinus:
  begin
   aForm.btnMinus.Click;
   Check((aForm.edtResult.Text) = TCalculator.FloatToStr(aA - aB));
  end;
  opMul:
  begin
   aForm.btnMul.Click;
   Check((aForm.edtResult.Text) = TCalculator.FloatToStr(aA * aB));
  end;
  opDiv:
  begin
   aForm.btnDiv.Click;
   Check((aForm.edtResult.Text) = TCalculator.FloatToStr(aA / aB));
  end;
  opDivInt:
  begin
   aForm.btnDivInt.Click;
   Check((aForm.edtResult.Text) = TCalculator.FloatToStr(Round(aA) div Round(aB)));
  end;
 end;//case GetOp
end;

Запускаем, проверяем что все наши тесты сходятся. Коммитим наши изменения в гит.

Исходные коды наших файлов:
unit Calculator;

interface

type
 TCalculator = class
  public
   class function Add(const A, B: string): string;
   class function Sub(const A, B: string): string;
   class function Mul(const A, B: string): string;
   class function Divide(const A, B: string): string;
   class function FloatToStr(aValue: Double): string;
   class function DivInt(const A, B: string): string;
 end;//TCalculator

implementation

uses
  SysUtils
  ;

class function TCalculator.FloatToStr(aValue: Double): string;
var
 l_FS : TFormatSettings;
begin
  l_FS := TFormatSettings.Create;
  l_FS.DecimalSeparator := '.';
  Result := SysUtils.FloatToStr(aValue, l_FS);
end;

class function TCalculator.Add(const A, B: string): string;
var
  x1, x2, x3 : single;
begin
  x1 := StrToFloat(A);
  x2 := StrToFloat(B);
  x3 := x1 + x2;
  Result := FloatToStr(x3);
end;

class function TCalculator.Sub(const A, B: string): string;
var
  x1, x2, x3 : single;
begin
  x1 := StrToFloat(A);
  x2 := StrToFloat(B);
  x3 := x1 - x2;
  Result := FloatToStr(x3);
end;

class function TCalculator.Mul(const A, B: string): string;
var
  x1, x2, x3 : single;
begin
  x1 := StrToFloat(A);
  x2 := StrToFloat(B);
  x3 := x1 * x2;
  Result := FloatToStr(x3);
end;

class function TCalculator.Divide(const A, B: string): string;
var
  x1, x2, x3 : single;
begin
  x1 := StrToFloat(A);
  x2 := StrToFloat(B);
  x3 := x1 / x2;
  Result := FloatToStr(x3);
end;

class function TCalculator.DivInt(const A, B: string): string;
var
  x1, x2, x3  : Integer;
begin
  x1 := round(StrToFloat(A));
  x2 := round(StrToFloat(B));
  x3 := x1 div x2;
  Result := FloatToStr(x3);
end;

end.
unit CalculatorOperationViaLogicTest;

interface

uses
  TestFrameWork,
  Calculator
  ;

 type
  TCalculatorOperationViaLogicTest = class(TTestCase)
   published
    procedure TestDiv;
    procedure TestMul;
    procedure TestAdd;
    procedure TestSub;
    procedure TestSubError;
    procedure TestDivInt;
  end;//TCalculatorOperationViaLogicTest

implementation

  uses
   SysUtils;

const
 cA = '5';
 cB = '10';
{ TCalculatorOperationViaLogicTest }

procedure TCalculatorOperationViaLogicTest.TestDiv;
var
  x1, x2 : string;
begin
  x1:= cA;
  x2:= cB;
  CheckTrue(2 = StrToFloat(TCalculator.Divide(x2, x1)));
end;

procedure TCalculatorOperationViaLogicTest.TestDivInt;
var
  x1, x2 : string;
begin
  x1:= cA;
  x2:= cB;
  CheckTrue(2 = StrToFloat(TCalculator.DivInt(x2, x1)));
end;

procedure TCalculatorOperationViaLogicTest.TestSub;
var
  x1, x2 : string;
begin
  x1:= cA;
  x2:= cB;
  CheckTrue(5 = StrToFloat(TCalculator.Sub(x2, x1)));
end;

procedure TCalculatorOperationViaLogicTest.TestSubError;
var
  x1, x2 : string;
begin
  x1:= cA;
  x2:= cB;
  CheckFalse(7 = StrToFloat(TCalculator.Sub(x2, x1)));
end;

procedure TCalculatorOperationViaLogicTest.TestMul;
var
  x1, x2: string;
begin
  x1:= cA;
  x2:= cB;
  CheckTrue(50 = StrToFloat(TCalculator.Mul(x2, x1)));
end;

procedure TCalculatorOperationViaLogicTest.TestAdd;
var
  x1, x2  : string;
begin
  x1:= cA;
  x2:= cB;
  CheckTrue(15 = StrToFloat(TCalculator.Add(x2, x1)));
end;

initialization
 TestFramework.RegisterTest(TCalculatorOperationViaLogicTest.Suite);
end.
unit CalculatorOperationViaEtalonTest;

interface

uses
  TestFrameWork,
  Calculator,
  Tests.Logger;

 type
  TCalcOperation = function (const A, B: string): string of object;

  TCalculatorOperationViaEtalonTest = class(TTestCase)
   private
    procedure CheckOperation(aLogger: TLogger;
                             aX1, aX2: string;
                             anOperation : TCalcOperation);
   published
    procedure TestDiv;
    procedure TestMul;
    procedure TestAdd;
    procedure TestSub;
    procedure TestDivInt;
  end;//TCalculatorOperationViaEtalonTest

implementation

  uses
    SysUtils;


const
 cA = '5';
 cB = '10';
{ TCalculatorOperationViaEtalonTest }
procedure TCalculatorOperationViaEtalonTest.CheckOperation(aLogger: TLogger;
                                                           aX1, aX2: string;
                                                           anOperation : TCalcOperation);
begin
  aLogger.OpenTest(Self);
  aLogger.ToLog(aX1);
  aLogger.ToLog(aX2);
  aLogger.ToLog(anOperation(aX1,aX2));
  CheckTrue(aLogger.CheckWithEtalon);
end;

procedure TCalculatorOperationViaEtalonTest.TestDiv;
var
  x1, x2 : string;
begin
  x1:= cA;
  x2:= cB;

  CheckOperation(g_Logger, x1, x2, TCalculator.Divide);
end;

procedure TCalculatorOperationViaEtalonTest.TestDivInt;
var
  x1, x2  : string;
begin
  x1:= cA;
  x2:= cB;

  CheckOperation(g_Logger, x1, x2, TCalculator.DivInt);
end;

procedure TCalculatorOperationViaEtalonTest.TestSub;
var
  x1, x2  : string;
begin
  x1:= cA;
  x2:= cB;

  CheckOperation(g_Logger, x1, x2, TCalculator.Sub);
end;

procedure TCalculatorOperationViaEtalonTest.TestMul;
var
  x1, x2  : string;
begin
  x1:= cA;
  x2:= cB;

  CheckOperation(g_Logger, x1, x2, TCalculator.Mul);
end;

procedure TCalculatorOperationViaEtalonTest.TestAdd;
var
  x1, x2  : string;
begin
  x1:= cA;
  x2:= cB;

  CheckOperation(g_Logger, x1, x2, TCalculator.Add);
end;

initialization
 TestFramework.RegisterTest(TCalculatorOperationViaEtalonTest.Suite);
end.
unit CalculatorOperationRandomSequenceTest;

interface

uses
  TestFrameWork,
  Calculator,
  Tests.Logger;

 type
  TCalcOperation = function (const A, B: string): string of object;

  TCalculatorOperationRandomSequenceTest = class(TTestCase)
   private
    procedure CheckOperation(aLogger: TLogger;
                             aX1, aX2: Double;
                             anOperation : TCalcOperation);
    procedure CheckOperationSeq(aLogger: TLogger;
                                anOperation : TCalcOperation);
   published
    procedure TestDiv;
    procedure TestMul;
    procedure TestAdd;
    procedure TestSub;
    procedure TestDivInt;
  end;//TCalculatorOperationRandomSequenceTest

implementation

  uses
    SysUtils;

{ TCalculatorOperationRandomSequenceTest }
procedure TCalculatorOperationRandomSequenceTest.CheckOperationSeq(
  aLogger: TLogger;
  anOperation: TCalcOperation);
var
  l_Index : Integer;
begin
  RandSeed := 40000;
  aLogger.OpenTest(Self);
  for l_Index := 0 to 10000 do
    CheckOperation(aLogger,
                   1000 * Random,
                   2000 * Random + 1, anOperation);
  CheckTrue(aLogger.CheckWithEtalon);
end;


procedure TCalculatorOperationRandomSequenceTest.CheckOperation(
  aLogger: TLogger;
  aX1, aX2: Double;
  anOperation : TCalcOperation);
begin
  aLogger.ToLog(aX1);
  aLogger.ToLog(aX2);
  aLogger.ToLog(anOperation(FloatToStr(aX1),FloatToStr(aX2)));
end;

procedure TCalculatorOperationRandomSequenceTest.TestDiv;
begin
  CheckOperationSeq(g_Logger, TCalculator.Divide);
end;

procedure TCalculatorOperationRandomSequenceTest.TestSub;
begin
  CheckOperationSeq(g_Logger, TCalculator.Sub);
end;

procedure TCalculatorOperationRandomSequenceTest.TestMul;
begin
  CheckOperationSeq(g_Logger, TCalculator.Mul);
end;

procedure TCalculatorOperationRandomSequenceTest.TestAdd;
begin
  CheckOperationSeq(g_Logger, TCalculator.Add);
end;

procedure TCalculatorOperationRandomSequenceTest.TestDivInt;
begin
  CheckOperationSeq(g_Logger, TCalculator.DivInt);
end;

initialization
 TestFramework.RegisterTest(TCalculatorOperationRandomSequenceTest.Suite);
end.
unit DivIntTest;

interface

uses
  OperationTest
  ;

type
  TDivIntTest = class(TOperationTest)
   protected
    function  GetOp: TOperation; override;
  end;//TPlusTest

implementation

uses
  TestFrameWork,
  SysUtils
  ;

function TDivIntTest.GetOp: TOperation;
begin
 Result := opDivInt;
end;

initialization
 TestFramework.RegisterTest(TDivIntTest.Suite);

end.
unit OperationTest;

interface

uses
  CalculatorGUITest,
  MainForm
  ;

type
  TOperation = (opAdd, opMinus, opMul, opDiv, opDivInt);

  TOperationTest = class(TCalculatorGUITest)
   protected
    procedure VisitForm(aForm: TfmMain); override;
    function  GetOp: TOperation; virtual; abstract;
    function  GetFirstParam: Single; virtual;
    function  GetSecondParam: Single; virtual;
  end;//TOperationTest

implementation

uses
  TestFrameWork,
  Calculator,
  SysUtils
  ;

function TOperationTest.GetFirstParam: Single;
begin
 Result := 10;
end;

function TOperationTest.GetSecondParam: Single;
begin
 Result := 20;
end;

procedure TOperationTest.VisitForm(aForm: TfmMain);
var
 aA, aB : Single;
begin
 aA := GetFirstParam;
 aB := GetSecondParam;
 aForm.edtFirstArg.Text := FloatToStr(aA);
 aForm.edtSecondArg.Text := FloatToStr(aB);
 case GetOp of
  opAdd:
  begin
   aForm.btnAdd.Click;
   Check((aForm.edtResult.Text) = TCalculator.FloatToStr(aA + aB));
  end;
  opMinus:
  begin
   aForm.btnMinus.Click;
   Check((aForm.edtResult.Text) = TCalculator.FloatToStr(aA - aB));
  end;
  opMul:
  begin
   aForm.btnMul.Click;
   Check((aForm.edtResult.Text) = TCalculator.FloatToStr(aA * aB));
  end;
  opDiv:
  begin
   aForm.btnDiv.Click;
   Check((aForm.edtResult.Text) = TCalculator.FloatToStr(aA / aB));
  end;
  opDivInt:
  begin
   aForm.btnDivInt.Click;
   Check((aForm.edtResult.Text) = TCalculator.FloatToStr(Round(aA) div Round(aB)));
  end;
 end;//case GetOp
end;

end.

Подведём итоги.
Мы разобрали вполне реальную ситуацию, и показали как идёт разработка через тестирование с использованием "классического TDD". Столкнулись с вполне реальной проблемой. Решение которой кстати лежит не только в тех вариантах которые мы тут показали, но так как мы будем менять архитектуру наших тестов в будущем, то читатели увидят и другие решения. В целом я думаю что наш продукт готов к приемке у заказчика, а я смогу спать спокойно не опасаясь ночных звонков :).

По мере написания статьи я решил перечитать те материалы которые описывают технику разработки TDD. Особо хотелось бы выделить лучшие ссылки:
Интервью Всеволода Леонова с Александром Люлиным.
Как не выстрелить себе в ногу.
TDD это как сноубординг.
TDD для начинающих. Ответы на популярные вопросы.
Википедия.

Ссылка на исходники.

Комментариев нет:

Отправить комментарий