четверг, 29 мая 2014 г.

Тестируем калькулятор № 6.1. Тестирование с использованием эталонов

Глава 0.
Глава 1.
Глава 2.
Глава 3.
Глава 4.
Глава 5.

В прошлой главе мы перешли от тестирования GUI формы, к тестированию бизнес-логики, выделив класс бизнес логики TCalculator.

В этой главе мы обсудим и внедрим “Тестирование с использованием эталонов”. Тестирование с использованием эталонов строится на базе тестов из прошлой главы. Однако выполняет более важную функцию, о чем будет рассказано далее. В двух словах -использование эталонов предполагает сохранение значений и результата теста в файл, который мы затем сравниваем с эталонным. Если файлы не совпадают то тест “провалился”. Тут возникает вопрос откуда мы возьмем эталонный файл? И здесь у нас 2 варианта: Либо мы его создадим руками, либо как поступил я - если эталона не существует, то мы создаем его автоматически на основе файла результата тестирования, так как допускаем что тесты у нас заведомо правильные.

Постараюсь более детально объяснить на примере написания эталонного теста к операции + или как она записана у нас в калькуляторе функция ADD.

Для начала напишем новый класс TCalculatorOperationViaEtalonTest, который очень похож на наш предыдущий класс TCalculatorOperationTest. Однако в этот раз мы проверяем не логику, а соответствие с эталонным файлом.

type
  TCalculatorOperationViaEtalonTest = class(TTestCase)
   published
    procedure TestDiv;
    procedure TestMul;
    procedure TestAdd;
    procedure TestSub;
  end;//TCalculatorOperationViaEtalonTest

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

  CheckTrue(AddArgumentsToLog(g_Logger, x1, x2, TCalculator.Add(x2, x1), Self));
end;

Для сохранения результатов тестирования в файл и сравнения с эталоном, введём дополнительный класс TLogger который будем использовать через глобальную переменную g_Logger.

 
unit Tests.Logger;

interface

uses
  TestFrameWork;

const
  cEtalonSuffix = '.etalon';
  cTestSuffix = '.out';
  cTestFolder = 'TestSet';

type
  TLogger = class
  strict private
    FTestFile : TextFile;
    FTestFilePath,
    FEtalonFilePath : string;
  private
    function TestOutputFolderPath: string;
    function Is2FilesEqual(const aFilePathTest, aFilePathEtalon: string): Boolean;
    function IsExistEtalonFile: Boolean;
  public
    class constructor Create;
    class destructor Destroy;
    procedure OpenTest(aTestCase: TTestCase);
    procedure ToLog(const aParametr: string);
    function CheckWithEtalon: Boolean;
  end;//TLogger

var
  g_Logger : TLogger;

implementation

uses
  SysUtils,
  System.Classes,
  Winapi.Windows;

{ TLogger }

function TLogger.CheckWithEtalon: Boolean;
begin
  Assert(FTestFilePath<>'');
  Assert(FEtalonFilePath<>'');

  CloseFile(FTestFile);

  if IsExistEtalonFile then
    Result := Is2FilesEqual(FTestFilePath, FEtalonFilePath)
  else
    Result := CopyFile(PWideChar(FTestFilePath),PWideChar(FEtalonFilePath),True);
end;

class destructor TLogger.Destroy;
begin
  FreeAndNil(g_Logger);
end;

class constructor TLogger.Create;
begin
  g_Logger := TLogger.Create;
end;

function TLogger.Is2FilesEqual(const aFilePathTest,
                                     aFilePathEtalon: string): Boolean;
var
  l_msFileTest, l_msFileEtalon: TMemoryStream;
begin
  Result := False;
  l_msFileTest := TMemoryStream.Create;
  try
    l_msFileTest.LoadFromFile(aFilePathTest);
    l_msFileEtalon := TMemoryStream.Create;
    try
      l_msFileEtalon.LoadFromFile(aFilePathEtalon);
      if l_msFileTest.Size = l_msFileEtalon.Size then
        Result := CompareMem(l_msFileTest.Memory, l_msFileEtalon.memory, l_msFileTest.Size);
    finally
      FreeAndNil(l_msFileEtalon);
    end;
  finally
    FreeAndNil(l_msFileTest);
  end
end;

function TLogger.IsExistEtalonFile: Boolean;
begin
  Result:= FileExists(FEtalonFilePath);
end;

procedure TLogger.OpenTest(aTestCase: TTestCase);
var
  l_FileName : string;
begin
  l_FileName := aTestCase.ClassName + aTestCase.GetName;
  FTestFilePath := TestOutputFolderPath + l_FileName + cTestSuffix;
  FEtalonFilePath := TestOutputFolderPath + l_FileName + cEtalonSuffix;

  if not DirectoryExists(TestOutputFolderPath) then
    ForceDirectories(TestOutputFolderPath);

  AssignFile(FTestFile, FTestFilePath);
  Rewrite(FTestFile);
end;

function TLogger.TestOutputFolderPath: string;
begin
  Result := ExtractFilePath(ParamStr(0)) + cTestFolder + '\'
end;

procedure TLogger.ToLog(const aParametr: string);
begin
  Writeln(FTestFile, aParametr + ' ');
end;

end.

Таким образом после выполнения тестирования в первый раз мы получим на выходе два файла: 
- TCalculatorOperationViaEtalonTestTestAdd.out файл который формирует "наш тест"
- TCalculatorOperationViaEtalonTestTestAdd.etalon файл который стал(ещё раз подчеркну что мы предполагаем что наши тесты верны) эталоном. Этот эталон мы фиксируем в GIT.

