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

Тестируем калькулятор №7. Сравнение чисел с плавающей запятой. Детальнее об архитектуре тестов

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

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

  1. Прислали тут - "Вот только про плавающую точку как то коряво. Напрочь попутаны сингл с даблом. И жестко 0,1 не прокатит. Результат в пределх точности сингла - 7-8 значащих цифр."

    @Ingword - парируете?

    ОтветитьУдалить
    Ответы
    1. P.S. Про Single это Я конечно - "попутал". Надо все Single на Double заменить.

      Удалить
  2. Казалось бы - "тривиальный калькулятор"... А СКОЛЬКО "тем для обсуждения" вытягивается "на свет"... А БУДЕТ ещё БОЛЬШЕ... Например "что делать с ТЗ"...

    ОтветитьУдалить
    Ответы
    1. И - ОТДЕЛЬНО - "что делать с ТЗ" - когда заказчик "куда-то подевался".. Т.е. он "то ли есть", а то ли "нет"...

      Удалить
  3. Анонимный30 июня 2014 г., 23:47

    Александр, спасибо за серию статей!
    Хотел бы предложить вам дополнить информацию базовыми сведениями об dunit. Например, я только озадачился, дозрел, до понимания значимости такого рода тестирования, а "простой и доступной" вводной информации днем с огнем не сыщешь.

    ОтветитьУдалить
  4. Всегда пожалуйста :-)

    Обращаю внимание, что статью писал не я, а @Ingword.

    Статью про DUnit скорее всего будет писать тоже он.

    ОтветитьУдалить
  5. Анонимный1 июля 2014 г., 12:39

    SomeValue понравилась, взял на вооружение!

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