четверг, 19 июня 2014 г.

Тестируем калькулятор № 6.2.2. Обработка неверных данных и граничных условий

Стоило мне написать про ночи без звонков. Как это сразу и случилось. Заказчик прислал такую вот картинку:


В общем, я конечно же не учел деления на ноль.
На мой удивленный вопрос - "А Вы знаете что на 0 делить нельзя ?"
Клиент заявил что - "Необходимо что бы калькулятор писал "Деление на 0" в ответе".




Быстрым ночным решением я просто сделал проверку знаменателя, и в ответ давал необходимое клиенту  - "Деление на 0".
Однако с утра необходимо было привести в порядок наши тесты. А также сделать учет ошибок деления на 0. Почему так получилось ? Мы детально разберём в этой статье.
Александр предложил идти по определенному плану, что мы собственно и будем делать:

1. Залатали ошибку. ОДНУ.
class function TCalculator.Divide(const A, B: string): string;
var
  x1, x2, x3 : single;
begin
  x1 := StrToFloat(A);
  x2 := StrToFloat(B);
  if x2=0 then
  begin
    Result := 'Деление на 0';
    exit;
  end;
  x3 := x1 / x2;
  Result := FloatToStr(x3);
end;

2. Написали тест логики, который её проверяет.
procedure TCalculatorOperationViaLogicTest.TestZeroDivide;
var
  x1, x2: string;
begin
  x1:= cA;
  x2:= '0';
  CheckTrue(c_ZeroDivideMessageError = TCalculator.Divide(x1, x2));
end;

3. Сделали тест с эталонами(здесь и далее всегда когда мы пишем ЭТАЛОНЫ подразумеваем ЭТАЛОНЫ на основе псевдослучайных данных), в котором участвуют ошибочные входные данные. Для начала я хотел бы привести код процедуры которая формирует "псевдослучайную" последовательность, которая работает сейчас:
procedure TCalculatorOperationRandomSequenceTest.CheckOperationSeq(
  aLogger: TLogger;
  anOperation: TCalcOperation);
var
  l_Index : Integer;
begin
  RandSeed := 40000;
  aLogger.OpenTest(Self);
  for l_Index := 0 to 10000 do
    CheckOperation(aLogger,
                   1000 * Random,
                   2000 * Random + 1, anOperation);
  CheckTrue(aLogger.CheckWithEtalon);
end;

Как видим второй аргумент который мы передаем в качестве знаменателя, однозначно никогда не будет нулем. Сделано это было потому-что клиент ничего об этом не написал в ТЗ специально, дабы исключить ошибки на ранних стадиях тестирования. Сегодня пришло время это изменить. Убираем +1.
procedure TCalculatorOperationRandomSequenceTest.CheckOperationSeq(
  aLogger: TLogger;
  anOperation: TCalcOperation);
var
  l_Index : Integer;
begin
  RandSeed := 40000;
  aLogger.OpenTest(Self);
  for l_Index := 0 to 10000 do
    CheckOperation(aLogger,
                   1000 * Random,
                   2000 * Random, anOperation);
  CheckTrue(aLogger.CheckWithEtalon);
end;

Запускаем наши тесты, конечно же все эталоны провалились. Но интересно, не только это. У нас ещё и выпал exception с делением на 0. Удивительно кстати, вероятность не такая уж и большая была. А почему ситуация возникла я объясню чуть позже.


4. Убедились, что он делает что надо.
Удаляем наши эталоны, так как у нас изменился, второй оператор для тестов.

5. Нашли ВТОРУЮ ошибку в DivInt. Предыдущим тестом.
Как видим наши тесты нормально прошли(то есть ЭТАЛОНЫ создались по "новой"), однако осталось исключение деления на 0 при запуске DivInt.

6. Обернули вызов логики из теста в блок try..except. В блоке try..except записали Exception.ClassName в эталон вместо результата. Для ошибочных данных естественно.
Изменяем наш код:
procedure TCalculatorOperationRandomSequenceTest.CheckOperation(
  aLogger: TLogger;
  aX1, aX2: Double;
  anOperation : TCalcOperation);
