Повышаем качество кода при помощи Unit Test
Андрей | 1 октября, 2010
Введение
При программировании на MQL4 мне пришлось столкнуться с некоторыми готовыми программами на MQL4, а так же написать не один десяток своих. В итоге я пришёл к выводу, что MQL4 - это очень благоприятная среда для создания некачественных программ. Вот почему я так считаю:
- MetaTrader 4 не имеет встроенного отладчика. Процесс поиска ошибок иногда бывает достаточно мучительным.
- MQL4 не имеет встроенных средств обработки исключений, как это реализовано в C++ или Java.
- Программы на MQL4 часто пишутся впопыхах, с большей ориентацией на идею, нежели на качество кода.
Всё это ведёт к тому, что снижается качество кода, что в свою очередь влечёт:
- Сбои в работе советников, неправильный алгоритм работы (особенно критично при работе на реале).
- Медленная скорость выполнения. Делает оптимизацию очень медленной.
- Плохая обработка ошибочных ситуаций. Советник может оказаться неработоспособным.
Сразу хочу сказать, что всё вышесказанное не касается опытных MQL4 программистов с многолетним опытом работы. Опытные программисты находят способы написания качественного кода.
Поскольку моя основная работа связана с тестированием качества ПО, меня интересовало наличие какого-нибудь материала по тестированию и отладке MQL4 программ. Но я не смог найти много таких статей. Поэтому я хочу вынести на рассмотрение один из способов повышения качества программ. Если эта тема окажется интересной, в следующих статьях можно рассмотреть другие вопросы.
Немного теории о Качестве
Итак, немного погуглив, узнаём, что:
Качество — это совокупность свойств и характеристик продукции, которые придают ей способность удовлетворять обусловленные или предполагаемые потребности.
В отношении программного обеспечения, можно считать программу качественной, если она отвечает потребностям заказчика и корректно выполняет все возложенные на неё функции.
Для того чтобы программа получилась качественной, обычно необходимо наличие двух активностей:
- Обеспечение Качества (Quality Assurance) - меры, нацеленные на предотвращение появления дефектов.
- Контроль Качества (Quality Control) - проверка качества готовой программы с целью выявления дефектов, если QA не помогло :). Необходимо понимать, что дефект вряд ли будет исправлен, если он не обнаружен. При этом ситуация не будет выглядеть красиво, если дефект был обнаружен заказчиком...
Обеспечение
Качества - это сложная материя. Она простирается от создания
комфортного рабочего места программиста и заканчивается внедрением
сложных бизнес процессов. Не будем пока её затрагивать, поговорим о
Контроле Качества.
Чем больше контроля качества - тем выше вероятность, что программа будет работать как надо :) В идеале контроль качества (или, другими словами, тестирование) должен осуществляться на каждом этапе разработки:
- Тестирование ТЗ (Техническое Задание) - на основании неправильного ТЗ невозможно разработать правильно работающую программу.
- Просмотр исходного кода (Code Review). Поиск недочётов, неэффективного кода, нарушение правил кодирования, очевидные ошибки.
- Тестирование отдельных функций программы в автоматическом режиме (Юнит Тестирование или Unit Testing).
- Тестирование всей получившейся программы в ручном режиме. Человек (тестировщик) проверяет, правильно ли работает программа.
- Тестирование программы в автоматическом режиме (Автоматизированное Тестирование или Automated Testing). Это когда Роботы сами проверяют качество программы. Звучит утопично, но иногда работает :)
- Тестирование программы заказчиком.
и т.д. Видов тестирования существует достаточно много...
Из всего этого разнообразия тестов нас больше всего может заинтересовать Unit Testing.
Немного теории о Unit Testing
Google нам выдаёт следующее определение Юнит Тестов. Unit Testing - это метод валидации приложения, в котором программист проверяет отдельные юниты (блоки) исходного кода на предмет пригодности их к использованию в остальной программе. Юнит - это наименьшая пригодная к тестированию часть программы. В функциональных языках (коим является MQL4) за юнит можно принимать отдельную функцию.
Чаще
всего Юнит Тестирование выполняется автоматически. То есть, пишется
программа, которая вызывает тестируемую функцию с разными параметрами,
затем формирует отчёт о том, правильные значения вернула функция или нет.
Юнит Тесты могут быть чрезвычайно полезны по следующим причинам:
- В случае если обнаружен сбой - легко понять его причину. Т.к. тестируется только одна функция. Если сбой обнаружен в целом приложении, то нужно ещё потратить время на то, чтобы найти функцию порождающую проблему.
- Легко проверить исправлен дефект или нет. Достаточно просто запустить ещё раз автоматический Unit Test. Не нужно перезапускать всё приложение. Например, бывают ошибки, возникающие редко в определённых условиях, которые сложно воссоздать. В случае Юнит тесов эта проблема отпадает.
- Можно легко оптимизировать код функции и совершенно не бояться, что что-то сломается. Юнит тест всегда покажет, продолжает функция работать нормально или нет.
- Можно выявить проблемы, которые сразу себя не проявляют, но могут воспроизвестись у заказчика и затем потребуют много часов отладки и поиска.
- Можно применить современный Test Driven подход. Когда сперва пишется Unit Test, а уж только потом разрабатывается функция. Функция разрабатывается до тех пор, пока Юнит Тест не будет пройден. Этот подход был мной впервые испробован в одном приложении на C++ и зарекомендовал себя с наилучшей стороны. У меня было ощущение непередаваемого восторга, когда к окончанию создания функций я был полностью уверен в их работоспособности, и дальнейшее их использование в программе не порождало никаких проблем.
Как это выглядит. Предположим мы написали функцию извлечения квадратного корня:
y=sqrt(x)
Соответственно, для нашего теста мы напишем другую функцию, которая будет работать по следующему алгоритму:
- проверить что sqrt(-1) == ошибка
- проверить что sqrt(0) == 0
- проверить что sqrt(0.01) == 0.1
- проверить что sqrt(1) == 1
- проверить что sqrt(4) == 2
- проверить что sqrt(7) == 2.6....
Мы
можем написать функцию проверки до того, как мы пишем основную функцию.
Таким образом мы как бы определим требования, которым должна
удовлетворять разрабатываемая функция. Это и будет наше применение подхода Test
Driven. И только после того, как наш Юнит Тест отработает
безошибочно, мы можем с уверенностью использовать функцию в основной
программе.
Остаётся открытым один вопрос: как выбрать набор тестовых параметров для тестируемой функции? Безусловно, в идеальном случае нужно использовать все возможные значения, но почти всегда это невозможно или трудоёмко. На тему выбора тестовых значений можно написать отдельную статью. Здесь же я постараюсь дать какие-то общие рекомендации:
- Нужно использовать как корректные данные, так и данные приводящие к ошибке. Т.к. нужно не только проверить, что функция выполняет возложенные на неё обязанности, но так же насколько корректно она обрабатывает ошибки.
- Нужно использовать граничные значения. Например, если диапазон значений от 0 до 100, нужно использовать для тестов значения раавные и 0, и 100. Если входные данные строка - нужно попробовать пустую строку и строку с максимальной длиной.
- Нужно использовать значения, выходящие за границы дозволенного. Если брать пример из предыдущего пункта, то нужно будет использовать значение 101, -1, для строки значение длинной max+1.
- Нужно постараться разбить множество всех возможных значений на подмножества схожих, для которых поведение функции будет одинаковым (называемых классы эквивалентности). И для каждого класса выбрать одно значение. Например, не имеет смысла проверять оба sqrt(4) и sqrt(9). Гораздо интереснее проверить sqrt(4) и sqrt(5), т.к. во втором случае функция вернёт иррациональное значение, а в первом случае целое.
- Если в функции есть ветвления (if, switch), нужно постараться, чтобы каждая из ветвей была затронута Юнит Тестом.
Попробую в следующей главе показать это на примере.
Немного практики написания Unit Test
Итак, поставим себе учебную цель! Допустим, нам нужно разработать библиотеку, в которой есть функция, принимающая на вход два массива. Функция удаляет из первого массива те элементы, которых нет во втором массиве. В итоге первый массив будет являться подмножеством второго массива.
Определим прототип нашей функции:
void CreateSubset(int & a1[], int a2[]);
Попробуем для разработки функции применить подход Test Driven. Определим набор тестовых
данных. Для этого выделим несколько классов эквивалентности входных
данных:
- Оба массива пустые.
- A1 пустой, в A2 есть элементы.
- В A1 есть элементы, A2 пустой.
- Оба содержат одинаковый набор элементов и имеют одинаковый размер.
- A1 содержит элементы, которых нет в A2.
- Часть элементов A1 содержится в A2, часть A2 содержится в A1 (оба множества имеют пересечение).
- Все элементы A1 содержатся в A2, но размер A2 больше.
- Небольшая часть элементов A1 содержится в A2. При этом элементы разбросаны по всему массиву.
- Небольшая часть элементов A1 содержится в A2. При этом элементы сконцентрированы в начале массива.
- Небольшая часть элементов 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, я пока не понял :) Скорее всего, что нет. Если знаете как - напишите :)
Каждый раз после запуска теста, мы можем спокойно вздохнуть и быть уверены, что всё работает как надо.
Несколько замечаний
- Может показаться, что написание тестов отнимает дополнительное время. Однако будьте уверены: время, потраченное на разработку юнит тестов, окупиться с лихвой.
- Не стоит так же фанатично разрабатывать Юнит Тесты на все функции. Нужно соблюдать баланс между важностью функции, вероятностью поломки и количеством кода внутри функции. Например, не стоит писать тест на простейшую функцию из пары строчек.
- Внутри юнит тестов можно делать всё, что угодно: открывать/закрывать ордера, использовать индикаторы, графические объекты и т.д. Тут ваша фантазия не должна быть ограничена.
И, напоследок
Надеюсь, что данный материал будет вам полезен. С радостью отвечу на любые ваши вопросы. Так же я открыт для любых предложений по улучшению статьи и написанию новых.
Всем удачи и кода без ошибок! :)