Формат файлов .out и .etalon для операции "Плюс":

5 -- первый аргумент
10 -- второй аргумент
15 -- результат

Полный код нового класса TCalculatorOperationViaEtalonTest:
unit CalculatorOperationViaEtalonTest;

interface

uses
  TestFrameWork,
  Calculator
  ;

 type
  TCalculatorOperationViaEtalonTest = class(TTestCase)
   published
    procedure TestDiv;
    procedure TestMul;
    procedure TestAdd;
    procedure TestSub;
  end;//TCalculatorOperationViaEtalonTest

implementation

  uses
    SysUtils,
    Tests.Logger;

const
 cA = '5';
 cB = '10';
{ TCalculatorOperationViaEtalonTest }
function AddArgumentsToLog(aLogger: TLogger;
                           aX1, aX2, aResult: string;
                           aTestCase: TTestCase): Boolean;
begin
  aLogger.OpenTest(aTestCase);
  aLogger.ToLog(aX1);
  aLogger.ToLog(aX2);
  aLogger.ToLog(aResult);
  Result := aLogger.CheckWithEtalon;
end;

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

  CheckTrue(AddArgumentsToLog(g_Logger, x1, x2, TCalculator.Divide(x2, x1), Self));
end;

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

  CheckTrue(AddArgumentsToLog(g_Logger, x1, x2, TCalculator.Sub(x2, x1), Self));
end;

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

  CheckTrue(AddArgumentsToLog(g_Logger, x1, x2, TCalculator.Mul(x2, x1), Self));
end;

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

  CheckTrue(AddArgumentsToLog(g_Logger, x1, x2, TCalculator.Add(x2, x1), Self));
end;

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


Ну а теперь самое интересное, зачем нам собственно нужны эталонные тесты.
1. Так как эталонный тест зафиксирован в GIT то мы всегда сможем узнать не сломалось ли у нас что то по ходу новых изменений.
2. "Наиболее" главное. В больших командах разработка поделена на разработчиков и тестировщиков. И если меняется логика программы, например функция Плюс стала не просто складывать 2 числа, а складывать 2 числа и добавлять к ним 8, то мы легко увидим этот факт.
class function TCalculator.Add(const A, B: string): string;
var
  x1, x2, x3 : single;
begin
  x1 := StrToFloat(A);
  x2 := StrToFloat(B);
  x3 := x1 + x2 + 8;
  Result := FloatToStr(x3);
end;

Теперь если мы запустим наше тестирование то выглядеть оно будет так:
То есть все наши тесты "Плюса" упали. И соответственно разработчику надо переделать 3 теста. Однако давайте попробуем просто подправить эталон.

Было:
5 10 15

Стало:

10 
23


И теперь:

Как видим тесты с эталонами выполнились успешно. То есть при изменении логики программы изменение тестов могут выполнять и тестировщики, БЕЗ НЕОБХОДИМОСТИ что-то изменять в исходниках. Что нам дает огромную гибкость для применения.

P.S.
При написании статьи пытался обнаружить раскрытие темы в рунете. Тема в рунете неразвита. Однако похожая схема тестирования описана у Кернигана и Пайка. В главе про тестирование. Правда там они предлагают замерять время выполнения теста, и производительность при прохождении. Однако к этому мы дойдем в наших будущих статьях.

Source code.

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

  1. Там есть проблемы с региональными настройками и "запятыми", это кстати повод для отдельного разговора.

    ОтветитьУдалить
    Ответы
    1. function FloatToStrP(const AValue: Extended; ADecSep: Char): string;
      var
      FS: TFormatSettings;
      begin
      GetLocaleFormatSettings(LOCALE_SYSTEM_DEFAULT, FS);
      FS.DecimalSeparator := ADecSep;
      Result := FloatToStr(AValue, FS);
      end;

      //с запятой
      str := FloatToStrP(123.456, ',');
      //с точкой
      str := FloatToStrP(123.456, '.');

      Удалить
    2. Ну "закостылять" то мы закостыляли. Но вообще говоря это повод для отдельного разговора про зависимость тестов от клиентского окружения.

      Удалить
  2. Правда можно посмотреть на проблему "запятой" и в ином разрезе - как на ОШИБКУ, которая пришла от "пользователя", только "пользователем" в этом случае выступает РАЗРАБОТЧИК.

    ОтветитьУдалить
  3. Вы разносите тест в несколько мест. Появляется с одной стороны гибкость - для тестировщика возможность подкорректировать быстро результаты. Но для программиста это может вносить сложность - файл с результатами нужно найти на диске. В C# в фреймворке NUnit есть такое понятие как TestCase - один метод теста можно прогнать множеством параметров или даже подсунуть метод который читает параметры из файлов и т.д. Второй вариант гибче но не нагляднее. Лучше когда код теста полностью перед глазами прост и легок.

    Вы внедрили этот способ тестирования полгода назад. Какие результаты эксперимента? Какие плюсы и минусы? Как изменяется работа тестов когда меняется сигнатура методов?

    ОтветитьУдалить
    Ответы
    1. Мы внедрили не "пол-года" назад. А лет пять назад. А то и больше. И это НЕ "эксперимент", это ПРОМЫШЛЕННОЕ использование.

      Удалить
    2. "Вы разносите тест в несколько мест"
      -- непонятно, что вы имеете в виду.

      Удалить