begin
  aLogger.ToLog(aX1);
  aLogger.ToLog(aX2);
  aLogger.ToLog(anOperation(FloatToStr(aX1),FloatToStr(aX2)));
end;

На:
procedure TCalculatorOperationRandomSequenceTest.CheckOperation(
  aLogger: TLogger;
  aX1, aX2: Double;
  anOperation : TCalcOperation);
begin
  aLogger.ToLog(aX1);
  aLogger.ToLog(aX2);
  try
    aLogger.ToLog(anOperation(FloatToStr(aX1),FloatToStr(aX2)));
  except
    on E : Exception do
      aLogger.ToLog(E.ClassName);
  end;
end;

После чего наши тесты все "зелёные", хотя при "запуске в отладке" и появляется Exception о котором я уже упоминал.

Когда я "полез" в эталоны разобраться в чем-же дело, я вспомнил о ТЗ.
TCalculatorOperationRandomSequenceTestTestDivInt.etalon
...
277.833182131872 
0.400131568312645 
EDivByZero 
...

Помните в прошлой главе я писал, о том что заказчик не рассказал что нам делать с вещественными числами при операции DivInt ? И вот что из этого получилось. Напомню код, которым мы решаем проблемы с вещественными числами:
class function TCalculator.DivInt(const A, B: string): string;
var
  x1, x2, x3  : Integer;
begin
  x1 := round(StrToFloat(A));
  x2 := round(StrToFloat(B));
  x3 := x1 div x2;
  Result := FloatToStr(x3);
end;

Так как мы округляем числа до целых. У нас появилось исключение при делении на 0. Меняем наш код на этот:
class function TCalculator.DivInt(const A, B: string): string;
var
  x1, x2, x3  : Integer;
begin
  x1 := round(StrToFloat(A));
  x2 := round(StrToFloat(B));
  try
    x3 := x1 div x2;
  except
    on EDivByZero do
    begin
      Result:= c_ZeroDivideMessageError;
      Exit;
    end;
  end;
  Result := FloatToStr(x3);
end;

После запуска тестов придется удалить эталон для DivInt, так как изменилась бизнес-логика программы. При всём этом хотел бы особо подчеркнуть разницу в обработке исключений в бизнес-логике и тестировании. В бизнес-логике мы проверяем "деление на 0" и выдаем соответствующий результат, то есть выполняем пожелание клиента. А в тестировании мы проверяем все варианты, и записываем соответствующий exception в файл "эталона". Зачем мы делаем именно так будет рассказано в будущих главах, сейчас скажу лишь что представьте как поведет себя программа когда мы дадим числа больше extended...

7. Написали тест логики DivInt.
По поводу этого пункта у меня с Александром возник спор на тему что является первичным.
Тестирование логики или запуск Random эталонов, с вероятностью 1 к 10000 что ошибка проявит себя. однако эта темя для другого поста :).

Комментарий от Александра -
" Как у нас говорят - "два ЮРИСТА - три мнения", с программистами - веселее - "два ПРОГРАММИСТА - пять с половиной мнений :-)")

Итак пишем тест логики для деления на 0 для DivInt:
procedure TCalculatorOperationViaLogicTest.TestZeroDivInt;
var
  x1, x2: string;
begin
  x1:= cA;
  x2:= '0';
  CheckTrue(c_ZeroDivideMessageError = TCalculator.DivInt(x1, x2));
end;

Запускаем наши тесты, и видим что всё ок, так как мы поправили логику ещё на прошлом шаге.

Как видим остальные шаги нам не понадобились потому, что мы их предприняли ещё в предыдущем пункте.
8. ПОПРАВИЛИ ВТОРУЮ ошибку. В DivInt.
9. Прогнали тесты с эталонами. Они не сойдутся для DivInt.
10. Пересоздали эталоны.

Нам остался последний шаг, это явно проверить нашу операцию для деления при занменателе 0. Одним из простых решений сделать это. Явно задавать 0 каждое сотое число:
procedure TCalculatorOperationRandomSequenceTest.CheckOperationSeq(
  aLogger: TLogger;
  anOperation: TCalcOperation);
var
  l_Index : Integer;
  x1, x2 : single;
