В нашей новой главе мы попробуем применить "классическое TDD" при разработке новой операции нашего калькулятора.
Наш заказчик сообщил нам что хочет что-бы калькулятор “умел” делать целочисленное деление. При этом заплатил нам аванс, уточнил что готово это должно быть на завтра, и исчез не дав нам задать не одного вопроса.
Вопросы которые появились у меня:
- Что появляется нового в приложении ?
Ну тут вроде всё понятно, необходимо реализовать операцию div.
- Есть ли граничные условия у функции ?
Так как тут нам ничего не говорили про прошлые функции тоже, а заказчик вроде доволен, делаем по аналогии с предыдущими.
- Есть ли необходимость в обработке “больших” чисел, больше BigInt например ?
Ответ такой же как и на предыдущий вопрос.
- Изменяется ли поведение других функций ?
Так как нам ничего не говорили об этом, пока будем считать что ничего не изменяется.
- Что изменяется в интерфейсе программы, как будет выглядеть кнопка с новой операцией, и где будет находиться ?
Тут мы решили просто добавить кнопку рядом и подписать её DivInt.
Далее зафиксируем наше ТЗ. Мы создадим папку в Google Docs, куда дадим доступ заказчику. Так как Google фиксирует изменения внесенные в документе, мы всегда сможем отследить кто и что менял. Можно было бы ещё добавить файл в GIT.
Ну а теперь приступим непосредственно к кодированию.
1. Написать новый тест. Убедиться что он не проходит
2. Написать код. Убедиться что тест проходит.
3. Запустить все тесты. Убедиться что не поломались старые тесты.
4. Рефакторинг, если он необходим.
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;
Было:
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 для начинающих. Ответы на популярные вопросы.
Википедия.
Ссылка на исходники.
Комментариев нет:
Отправить комментарий