Повышаем качество кода при помощи Unit Test

Андрей | 1 октября, 2010

Введение

При программировании на MQL4 мне пришлось столкнуться с некоторыми готовыми программами на MQL4, а так же написать не один десяток своих. В итоге я пришёл к выводу, что MQL4 - это очень благоприятная среда для создания некачественных программ. Вот почему я так считаю:

  1. MetaTrader 4 не имеет встроенного отладчика. Процесс поиска ошибок иногда бывает достаточно мучительным.
  2. MQL4 не имеет встроенных средств обработки исключений, как это реализовано в C++ или Java.
  3. Программы на MQL4 часто пишутся впопыхах, с большей ориентацией на идею, нежели на качество кода.

Всё это ведёт к тому, что снижается качество кода, что в свою очередь влечёт:

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

Сразу хочу сказать, что всё вышесказанное не касается опытных MQL4 программистов с многолетним опытом работы. Опытные программисты находят способы написания качественного кода.

Поскольку моя основная работа связана с тестированием качества ПО, меня интересовало наличие какого-нибудь материала по тестированию и отладке MQL4 программ. Но я не смог найти много таких статей. Поэтому я хочу вынести на рассмотрение один из способов повышения качества программ. Если эта тема окажется интересной, в следующих статьях можно рассмотреть другие вопросы.


Немного теории о Качестве

Итак, немного погуглив, узнаём, что:

Качество — это совокупность свойств и характеристик продукции, которые придают ей способность удовлетворять обусловленные или предполагаемые потребности.

В отношении программного обеспечения, можно считать программу качественной, если она отвечает потребностям заказчика и корректно выполняет все возложенные на неё функции.

Для того чтобы программа получилась качественной, обычно необходимо наличие двух активностей:

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

Чем больше контроля качества - тем выше вероятность, что программа будет работать как надо :) В идеале контроль качества (или, другими словами, тестирование) должен осуществляться на каждом этапе разработки:

  1. Тестирование ТЗ (Техническое Задание) - на основании неправильного ТЗ невозможно разработать правильно работающую программу.
  2. Просмотр исходного кода (Code Review). Поиск недочётов, неэффективного кода, нарушение правил кодирования, очевидные ошибки.
  3. Тестирование отдельных функций программы в автоматическом режиме (Юнит Тестирование или Unit Testing).
  4. Тестирование всей получившейся программы в ручном режиме. Человек (тестировщик) проверяет, правильно ли работает программа.
  5. Тестирование программы в автоматическом режиме (Автоматизированное Тестирование или Automated Testing). Это когда Роботы сами проверяют качество программы. Звучит утопично, но иногда работает :)
  6. Тестирование программы заказчиком.

и т.д. Видов тестирования существует достаточно много...

Из всего этого разнообразия тестов нас больше всего может заинтересовать Unit Testing.


Немного теории о Unit Testing

Google нам выдаёт следующее определение Юнит Тестов. Unit Testing - это метод валидации приложения, в котором программист проверяет отдельные юниты (блоки) исходного кода на предмет пригодности их к использованию в остальной программе. Юнит - это наименьшая пригодная к тестированию часть программы. В функциональных языках (коим является MQL4) за юнит можно принимать отдельную функцию.

Чаще всего Юнит Тестирование выполняется автоматически. То есть, пишется программа, которая вызывает тестируемую функцию с разными параметрами, затем формирует отчёт о том, правильные значения вернула функция или нет.

Юнит Тесты могут быть чрезвычайно полезны по следующим причинам:

  1. В случае если обнаружен сбой - легко понять его причину. Т.к. тестируется только одна функция. Если сбой обнаружен в целом приложении, то нужно ещё потратить время на то, чтобы найти функцию порождающую проблему.
  2. Легко проверить исправлен дефект или нет. Достаточно просто запустить ещё раз автоматический Unit Test. Не нужно перезапускать всё приложение. Например, бывают ошибки, возникающие редко в определённых условиях, которые сложно воссоздать. В случае Юнит тесов эта проблема отпадает.
  3. Можно легко оптимизировать код функции и совершенно не бояться, что что-то сломается. Юнит тест всегда покажет, продолжает функция работать нормально или нет.
  4. Можно выявить проблемы, которые сразу себя не проявляют, но могут воспроизвестись у заказчика и затем потребуют много часов отладки и поиска.
  5. Можно применить современный Test Driven подход. Когда сперва пишется Unit Test, а уж только потом разрабатывается функция. Функция разрабатывается до тех пор, пока Юнит Тест не будет пройден. Этот подход был мной впервые испробован в одном приложении на C++ и зарекомендовал себя с наилучшей стороны. У меня было ощущение непередаваемого восторга, когда к окончанию создания функций я был полностью уверен в их работоспособности, и дальнейшее их использование в программе не порождало никаких проблем.