begin
  RandSeed := 40000;
  aLogger.OpenTest(Self);
  for l_Index := 0 to 10000 do
  begin
    x1 := 1000 * Random;
    x2 := 2000 * Random;
    if (l_Index mod 100) = 0 then
      x2 := 0;
    CheckOperation(aLogger,
                   x1,
                   x2, anOperation);
  end;
  CheckTrue(aLogger.CheckWithEtalon);
end;

Запускаем:

Как видим опять упали все наши эталоны. Удаляем старые и опять прогоняем тесты, результаты фиксируем в git. Заодно поправим наше ночное решение, на проверку исключений:
class function TCalculator.Divide(const A, B: string): string;
var
  x1, x2, x3 : single;
begin
  x1 := StrToFloat(A);
  x2 := StrToFloat(B);
  try
    x3 := x1 / x2;
  except
    on EDivByZero do
    begin
      Result:= c_ZeroDivideMessageError;
      Exit;
    end;
  end;
  x3 := x1 / x2;
  Result := FloatToStr(x3);
end;

На этом я хотел заканчивать этот пост. Однако диалоги с Александром, вывели дополнение, которое я бы пока не заметил.
Так как мы дважды проверяем исключения. А в бизнес-логике мы определяем только то исключение, о котором "мы в курсе". То было бы не плохо, по концовке теста(Эталонно-Рандомного) проверять что у нас ни одно исключение не возникло.

Благодаря архитектуре тестов, кода будет не много. Меняем процедуру запуска "последовательности":
procedure TCalculatorOperationRandomSequenceTest.CheckOperation(
  aLogger: TLogger;
  aX1, aX2: Double;
  anOperation : TCalcOperation);
var
  l_ExceptionCount: integer;
begin
  aLogger.ToLog(aX1);
  aLogger.ToLog(aX2);
  l_ExceptionCount:= 0;
  try
    aLogger.ToLog(anOperation(FloatToStr(aX1),FloatToStr(aX2)));
  except
    on E : Exception do
    begin
      aLogger.ToLog(E.ClassName);
      inc(l_ExceptionCount);
    end;
  end;
  Check(l_ExceptionCount = 0);
end;

Предвкушая "все зеленые лампочки", очень был удивлен, когда увидел и фиолетовую(тест провалился) и красную(возникновение exception).

И вот тут я не полез в debug. Я сел и подумал, почему так вышло. Мы ведь по сути всё закончили. Сначала я думал что "виновато" мое последнее изменение(Отдельная благодарность людям которые придумали GIT и любые другие системы контроля версий). Откатившись назад на коммит... И запустив тесты, я увидел тоже самое.

Именно этот момент и является очень показательным. После изменения сравнения знаменателя с 0(ночное решение) на проверку исключений, и выдаче результата который я предвкушал,  я решил что "дело в шляпе". Закомител код. И планировал заканчивать статью. 
Где моя главная ошибка ? 
Я не воспользовался преимуществами подхода, о котором пишу. А именно. ТЕСТЫ ПРОВЕРЯЮТ РАБОТУ программиста. Я не запустил тесты, так как решил, что "уж тут-то точно всё ок". 
У нас провалилось 2 теста:
- Тест логики проверки деления на 0.
- Тест эталонов с 10к вариантами.

Первый тест "логики проверки деления на 0":
procedure TCalculatorOperationViaLogicTest.TestZeroDiv;
var
  x1, x2: string;
begin
  x1:= cA;
  x2:= '0';
  CheckTrue(c_ZeroDivideMessageError = TCalculator.Divide(x1, x2));
end;

Здесь первая ошибка. Нужная нам константа c_ZeroDivideMessageError появится только тогда когда будет Exception для целочисленного деления на 0, или EDivByZero. Вместо него мы ловим EZeroDivide.
Delphi различает эти типы исключений, поэтому для вещественных чисел введён свой класс. Почему это сделано, я в принципе понимаю. Но мои тесты не поняли. Будем менять.
Второй тест провалился потому что, он не сошелся с эталоном. В котором уже было записано строковое значение нашей константы c_ZeroDivideMessageError. 

В следствии того что в операцию Div попадают только вещественные числа, меняем проверку исключения на необходимый нам тип:
class function TCalculator.Divide(const A, B: string): string;
var
  x1, x2, x3 : single;
