Pull to refresh

Много тестов не бывает

Reading time 6 min
Views 13K


Некоторое время назад я принял решение потихоньку внедрять в свою практику автоматизированное тестирование и TDD. Признаюсь честно, получалось все это с переменным успехом. Но то, что жить стало гораздо интереснее – это неоспоримый факт. Со мной стали происходить разные приключения. И, как во всех приключениях, иногда становилось немного страшно. Об одном таком случае я и хочу рассказать.

В проекте, в котором я принимал участие, потребовалась плотная работа с временными интервалами от минуты до года. Плох (или наоборот слишком хорош) программист, не написавший в своей жизни ни одну библиотеку работы с датами. Я не хуже и не лучше других, поэтому решил размять мозги и создать немного кода.

С чего все началось


Проект был на Delphi. Решив придерживаться ООП, я написал абстрактный класс TTimeIterator, от которого порождались остальные классы для передвижения с шагом минута, час, день, месяц, год. Идея этих итераторов в том, что получив начальную и конечную точку, они выравнивают их по корректным границам интервалов и позволяют перемещаться четко попадая в нужные моменты времени. Привожу объявление абстрактного класса, без купюр, как он был реализован в проекте.

TTimeIterator=class(TObject)
private
  StartPoint, FinishPoint:TDateTime;
  CurrentPoint:TDateTime;
  CurrentPointNumber:integer;

  function DTRound(p:TDateTime):TDateTime; virtual; abstract;
  function DTNext(p:TDateTime):TDateTime; virtual; abstract;
  function DTPrev(p:TDateTime):TDateTime; virtual; abstract;
public
  constructor Create;overload;
  function Dump:string;
  function GetTotalPoints:integer; virtual; abstract;
  function GetCurrentPointNumber:integer;
  function GetCurrentPoint:TDateTime;
  procedure MoveNextPoint;
  function IsCurrentPoint:boolean;

  procedure SetStartPoint(DateTime:TDateTime;
                          IncludeMode:TTimeIncludeModeType=INCLUDE_MODE);

  procedure SetFinishPoint(DateTime:TDateTime; 
                          IncludeMode:TTimeIncludeModeType=INCLUDE_MODE);
end;

Далее речь пойдет о реализации функции

 function GetTotalPoints:integer; virtual; abstract;

Эта функция после установления начальной и конечной точки позволяет получить общее количество точек, через которые пройдет итератор. Для классической парадигмы итератора она не требуется, но для некоторых операций в проекте такая функция была бы очень полезна.

Как знает всякий, кто работал с датами, интервалы минута, час, день и даже неделя не доставляют особых хлопот. Проблемы начинаются с месяцев. Задача заключалась в том, чтобы реализовать функцию GetTotalPoints в месячном итераторе.

Странная функция MonthsBetween


Без лишних раздумий я нашел в стандартной библиотеке функцию с названием и аргументами, не оставляющими никаких сомнений.

function MonthsBetween(const ANow, AThen: TDateTime): Integer;

Не очень сложные тесты прошли на ура. Какое-то смутное чувство меня заставило написать тесты посложнее. Один из этих тестов поломался, причем на ровном месте.

После небольшого расследования увидел, что по мнению функции MonthsBetween между 01.01.2012 0:00 и 01.05.2012 0:00 не четыре месяца (январь, февраль, март, апрель), а всего лишь 3. Меня это заинтересовало. Как говорится в известной пословице: если ничего не получается, прочитайте документацию. Я открыл Help и с ужасом прочитал:

Call MonthsBetween to obtain the difference, in months, between two TDateTime values. Because months are not all the same length, MonthsBetween returns an approximation based on an assumption of 30.4375 days per month.

Вот так! Функция работает приблизительно, принимая среднюю длину месяца за 30.4375 дней.

Несколько слов в оправдание фирмы Borland


После того, как первый шок прошел, я пораскинул мозгами. Действительно, реализация функции странная. Но логика в этом есть. И в Help на эту логику есть прямые указания. Проблема в том, что при задании некоторых временных точек однозначно определить сколько месяцев между ними прошло трудно. И результат зависит от взгляда на проблему. Что принимать за целый месяц? Сколько целых месяцев прошло между 28 февраля и 31 марта? А между 28 февраля и 28 марта? Почва очень скользкая.

Почему я считаю, что фирма Borland не совсем права


Во-первых, даже если мы не можем корректно определить значение функции для всех точек, то для точек, которые попадают точно в границы месяцев требуемый результат очевиден. И в этих точках функция должна возвращать не противоречащее житейской логике значение.

Второе возражение чисто практического свойства – я не смог придумать ни одного случая, когда функция в том виде, в котором ее сделала фирма Borland, могла бы пригодится. Она не подходит даже для статистики, которая, как известно, любит оперировать средними значениями.

Почему мне стало страшно


Я испугался не из-за фирмы Borland, которая написала столь странно работающую функцию. Тем более, что правильная реализация занимает несколько строк кода.

Я испугался не из-за собственного легкомыслия, когда я включил в код вызов функции толком не прочитав документации.

Я испугался потому, что в большом количестве случаев эта функция возвращает правильные значения, а если возвращает неправильное, оно отличается всего лишь на единицу. Я примерно оценил, как бы выглядело воздействие этой ошибки на систему в целом. Это бы означало, что иногда мы бы получали данные в отчете за предыдущий месяц вместо текущего (а отчеты за различные месяцы и так похожи друг на друга как близнецы).

Я представил, как эта ошибка ловко проходит через забор модульных тестов. Затем преодолевает интеграционное тестирование. Без особого труда преодолевает ручное тестирование и приемо-сдаточные испытания. И система функционирует десятки лет в реальном мире, время от времени выдавая фальшивые данные.