Как это выглядит. Предположим мы написали функцию извлечения квадратного корня:
y=sqrt(x)

Соответственно, для нашего теста мы напишем другую функцию, которая будет работать по следующему алгоритму:


Мы можем написать функцию проверки до того, как мы пишем основную функцию. Таким образом мы как бы определим требования, которым должна удовлетворять разрабатываемая функция. Это и будет наше применение подхода Test Driven. И только после того, как наш Юнит Тест отработает безошибочно, мы можем с уверенностью использовать функцию в основной программе.

Остаётся открытым один вопрос: как выбрать набор тестовых параметров для тестируемой функции? Безусловно, в идеальном случае нужно использовать все возможные значения, но почти всегда это невозможно или трудоёмко. На тему выбора тестовых значений можно написать отдельную статью. Здесь же я постараюсь дать какие-то общие рекомендации:

  1. Нужно использовать как корректные данные, так и данные приводящие к ошибке. Т.к. нужно не только проверить, что функция выполняет возложенные на неё обязанности, но так же насколько корректно она обрабатывает ошибки.
  2. Нужно использовать граничные значения. Например, если диапазон значений от 0 до 100, нужно использовать для тестов значения раавные и 0, и 100. Если входные данные строка - нужно попробовать пустую строку и строку с максимальной длиной.
  3. Нужно использовать значения, выходящие за границы дозволенного. Если брать пример из предыдущего пункта, то нужно будет использовать значение 101, -1, для строки значение длинной max+1.
  4. Нужно постараться разбить множество всех возможных значений на подмножества схожих, для которых поведение функции будет одинаковым (называемых классы эквивалентности). И для каждого класса выбрать одно значение. Например, не имеет смысла проверять оба sqrt(4) и sqrt(9). Гораздо интереснее проверить sqrt(4) и sqrt(5), т.к. во втором случае функция вернёт иррациональное значение, а в первом случае целое.
  5. Если в функции есть ветвления (if, switch), нужно постараться, чтобы каждая из ветвей была затронута Юнит Тестом.

Попробую в следующей главе показать это на примере.


Немного практики написания Unit Test

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

Определим прототип нашей функции:

void CreateSubset(int & a1[], int a2[]);

Попробуем для разработки функции применить подход Test Driven. Определим набор тестовых данных. Для этого выделим несколько классов эквивалентности входных данных:

  1. Оба массива пустые.
  2. A1 пустой, в A2 есть элементы.
  3. В A1 есть элементы, A2 пустой.
  4. Оба содержат одинаковый набор элементов и имеют одинаковый размер.
  5. A1 содержит элементы, которых нет в A2.
  6. Часть элементов A1 содержится в A2, часть A2 содержится в A1 (оба множества имеют пересечение).
  7. Все элементы A1 содержатся в A2, но размер A2 больше.
  8. Небольшая часть элементов A1 содержится в A2. При этом элементы разбросаны по всему массиву.
  9. Небольшая часть элементов A1 содержится в A2. При этом элементы сконцентрированы в начале массива.
  10. Небольшая часть элементов A1 содержится в A2. При этом элементы сконцентрированы в конце массива.

Если наша функция правильно сработает для всех 10 случаев, мы можем спать спокойно и быть уверены, что те эксперты, которые используют эту функцию, не пострадают от её несовершенства :) Однако необходимо понимать, что протестировать что-то на 100% невозможно, и всегда останутся какие-то возможные скрытые дефекты.

Для удобства я создал библиотечку mql4unit. В неё добавил функции, необходимые для юнит тестов:

//-------------------------------------------------------------------+
//Глобальные переменные хранят состояние текущего теста
//-------------------------------------------------------------------+
int tests_passed;    //Количество успешных тестов
int tests_failed;    //Количество неуспешных тестов
int tests_total;     //Обрщее количество тестов
string test_name;    //Имя теста

