Оглавление всей серии постов о тестировании калькулятора.
Нарисовав диаграмму классов к прошлой главе, я заметил что у меня есть класс TRandomPlusTest который я не видел в GUI DUnit'a.
Взглянув на исходник видим что наш класс не зарегистрирован в DUnit. Убираем комментарий и запускаем.
Как видим наш тест не прошел. Взглянем на детали. Выделим наш тест, и запустим несколько раз.Видим что наш "случайный" тест, падает не всегда.
Предлагаю читателям вспомнить иерархию классов для тестирования GUI.
Первым делом, взглянем на TFirstTest. Он напрямую унаследован от TTestCase, и выполняет один метод DoIt.
Первый тест, нужен нам для "проверки работы инфраструктуры". В данном случае, после запуска DoIt, мы точно знаем что наш тест зарегистрирован, и что он проходит.
Далее начинается, более веселая архитектура.
DUnit запускает только published процедуры(такие уж особенности), в которых собственно и делается проверка. Рассмотрим поближе наш следующий(первый потомок TTestCase) класс TCalculatorGUITest:
Как видим здесь есть единственная published процедура DoIt. Которая собственно и будет выполняться для всех наследников. Вызывая при этом абстрактную процедуру VisitForm. Которую нам предстоит написать в наследнике.
Отдельно хочу обратить внимание на то что мы не регистрируем наш класс в DUnit.
Далее идет класс TOperationTest. В котором реализовано посещение форм(protected), однако он так же не регистрируется в фреймворке тестирования:
Наконец-то мы дошли до собственно тестов. В тестах, например в TPlusTest, мы всего лишь определяем нужный нам метод GetOp. НО !!!
Здесь мы регистрируем наш тест в DUnit.
Всё что мы делаем дальше для нашего "псевдослучайного" теста, так это переопределяем процедуры получения параметров(GetFirstParam, GetSecondParam) и регистрируемся, в DUnit:
После рассмотрения архитектуры, вернемся к нашему "провалу". Как видим из кода выше, для нашего random теста, мы берём "любые" 2 числа(TOperationTest.VisitForm), выполняем над ними операцию, через ButtonClick, а далее сравниваем с результатом сложения переведенным в строку.
Конечно же здесь не всегда будет равенство. Всё дело здесь в том что многие дробные десятичные числа не могут быть точно представлены с помощью нулей и единиц, используемых в цифровом компьютере.
И тут мы наконец-то добираемся до сути нашей статьи.
Сравнение чисел с плавающей запятой.
Об этой проблеме писали не раз. Я впервые об этом узнал из Совершенного кода(с. 287. 12.3. Числа с плавающей запятой) Стива Макконнелла, хотя в работе так ни разу и не сталкивался. Пример описанный Стивом актуален до сих пор:
program DoubleEqualsExample;
Результатом работы программы будет:
Если мы пойдем по "стопам мастера" то следующим шагом, выведем значение sum в момент каждой итерации:
Как видим хоть делфи и округляет нашу сумму до единицы, при финальном выводе, на самом деле, число другое.
Ну а дальше дело техники. Так как Макконнелла, читал не только я, но и создатели Delphi. То конечно же вариант сравнения чисел с плавающей запятой был учтен.
В юните Math.pas есть следующие процедуры для сравнения:
- SomeValue
- CompareValue
- IsZero
Все три функции предназначены, для сравнения, с определённой точностью стравнения Epsilon. Которую пользователь задает самостоятельно. Проверим на нашем примере:
Исходный код SomeValue:
Изменим сравнение для нашего Random теста, напоминаю что он унаследован от TPlusTest:
После запуска теста(несколько раз), убеждаемся что всё ок:
По аналогии добавим random тестов GUI для всех операций. В следствии того что код практически одинаков с TRandomTest, я его приводить не буду.
Запускаем все тесты:
Все тесты кроме целочисленного деления провалились. Поправим код VisitForm, учитывая "сравнение":
Как видим, у нас осталась одна проблема с тестом на умножение. Если мы умножим "random числа" из нашего приложения в калькуляторе Windows.
То увидим, что ошибка в погрешности будет составлять 1/10.
Приведём сравнение операций, для умножения к нужной погрешности:
Подведём итоги:.
Числа с плавающей запятой, не всегда будут одинаковы. Даже если визуально, они будут выглядеть идентично.
Большинство решений проблем уже заложено в стандартных библиотеках, поэтому не спешите выдумывать свой велосипед. RTFM :)
В случае с умножением двух double, уточните у заказчика точность расчетов.
Ещё немного о архитектуре наших тестов GUI. Финальная диаграмма выглядит так:
TCalculatorGUITest регистрирует в DUnit процедуру DoIt для всех потомков, которая собственно и начинает процедуру тестирования. TOperationTest является по сути абстрактным классом, однако содержит в себе всю логику проверки операций. Классы - TPlusTest, TMinusTest, ..., etc. Регистрируются в DUnit и благодаря механизму наследования являют собой конечные тесты. Хотя логика "проверки верности" и находится у предка. Все Random'ные тесты, являют собой расширенный вариант обычных тестов, однако благодаря перегрузке операций GetFirstParam и GetSecondParam, могут выступать в частном случае. В данной ситуации, каждый класс реализует псевдослучайные входные данные.
Ссылка на репозиторий.
p.s.
Полезные линки:
http://mat.net.ua/mat/biblioteka/McKraken-Dorn-Chislennie-metodi.djvu http://stackoverflow.com/questions/6106119/how-to-compare-double-in-delphi
Нарисовав диаграмму классов к прошлой главе, я заметил что у меня есть класс TRandomPlusTest который я не видел в GUI DUnit'a.
unit RandomPlusTest; interface uses PlusTest ; type TRandomPlusTest = class(TPlusTest) protected function GetFirstParam: Single; override; function GetSecondParam: Single; override; end;//TRandomPlusTest implementation uses TestFrameWork, SysUtils ; function TRandomPlusTest.GetFirstParam: Single; begin Result := 1000 * Random; end; function TRandomPlusTest.GetSecondParam: Single; begin Result := 2000 * Random; end; initialization //TestFramework.RegisterTest(TRandomPlusTest.Suite); end.
Взглянув на исходник видим что наш класс не зарегистрирован в DUnit. Убираем комментарий и запускаем.
Как видим наш тест не прошел. Взглянем на детали. Выделим наш тест, и запустим несколько раз.Видим что наш "случайный" тест, падает не всегда.
Предлагаю читателям вспомнить иерархию классов для тестирования GUI.
Первым делом, взглянем на TFirstTest. Он напрямую унаследован от TTestCase, и выполняет один метод DoIt.
unit FirstTest; interface uses TestFrameWork ; type TFirstTest = class(TTestCase) published procedure DoIt; end;//TFirstTest implementation procedure TFirstTest.DoIt; begin Check(true); end; initialization TestFramework.RegisterTest(TFirstTest.Suite); end.
Первый тест, нужен нам для "проверки работы инфраструктуры". В данном случае, после запуска DoIt, мы точно знаем что наш тест зарегистрирован, и что он проходит.
Далее начинается, более веселая архитектура.
DUnit запускает только published процедуры(такие уж особенности), в которых собственно и делается проверка. Рассмотрим поближе наш следующий(первый потомок TTestCase) класс TCalculatorGUITest:
unit CalculatorGUITest; interface uses TestFrameWork, MainForm ; type TCalculatorGUITest = class(TTestCase) protected procedure VisitForm(aForm: TfmMain); virtual; abstract; published procedure DoIt; end;//TCalculatorGUITest implementation uses Forms ; procedure TCalculatorGUITest.DoIt; var l_Index : Integer; begin for l_Index := 0 to Screen.FormCount do if (Screen.Forms[l_Index] Is TfmMain) then begin VisitForm(Screen.Forms[l_Index] As TfmMain); break; end;//Screen.Forms[l_Index] Is TfmMain end; end.
Как видим здесь есть единственная published процедура DoIt. Которая собственно и будет выполняться для всех наследников. Вызывая при этом абстрактную процедуру VisitForm. Которую нам предстоит написать в наследнике.
Отдельно хочу обратить внимание на то что мы не регистрируем наш класс в DUnit.
Далее идет класс TOperationTest. В котором реализовано посещение форм(protected), однако он так же не регистрируется в фреймворке тестирования:
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.
Наконец-то мы дошли до собственно тестов. В тестах, например в TPlusTest, мы всего лишь определяем нужный нам метод GetOp. НО !!!
Здесь мы регистрируем наш тест в DUnit.
unit PlusTest; interface uses OperationTest ; type TPlusTest = class(TOperationTest) protected function GetOp: TOperation; override; end;//TPlusTest implementation uses TestFrameWork, SysUtils ; function TPlusTest.GetOp: TOperation; begin Result := opAdd; end; initialization TestFramework.RegisterTest(TPlusTest.Suite); end.
Всё что мы делаем дальше для нашего "псевдослучайного" теста, так это переопределяем процедуры получения параметров(GetFirstParam, GetSecondParam) и регистрируемся, в DUnit:
unit RandomPlusTest; interface uses PlusTest ; type TRandomPlusTest = class(TPlusTest) protected function GetFirstParam: Single; override; function GetSecondParam: Single; override; end;//TRandomPlusTest implementation uses TestFrameWork, SysUtils ; function TRandomPlusTest.GetFirstParam: Single; begin Result := 1000 * Random; end; function TRandomPlusTest.GetSecondParam: Single; begin Result := 2000 * Random; end; initialization TestFramework.RegisterTest(TRandomPlusTest.Suite); end.
После рассмотрения архитектуры, вернемся к нашему "провалу". Как видим из кода выше, для нашего random теста, мы берём "любые" 2 числа(TOperationTest.VisitForm), выполняем над ними операцию, через ButtonClick, а далее сравниваем с результатом сложения переведенным в строку.
Конечно же здесь не всегда будет равенство. Всё дело здесь в том что многие дробные десятичные числа не могут быть точно представлены с помощью нулей и единиц, используемых в цифровом компьютере.
И тут мы наконец-то добираемся до сути нашей статьи.
Сравнение чисел с плавающей запятой.
Об этой проблеме писали не раз. Я впервые об этом узнал из Совершенного кода(с. 287. 12.3. Числа с плавающей запятой) Стива Макконнелла, хотя в работе так ни разу и не сталкивался. Пример описанный Стивом актуален до сих пор:
program DoubleEqualsExample;
{$APPTYPE CONSOLE} {$R *.res} uses System.SysUtils; var nominal, sum : double; i: byte; begin nominal := 1.0; sum := 0; for I := 1 to 10 do sum := sum + 0.1; if sum = nominal then Writeln('Equals sum=' + FloatToStr(sum) + ' nominal=' + FloatToStr(nominal)) else Writeln('NOT Equals sum=' + FloatToStr(sum) + ' nominal=' + FloatToStr(nominal)); Readln; end.
Результатом работы программы будет:
Если мы пойдем по "стопам мастера" то следующим шагом, выведем значение sum в момент каждой итерации:
Как видим хоть делфи и округляет нашу сумму до единицы, при финальном выводе, на самом деле, число другое.
Ну а дальше дело техники. Так как Макконнелла, читал не только я, но и создатели Delphi. То конечно же вариант сравнения чисел с плавающей запятой был учтен.
В юните Math.pas есть следующие процедуры для сравнения:
- SomeValue
- CompareValue
- IsZero
Все три функции предназначены, для сравнения, с определённой точностью стравнения Epsilon. Которую пользователь задает самостоятельно. Проверим на нашем примере:
... if SameValue(sum, nominal, 0.00000001) then Writeln('Equals sum=' + FloatToStr(sum) + ' nominal=' + FloatToStr(nominal)) else Writeln('NOT Equals sum=' + FloatToStr(sum) + ' nominal=' + FloatToStr(nominal)); ...Результатом будет:
Исходный код SomeValue:
function SameValue(const A, B: Double; Epsilon: Double): Boolean; begin if Epsilon = 0 then Epsilon := Max(Min(Abs(A), Abs(B)) * DoubleResolution, DoubleResolution); if A > B then Result := (A - B) <= Epsilon else Result := (B - A) <= Epsilon; end;
Изменим сравнение для нашего Random теста, напоминаю что он унаследован от TPlusTest:
... const c_Epsilon = 0.0001; ... opAdd: begin aForm.btnAdd.Click; Check(SameValue(StrToFloat(aForm.edtResult.Text), (aA + aB), c_Epsilon)); end; ...
После запуска теста(несколько раз), убеждаемся что всё ок:
По аналогии добавим random тестов GUI для всех операций. В следствии того что код практически одинаков с TRandomTest, я его приводить не буду.
Запускаем все тесты:
Все тесты кроме целочисленного деления провалились. Поправим код VisitForm, учитывая "сравнение":
Как видим, у нас осталась одна проблема с тестом на умножение. Если мы умножим "random числа" из нашего приложения в калькуляторе Windows.
То увидим, что ошибка в погрешности будет составлять 1/10.
Приведём сравнение операций, для умножения к нужной погрешности:
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, Math; const c_Epsilon = 0.0001; c_MulEpsilon = 0.1; 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(SameValue(StrToFloat(aForm.edtResult.Text), (aA + aB), c_Epsilon)); end; opMinus: begin aForm.btnMinus.Click; Check(SameValue(StrToFloat(aForm.edtResult.Text), (aA - aB), c_Epsilon)); end; opMul: begin aForm.btnMul.Click; Check(SameValue(StrToFloat(aForm.edtResult.Text), (aA * aB), c_MulEpsilon)); end; opDiv: begin aForm.btnDiv.Click; Check(SameValue(StrToFloat(aForm.edtResult.Text), (aA / aB), c_Epsilon)); end; opDivInt: begin aForm.btnDivInt.Click; Check(SameValue(StrToFloat(aForm.edtResult.Text), (Round(aA) div Round(aB)), c_Epsilon)); end; end;//case GetOp end; end.
Подведём итоги:.
Числа с плавающей запятой, не всегда будут одинаковы. Даже если визуально, они будут выглядеть идентично.
Большинство решений проблем уже заложено в стандартных библиотеках, поэтому не спешите выдумывать свой велосипед. RTFM :)
В случае с умножением двух double, уточните у заказчика точность расчетов.
Ещё немного о архитектуре наших тестов GUI. Финальная диаграмма выглядит так:
TCalculatorGUITest регистрирует в DUnit процедуру DoIt для всех потомков, которая собственно и начинает процедуру тестирования. TOperationTest является по сути абстрактным классом, однако содержит в себе всю логику проверки операций. Классы - TPlusTest, TMinusTest, ..., etc. Регистрируются в DUnit и благодаря механизму наследования являют собой конечные тесты. Хотя логика "проверки верности" и находится у предка. Все Random'ные тесты, являют собой расширенный вариант обычных тестов, однако благодаря перегрузке операций GetFirstParam и GetSecondParam, могут выступать в частном случае. В данной ситуации, каждый класс реализует псевдослучайные входные данные.
Ссылка на репозиторий.
p.s.
Полезные линки:
http://mat.net.ua/mat/biblioteka/McKraken-Dorn-Chislennie-metodi.djvu http://stackoverflow.com/questions/6106119/how-to-compare-double-in-delphi
Прислали тут - "Вот только про плавающую точку как то коряво. Напрочь попутаны сингл с даблом. И жестко 0,1 не прокатит. Результат в пределх точности сингла - 7-8 значащих цифр."
ОтветитьУдалить@Ingword - парируете?
P.S. Про Single это Я конечно - "попутал". Надо все Single на Double заменить.
УдалитьКазалось бы - "тривиальный калькулятор"... А СКОЛЬКО "тем для обсуждения" вытягивается "на свет"... А БУДЕТ ещё БОЛЬШЕ... Например "что делать с ТЗ"...
ОтветитьУдалитьИ - ОТДЕЛЬНО - "что делать с ТЗ" - когда заказчик "куда-то подевался".. Т.е. он "то ли есть", а то ли "нет"...
УдалитьАлександр, спасибо за серию статей!
ОтветитьУдалитьХотел бы предложить вам дополнить информацию базовыми сведениями об dunit. Например, я только озадачился, дозрел, до понимания значимости такого рода тестирования, а "простой и доступной" вводной информации днем с огнем не сыщешь.
Всегда пожалуйста :-)
ОтветитьУдалитьОбращаю внимание, что статью писал не я, а @Ingword.
Статью про DUnit скорее всего будет писать тоже он.
SomeValue понравилась, взял на вооружение!
ОтветитьУдалить