С точки зрения теории надежности мы бы получили плавающий, трудно диагностируемый дефект непонятного происхождения. Сколько таких дефектов присутствует в системах, обеспечивающих нашу безопасность и жизнедеятельность? Я не знаю. Мне просто ПОВЕЗЛО что я обнаружил этот дефект.

Меня немного утешили следующие мысли:

Во-первых: без тестирования я бы точно не обнаружил такого рода ошибку.
Во-вторых: мне вспомнились слова одного из наших политиков. Его спросили: “Правда ли, что России повезло с ценами на нефть”. Он ответил: “Везет дуракам, а мы работаем с утра до вечера”.

Чем все закончилось


Я написал верную, с точки зрения моих требований, функцию,

function DeviceTimeExactMonthsBetween(StartDate, EndDate: TDateTime):integer;
const
  BASE_YEAR=1990;
var
  y1,y2,m1,m2,d1,d2 : word;
  StartMonths, EndMonths:integer;
begin
  DecodeDate(StartDate,y1,m1,d1);
  DecodeDate(EndDate,y2,m2,d2);
  StartMonths:=(y1-BASE_YEAR)*12+m1;
  EndMonths:=(y2-BASE_YEAR)*12+m2;
  Result:=EndMonths-StartMonths;
  if d2<d1 then dec(Result);
  if Result<0 then Result:=0;
end;

а в Unit-тесты наряду с другими добавил следующий код:

CheckNotEquals(
// Headfire function
    DeviceTimeExactMonthsBetween(
           StrToDateTime('01.01.2012'),
           StrToDateTime('01.05.2012')), 
// BorlandFunction
MonthsBetween(
            StrToDateTime('01.01.2012'),
            StrToDateTime('01.05.2012')),      
//Test name
   'Month BorladFailureTest'
);

Хотя фирма Borland уже не существует, кто знает, вдруг функция MonthsBetween когда-нибудь заработает более правильно.

Насколько же мне повезло?


Уже сейчас, когда я решил оформить этот случай в виде статьи, я задался вопросом, а насколько действительно мне повезло обнаружить ошибку. За десяток минут набросал небольшую визуализацию. Ниже привожу текст (кстати, из него видно, как итераторы по датам используются на практике).

procedure TMainForm.DrawFaults(BeginDate,EndDate:TDateTime);
var IteratorForBegin,IteratorForEnd:TTimeIterator;
    x,y,diff:Integer;
    color:TColor;
begin
  IteratorForBegin:=CreateDeviceTimeIterator(MONTH_INTERVAL);
  IteratorForEnd:=CreateDeviceTimeIterator(MONTH_INTERVAL);
  IteratorForBegin.SetStartPoint(BeginDate , INCLUDE_MODE);
  IteratorForBegin.SetFinishPoint(EndDate, INCLUDE_MODE);
  while (IteratorForBegin.IsCurrentPoint) do
    begin
      IteratorForEnd.SetStartPoint(IteratorForBegin.GetCurrentPoint, INCLUDE_MODE);
      IteratorForEnd.SetFinishPoint(EndDate, INCLUDE_MODE);
      while (IteratorForEnd.IsCurrentPoint) do
        begin

          diff:=DeviceTimeExactMonthsBetween(IteratorForBegin.GetCurrentPoint,IteratorForEnd.GetCurrentPoint)
                - MonthsBetween(IteratorForBegin.GetCurrentPoint,IteratorForEnd.GetCurrentPoint);
          color:=clBlack;  // режим паранойи, если точка черная - значит где-то жуткая ошибка
          if diff=0 then color:=clGreen;
          if diff=1 then color:=clRed;
          y:=IteratorForBegin.GetCurrentPointNumber;
          x:=y+IteratorForEnd.GetCurrentPointNumber;
          Canvas.Brush.Color:=color;
          Canvas.Ellipse(10+x*12,10+y*12,20+x*12,20+y*12);
          //для больших интервалов
          // Canvas.Pixels[10+x,10+y]:=color;
          IteratorForEnd.MoveNextPoint;
        end;
      IteratorForBegin.MoveNextPoint;
    end;
end;

Вот картинка отражающая работу функции MonthsBetween в пределах 2012 года (когда я писал проект). Зеленые соответствуют случаям, когда MonthsBetween выдает правильный результат, красным, когда на единицу меньше.


Ситуация не очень хорошая, тем более, что я писал проект во второй половине года. (При тестировании я стараюсь использовать даты как можно ближе к текущей). Большая вероятность что тесты пройдут.

Вот визуализация работы функции в двадцатилетнем диапазоне (2010-2020 годы)


Мы видим, что количество ошибок возрастает и становится примерно 50 на 50. Таким образом, при включении в тесты длинных интервалов вероятность обнаружить ошибку повышается. Что собственно и случилось. Когда я не поленился и взял интервал побольше ошибка вскрылась.

Заключение


Да, конечно. Разумом я понимаю, что для любого проекта существует оптимальное количество тестов и величина тестового покрытия. Это зависит от назначения программы, используемых технологий, отрасли и требований к надежности.
Но когда я смотрю, как стоит на старте ракета-носитель, готовая отправится в космос. Как подлодка, несущая ядерное оружие, задраивает люки и готовится к погружению, уходя в полугодовой поход. Когда я смотрю, как на соседней улице строится новый торговый центр, расчеты на прочность которого проводились на современных кластерах. Когда я это вижу, я всей душой желаю, чтобы тестов было как можно больше, хороших и разных.

Tags:
Hubs:
+30
Comments 15
Comments Comments 15

Articles