//-------------------------------------------------------------------+
//Функция выполняет инициализицию тестовой среды для одного теста
//-------------------------------------------------------------------+
void UnitTestStart(string TestName)
{
   test_name = TestName;
   tests_passed = 0;
   tests_failed = 0;
   tests_total = 0;
   Print("*--------------------------------------------------*");
   Print("Начинаем выполнение юнит теста ", test_name);
}

//-------------------------------------------------------------------+
//функция вызывается в конце теста. Возвращвает true если все тесты
//прошли успешно. False в противном случае.
//-------------------------------------------------------------------+
bool UnitTestEnd()
{
   if (tests_failed == 0)
   {
      Print("УРА!!! ", test_name, " PASSED. ", tests_passed, " тестов выполнено успешно.");
   }
   else
   {
      Print(":((( ", test_name, " FAILED. ", tests_passed,"/",tests_total, " тестов выполнено успешно.");   
   }
   Print("*--------------------------------------------------*");
}

//-------------------------------------------------------------------+
//Функция выполняет тест для двух массивов типа int
//Возвращает true если массивы равны
//-------------------------------------------------------------------+
bool TestIntArray(int actual[], int expected[]){
   tests_total++;
   //Сравниваем размеры массивов
   if (ArraySize(actual) != ArraySize(expected))
   {
      Print("Test #", tests_total," ERROR. Размер массива ", ArraySize(actual), " вместо ", ArraySize(expected));
      tests_failed++;
      return(false);      
   }
   //Далее сравниваем поэлементно
   for (int i=0; i<ArraySize(actual);i++)
   {
      if (actual[i]!=expected[i]){
         Print("Test #", tests_total," ERROR. Значение элемента #",i,"=", actual, " вместо ", expected);
         tests_failed++;
         return(false);
      }
   }
   //Если все элементы равны - тест прошёл
   Print("Test #", tests_total," OK: Passed!");  
   tests_passed++;
   return(true);
}
Создадим новый тестовый скрипт “mytests” с пустым телом нашей функции. Создадим в нём функцию test и опишем в ней все юнит тесты.
bool Test()
{
   UnitTestStart("Проверка функции CreateSubset");
   Print("1. Оба массива пустые.");
   int a1_1[], a1_2[];
   int result_1[]; //Ожидаем в результате выполнения функции пустой массив
   CreateSubset(a1_1, a1_2);
   TestIntArray(a1_1, result_1);
   
   Print("2. A1 пустой, в A2 есть элементы");
   int a2_1[], a2_2[] = {1,2,3};
   int result_2[]; //Ожидаем в результате выполнения функции пустой массив
   CreateSubset(a2_1, a2_2);
   TestIntArray(a2_1, result_2);

   Print("3. В A1 есть элементы, A2 пустой");
   int a3_1[] = {1,2,3}, a3_2[];
   int result_3[]; //Ожидаем в результате выполнения функции пустой массив
   CreateSubset(a3_1, a3_2);
   TestIntArray(a3_1, result_3);

   Print("4. Оба содержат одинаковый набор элементов и имеют одинаковый размер");
   int a4_1[] = {1,2,3}, a4_2[] = {1,2,3};
   int result_4[] = {1,2,3}; //Ожидаем в результате выполнения функции неизменённый массив
   CreateSubset(a4_1, a4_2);
   TestIntArray(a4_1, result_4);

   Print("5. A1 содержит элементы, которых нет в A2");
   int a5_1[] = {4,5,6}, a5_2[] = {1,2,3};
   int result_5[]; //Ожидаем в результате выполнения функции пустой массив
   CreateSubset(a5_1, a5_2);
   TestIntArray(a5_1, result_5);
   
   Print("6. Часть элементов A1 содержится в A2, часть A2 содержится в A1 (оба множества имеют пересечение)");
   int a6_1[] = {1,2,3,4,5,6,7,8,9,10}, a6_2[] = {3,5,7,9,11,13,15};
   int result_6[] = {3,5,7,9}; //Ожидаем в результате выполнения функции пересечение массивов
   CreateSubset(a6_1, a6_2);
   TestIntArray(a6_1, result_6);
   
   Print("7. Все элементы A1 содержатся в A2, но размер A2 больше");
   int a7_1[] = {3,4,5}, a7_2[] = {1,2,3,4,5,6,7,8,9,10};
   int result_7[] = {3,4,5}; //Ожидаем в результате выполнения функции пересечение массивов
   CreateSubset(a7_1, a7_2);
   TestIntArray(a7_1, result_7);
   
   Print("8. Небольшая часть элементов A1 содержится в A2. При этом элементы разбросаны по всему массиву.");
   int a8_1[] = {1,2,3,4,5,6,7,8,9,10}, a8_2[] = {2,5,9};
   int result_8[] = {2,5,9}; //Ожидаем в результате выполнения функции пересечение массивов
   CreateSubset(a8_1, a8_2);
   TestIntArray(a8_1, result_8);
   
   Print("9. Небольшая часть элементов A1 содержится в A2. При этом элементы сконцентрированы в начале массива.");
   int a9_1[] = {1,2,3,4,5,6,7,8,9,10}, a9_2[] = {1,2,3};
   int result_9[] = {1,2,3}; //Ожидаем в результате выполнения функции пересечение массивов
   CreateSubset(a9_1, a9_2);
   TestIntArray(a9_1, result_9);

   Print("10. Небольшая часть элементов A1 содержится в A2. При этом элементы сконцентрированы в конце массива.");
   int a10_1[] = {1,2,3,4,5,6,7,8,9,10}, a10_2[] = {8,9,10};
   int result_10[] = {8,9,10}; //Ожидаем в результате выполнения функции пересечение массивов
   CreateSubset(a10_1, a10_2);
   TestIntArray(a10_1, result_10);
   
   return (UnitTestEnd());
}