begin
  x1 := StrToFloat(A);
  x2 := StrToFloat(B);
  try
    x3 := x1 / x2;
  except
    on EZeroDivide do
    begin
      Result:= c_ZeroDivideMessageError;
      Exit;
    end;
  end;
  x3 := x1 / x2;
  Result := FloatToStr(x3);
end;

Всё отлично:


Но!!!
Наша проверка количества исключений, на данный момент находится в процедуре CheckOperation. То есть запуская каждый раз операцию на тестирование, мы проверяем что бы в результате не было не одного исключения. В данный момент нас это в принципе устраивает. Но суть проверки теряется. Изменим это:
procedure TCalculatorOperationRandomSequenceTest.CheckOperationSeq(
  aLogger: TLogger;
  anOperation: TCalcOperation);
var
  l_Index, l_ExceptionCount : Integer;
  x1, x2 : single;
begin
  RandSeed := 40000;
  aLogger.OpenTest(Self);

  l_ExceptionCount:= 0;
  for l_Index := 0 to 10000 do
  begin
    try
      x1 := 1000 * Random;
      x2 := 2000 * Random;
      if (l_Index mod 100) = 0 then
        x2 := 0;
      CheckOperation(aLogger,
                     x1,
                     x2, anOperation);
      except
        on E : Exception do
        begin
          aLogger.ToLog(E.ClassName);
          inc(l_ExceptionCount);
        end;
    end;
    end;
  CheckTrue(aLogger.CheckWithEtalon);
  Check(l_ExceptionCount = 0);
end;

Запустим тесты и УБЕДИМСЯ что всё ок.

Подведем итоги:
Первым делом, ТЗ ТЗ и ещё раз ТЗ. Уточнять и думать это именно наша работа, а не клиента.
Каждый раз меняя логику или условия тестирования мы обновляем эталоны.
Каждый раз МЕНЯЯ ЧТО УГОДНО - МЫ ПРОВЕРЯЕМ СЕБЯ ТЕСТАМИ.
Иначе на кой они вообще ?
Ноль не всегда равен 0, так уж вышло в программировании однако об этом будет другая статья.

Проверка на кол-во исключений позволяет нам быть более уверенными в "крепости обороны" от ошибок. Однако главное это человек сидящий за клавиатурой. Он всегда может допустить ошибку, как минимум он может не запустить тесты как я.
Программирование сложный и отчасти не предсказуемый процесс. А в придачу, мы все люди. Мы все допускаем ошибки. Пусть создание дополнительных "укреплений обороны" и несет в себе затраты в виде человеко-часов, зато взамен мы получаем КАЧЕСТВО и ПРЕДУСМОТРИТЕЛЬНОСТЬ. Однако есть ещё противоречия в ТЗ, и много других тем которые мы ещё обсудим.

p.s. В процессе подготовки статьи случайно натолкнулся на замечательную статью GunSmoker'a о расширении класса Exception после Delphi 2009.

Репозиторий проекта.

