Оглавление всей серии постов о тестировании калькулятора.
Нарисовав диаграмму классов к прошлой главе, я заметил что у меня есть класс 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 понравилась, взял на вооружение!
ОтветитьУдалить