Для того чтобы выполнить Юнит Тест, нам нужно вызвать функцию Test в функции main и запустить скрипт.

Запустим наш тест на выполнение.


Как видим - результат удручающий :). Этого и следовало ожидать, ведь функция совсем не готова. Однако! 4 теста из 10 прошли успешно. Это означает, что теоретически мы могли бы и не заметить, что функция пустая, т.к. в некоторых случаях она бы срабатывала нормально :)

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

Теперь создадим саму функцию CreateSubset. Не будем здесь обсуждать эффективность и красоту этой функции ;)

void CreateSubset(int & a1[], int a2[]){
   int i=0;
   while(i<ArraySize(a1)){
      bool b_exist = false;
      for (int j=0; j<ArraySize(a2);j++){
         if (a1[i] == a2[j]) b_exist = true;
      }
      if (!b_exist){
         for (j=i; j<ArraySize(a1)-1;j++){
            a1[j] = a1[j+1];   
         }
         ArrayResize(a1, ArraySize(a1)-1);
      }else{
         i++;
      }
   }
}
Снова запустим тест:


Запускать функцию можно откуда угодно. Можно определить функцию внутри эксперта и запускать во время инициализации. Если разрабатывается отдельный модуль, то можно внутри модуля определить одну или несколько тестовых функций и вызывать из скрипта. Тут можно фантазировать.

Конечно, идеально было бы иметь возможность запускать юнит тест сразу же после компиляции библиотеки, но можно ли это сделать в MQL4, я пока не понял :) Скорее всего, что нет. Если знаете как - напишите :)

Каждый раз после запуска теста, мы можем спокойно вздохнуть и быть уверены, что всё работает как надо.


Несколько замечаний

  1. Может показаться, что написание тестов отнимает дополнительное время. Однако будьте уверены: время, потраченное на разработку юнит тестов, окупиться с лихвой.
  2. Не стоит так же фанатично разрабатывать Юнит Тесты на все функции. Нужно соблюдать баланс между важностью функции, вероятностью поломки и количеством кода внутри функции. Например, не стоит писать тест на простейшую функцию из пары строчек.
  3. Внутри юнит тестов можно делать всё, что угодно: открывать/закрывать ордера, использовать индикаторы, графические объекты и т.д. Тут ваша фантазия не должна быть ограничена.


И, напоследок

Надеюсь, что данный материал будет вам полезен. С радостью отвечу на любые ваши вопросы. Так же я открыт для любых предложений по улучшению статьи и написанию новых.

Всем удачи и кода без ошибок! :)