Диаграмма классов UML.


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

  1. Добрый день,

    Спасибо за статью. Наконец-то на этом блоге стали появляться детальные статьи. Приятно читать, автору спасибо.

    Если я правильно понимаю, то условие в коде первого пункта:
    if x2=0 then
    не совсем корректное. Потому что x2 вещественного типа и равенства нулю собственно при такой проверки может и не достигаться. За счет погрешности типа. Поэтому нужно использовать сравнение с точностью SameValue.

    Спасибо

    ОтветитьУдалить
    Ответы
    1. "Спасибо за статью. Наконец-то на этом блоге стали появляться детальные статьи. Приятно читать, автору спасибо."

      ПОЖАЛУЙСТА.

      И от автора - ТОЖЕ (поскольку я не автор, а "научный руководитель").

      "Наконец-то на этом блоге стали появляться детальные статьи"

      Понимаете в чём дело! :-)

      Это же не "просто пост в блоге", типа "бла-бла-бла я вас научу".

      Это - ПОЛНОЦЕННАЯ статья.

      Плод двухнедельного труда ДВОИХ людей. Со спорами, бессонными ночами и "вариантами как лучше". И уже ПРОРАБОТАННОЙ (как нам кажется) методикой.

      Поэтому - если ТЕМА ИНТЕРЕСНА - МЫ РАДЫ. Пишите нам.

      У нас есть "много тем" и БОЛЬШОЕ количество материала. И есть ПЛАН СТАТЕЙ.

      И есть "список задач". Открыть его кстати в ПУБЛИЧНЫЙ ДОСТУП?

      Т.е. мы пишем - "не потому что пишется", а "потому что это может быть интересно".

      Мы себе "наметили вехи" и "написали план статей и завели список задач".

      И пока - "у нас драйва хватает".

      Но если отклика нету, то "энтузиазм угасает".

      Посему - ЕСЛИ ИНТЕРЕСНО - пишите, СПРАШИВАЙТЕ и КРИТИКУЙТЕ.

      Ну и СПАСИБО вам за ваш отклик.

      И ещё - мы во-первых ищем человека, который мог бы эту "серию про калькулятор" перевести на английский.

      А во-вторых - мы ищем соавторов.

      Если есть что посоветовать - БУДЕМ ОЧЕНЬ рады.

      Удалить
    2. И ещё... Уже НЕСКОЛЬКО людей написали что-то вроде - "пример не показательный, а усилия чрезмерны"...

      На это я попытался ответить тут - http://programmingmindstream.blogspot.ru/2014/06/blog-post_21.html

      Но! Попытайтесь "абстрагироваться от примера"... Подумайте о том, что это "не калькулятор", а "большой проект"... ПОВЕРЬТЕ - мало что изменится.

      Если есть КОНКРЕТНЫЕ вопросы - ОБЯЗАТЕЛЬНО задавайте их.

      Если есть КОНКРЕТНЫЕ примеры - приводите их.

      МЫ БУДЕМ вам очень ПРИЗНАТЕЛЬНЫ.

      Удалить
    3. "Наконец-то на этом блоге стали появляться детальные статьи."

      Кстати ДЕТАЛЬНЫЕ статьи были и раньше.

      Например "GUI-тестирование по-русски", но они НЕ ЗАИНТЕРЕСОВАЛИ аудиторию.

      Например вот:
      http://18delphi.blogspot.ru/2013/11/5.html
      http://18delphi.blogspot.ru/2013/11/4.html
      http://18delphi.blogspot.ru/2013/11/gui-25-tscriptcontext.html
      http://18delphi.blogspot.ru/2013/11/gui-2.html
      http://18delphi.blogspot.ru/2013/11/gui_5.html

      Удалить
    4. "И ещё - мы во-первых ищем человека, который мог бы эту "серию про калькулятор" перевести на английский."

      Да! Забыл добавить - КОНЕЧНО ЖЕ - НЕ БЕСПЛАТНО.

      Удалить
    5. "Мы себе "наметили вехи" и "написали план статей и завели список задач"."

      Пример одного из тасков кстати:

      "Тесты и "клиентороиентированность".
      Про "ту самую запятую" в FloatToStr.
      И как делать тесты и ЭТАЛОНЫ зависимыми от клиентского ОКРУЖЕНИЯ.
      Когда в TLogger.Open мы подаём ещё и "предикат" клиентозависимости.
      Например:
      TLogger.Open(Self, [FormatSettings.DecimalSeparator]);
      -- и тогда В ЗАВИСИМОСТИ от ЗНАЧЕНИЯ FormatSettings.DecimalSeparator - будет сделаны ЭТАЛОНЫ с РАЗНЫМИ именами."

      Удалить
    6. Пожалуйста.
      По поводу корректности. Согласен. В следующей главе описано применение SomeValue. В данном случае можно и IsZero использовать.

      Удалить
  2. "не совсем корректное"

    КОНЕЧНО! Так это же из серии "залатали" :-)

    ОтветитьУдалить
    Ответы
    1. Про "сравнение float'ов" у нас будет отдельная глава. Один из авторов - трудится над ней.

      Удалить
    2. И ещё будет статья про то как "завернуть обработку ошибок" в класс TLogger. И тогда класс TLogger - становится РЕАЛЬНОЙ ИНФРАСТРУКТУРОЙ.

      Удалить