Создание фреймворка для модульного тестирования на этапе компиляции в MQL5 с использованием утверждений препроцессора
Введение
В MQL5 отсутствует встроенная инфраструктура модульного тестирования — нет ни встроенных средств запуска тестов, ни библиотек утверждений, ни наборов тестов. Поэтому разработчики обычно проверяют основные служебные функции (например, калькуляторы размера лота или конвертеры пунктов), просматривая журналы на вкладке "Эксперты" при работе на реальном или демонстрационном счёте. Опасность заключается в том, что малозаметные математические ошибки часто проявляются лишь при определённых сочетаниях входных данных и потому могут совершенно незаметно попасть в эксплуатацию.
Рассмотрим калькулятор размера лота, который рассчитывает объём позиции на основе средств счёта, процента риска и расстояния до стоп-лосса. Функция может безупречно работать с типичными входными данными, но незаметно давать неверный результат, когда рассчитанный размер лота оказывается между двумя допустимыми значениями, кратными шагу лота, и округляется в неправильном направлении.
Данный тип ошибки не приведет к сбою работы советника и не вызовет появления кода ошибки в терминале. В результате просто отправляется ордер с объёмом, немного отличающимся от требуемого, что незаметно искажает профиль риска стратегии на протяжении сотен сделок. Без формального утверждения, проверяющего точность результатов для известных входных данных, такие дефекты с накапливающимся эффектом остаются совершенно незаметными при поверхностном наблюдении.
Чтобы восполнить этот пробел, в статье разрабатывается фреймворк тестирования на самом MQL5, без внешних зависимостей. Архитектура основана на трёх структурных компонентах:
- Макросы утверждений препроцессора: автоматически фиксируют исходный файл и точный номер строки, в которой тест завершился неудачей.
- Наборы тестов на основе интерфейсов: изолируют и группируют связанную тестовую логику, поддерживая порядок в рабочей среде разработки.
- Центральное средство запуска тестов: объединяет результаты отдельных тестов и выводит структурированную сводку об успешных и неуспешных тестах.
Вся система работает как стандартный скрипт MQL5, не требует никаких внешних инструментов и выводит результаты непосредственно на вкладку "Эксперты" терминала.

Рис. 1. Последовательность работы фреймворка тестирования. Средство запуска тестов регистрирует и запускает каждый набор тестов; наборы тестов вызывают тестируемую систему и проверяют выходные данные с помощью макросов утверждений, которые создают записи STestResult; затем модуль форматирования выводит сводку об успешных и неуспешных тестах на вкладку "Эксперты"
Почему служебные функции поставляются со скрытыми дефектами
Ошибки, которые модульные тесты наиболее надёжно выявляют в служебном коде MQL5, делятся на четыре категории.
Ошибки округления чисел с плавающей запятой. MQL5 использует двойную точность для всех финансовых вычислений. Операции, которые кажутся математически точными, например 0.1 + 0.2, дают в представлении IEEE 754 такие значения, как 0.30000000000000004. Калькулятор размера лота, который напрямую сравнивает вычисленный объём с целевым значением с помощью оператора ==, будет работать некорректно на входных данных, при обработке которых накапливается погрешность представления. Макрос ASSERT_NEAR с явно заданным допуском — единственное корректное утверждение для результатов вычислений с плавающей запятой.
Ошибки нормализации по шагу лота. Брокеры устанавливают минимальный шаг лота, как правило, равный 0,01. Рассчитанный размер лота 0,105 необходимо округлить либо до 0,10, либо до 0,11. Выбор направления округления определяется бизнес-логикой, но это правило должно применяться последовательно. Неправильный выбор направления округления изменяет профиль риска. Без утверждения, задающего ожидаемый нормализованный результат для заданного входного значения, ошибка распространяется незаметно.
Ошибки в граничных случаях. Функция расчета стоимости пункта, которая работает для пары EURUSD со стандартным лотом, не работает для пары USDJPY, где для расчета стоимости пункта требуется деление на текущую цену "аск", а не умножение. Нормализатор спреда, который работает у брокеров с пятизначными котировками, не работает у брокеров с трёхзначными котировками, где применяется другой множитель. Для этих граничных случаев необходимо явно задать тестовые входные данные, обеспечивающие выполнение граничных условий.
Незаметные переполнение и антипереполнение. Преобразователь риска, заданного в процентах, при нулевом балансе счёта или нулевом расстоянии до стоп-лосса может выдать результат деления на ноль, который MQL5 представляет как DBL_MAX или отрицательную бесконечность, вместо того чтобы сообщить об ошибке. Без утверждения, проверяющего, что выходные данные находятся в допустимом диапазоне, эти входные данные приводят к незаметному искажению размеров лотов.
Типичные объекты тестирования и их профили риска
| Функция | Распространенный дефект | Метод обнаружения |
|---|---|---|
| Калькулятор размера лота | Неправильное направление округления с учётом шага лота | ASSERT_NEAR с известными парами входных и выходных данных |
| Модуль масштабирования ATR | Множитель применяется до нормализации | ASSERT_EQ с фиксированными значением ATR и множителем |
| Нормализатор спреда | Неверное предположение о количестве знаков | ASSERT_EQ с пятизначными и трёхзначными входными данными |
| Калькулятор стоимости пункта | Ошибка инверсии валютной пары | ASSERT_NEAR с входными данными для кросс-пар |
| Алгоритм округления | Ошибка на единицу на границе шага лота | ASSERT_EQ с входными данными, пересекающими границы |
| Конвертер процентных значений риска | Скрытое искажение из-за нулевого знаменателя | ASSERT_TRUE для проверки допустимости диапазона выходных значений |
Механика утверждений и интеграция с препроцессором
Макросы утверждений используют константы препроцессора MQL5 __FILE__ и __LINE__, чтобы зафиксировать расположение вызова утверждения в исходном коде. Эти константы определяются на этапе компиляции. Таким образом, указанный номер строки соответствует месту вызова утверждения в исходном файле независимо от глубины цепочки вызовов между макросом и средством запуска тестов.
Макросы не используют исключения в том смысле, в каком они понимаются в C++. MQL5 не поддерживает структурированную обработку исключений с использованием throw и catch с передачей исключений через произвольные кадры вызовов. Вместо этого ASSERT_THROWS проверяет флаг состояния до и после вызова и имитирует обнаружение исключения с помощью логического сигнального значения. Предполагается, что тестируемая функция при получении недопустимых входных данных устанавливает глобальный флаг ошибки или возвращает сигнальное значение, и данное утверждение проверяет, произошло ли это.
Каждый макрос утверждения вызывает общую внутреннюю функцию _RecordAssertionResult(), которая сохраняет результат в виде структуры STestResult в массиве результатов активного набора тестов. Макрос является единственной точкой входа для этой функции; прямые вызовы функции _RecordAssertionResult() из пользовательского кода не предусмотрены и не дают никаких дополнительных преимуществ.
Типы утверждений и их семантика
| Утверждение | Тип сравнения | Когда применять |
|---|---|---|
| ASSERT_EQ(expected, actual) | Точное равенство (==) | Целочисленные и строковые результаты, логические флаги |
| ASSERT_NEAR(expected, actual, tolerance) | MathAbs(expected - actual) <= tolerance | Все финансовые вычисления с плавающей запятой |
| ASSERT_TRUE(condition) | condition == true | Допустимость диапазона, состояния флагов, проверки наличия |
| ASSERT_FALSE(condition) | condition == false | Отсутствие состояний ошибки, исключение граничных значений |
| ASSERT_THROWS(call, flag_var) | flag_var == true после вызова | Обработка недопустимых входных данных, проверка возврата сигнального значения |
Архитектура набора тестов
Каждый набор тестов представляет собой класс, реализующий интерфейс ITestSuite. Интерфейс требует наличия двух методов: GetName(), который возвращает понятное для человека название набора тестов, и Execute(CTestRunner *runner), который вызывает макросы утверждений с использованием объекта runner в качестве контекста. Наборы тестов регистрируются в средстве запуска тестов до начала выполнения. Средство запуска тестов перебирает зарегистрированные наборы тестов в порядке регистрации, вызывает для каждого из них метод Execute() и накапливает результаты.
Для регистрации наборов тестов используется плоский массив указателей, а не связный список, поскольку операции MQL5 с массивом типа ITestSuite*[] просты и не требуют объектов-узлов с управлением памятью в куче. Средство запуска тестов владеет всеми зарегистрированными указателями на наборы тестов и удаляет соответствующие объекты в своём деструкторе.

Рис. 2. Архитектура набора тестов. Каждый конкретный набор тестов реализует интерфейс ITestSuite, который определяет два метода: GetName() для получения понятного человеку имени и Execute(CTestRunner *runner) для вызова макросов утверждений. Наборы тестов регистрируются в плоском массиве указателей ITestSuite*[] средства запуска тестов — этот массив выбран вместо связного списка, чтобы избежать использования объектов-узлов, размещаемых в куче. При выполнении CTestRunner поочередно обрабатывает зарегистрированные наборы тестов, вызывает для каждого из них метод Execute() и накапливает возвращаемые результаты
Компоненты фреймворка
Назначение компонентов фреймворка
| Компонент | Назначение |
|---|---|
| TestStatus.mqh | Определяет ENUM_TEST_STATUS и структуру STestResult |
| AssertionMacros.mqh | Определяет все пять макросов утверждений и функцию _RecordAssertionResult() |
| ITestSuite.mqh | Абстрактный интерфейс, требующий наличия методов GetName() и Execute() |
| TestRunner.mqh | Владеет наборами тестов, выполняет их, объединяет записи STestResult |
| ReportFormatter.mqh | Форматирует и выводит структурированные результаты тестов (пройден/не пройден) на вкладку "Эксперты" |
| MathUtilities.mqh | Содержит тестируемые вспомогательные функции |
| LotCalculationTests.mqh | Реализация ITestSuite для проверки расчета размера лота |
| ATRScalingTests.mqh | Реализация ITestSuite для проверки масштабирования ATR |
Перечисление статусов теста
| Статус | Числовое значение | Значение |
|---|---|---|
| TEST_PASS | 0 | Условие утверждения выполнено |
| TEST_FAIL | 1 | Условие утверждения не выполнено |
| TEST_ERROR | 2 | Неожиданная проблема во время выполнения теста |
Пошаговое руководство по коду: подробное описание каждого файла
В следующих разделах приводится полный текст каждого файла фреймворка с комментариями о том, для чего предназначен каждый блок и почему он построен именно таким образом.
TestStatus.mqh — перечисление результатов и контейнер данных
//+------------------------------------------------------------------+ //| TestStatus.mqh | //| ENUM_TEST_STATUS: typed result codes for assertion outcomes. | //| STestResult: per-assertion record stored by CTestRunner. | //+------------------------------------------------------------------+ #ifndef TESTSTATUS_MQH #define TESTSTATUS_MQH //+------------------------------------------------------------------+ //| ENUM_TEST_STATUS | //+------------------------------------------------------------------+ enum ENUM_TEST_STATUS { TEST_PASS = 0, // Assertion condition satisfied TEST_FAIL = 1, // Assertion condition not satisfied TEST_ERROR = 2 // Unexpected runtime problem during test }; //+------------------------------------------------------------------+ //| STestResult | //+------------------------------------------------------------------+ struct STestResult { string test_name; // Human-readable assertion label string suite_name; // Suite that owns this result ENUM_TEST_STATUS status; // Pass, fail, or error string expected; // Stringified expected value string actual; // Stringified actual value string file; // Source file captured by __FILE__ int line; // Source line captured by __LINE__ //--- Constructor STestResult(void) : test_name(""), suite_name(""), status(TEST_PASS), expected(""), actual(""), file(""), line(0) { } }; #endif // TESTSTATUS_MQH //+------------------------------------------------------------------+
ENUM_TEST_STATUS присваивает каждому возможному результату типизированное целочисленное значение. Использование перечисления вместо простого целого числа не позволяет вызывающей стороне передавать произвольное число в запись результата. Этим трём состояниям соответствуют три разных варианта вывода внутри CReportFormatter::PrintReport(): строка [PASS], строка [FAIL] с указанием ожидаемого и фактического значений, файла и номера строки и строка [ERROR] для проблем, возникающих во время выполнения.
STestResult — это контейнер данных, который передаётся из места вызова макроса утверждения через _RecordAssertionResult() во внутренний массив m_results[] средства запуска тестов. При формировании отчёта каждое поле имеет значение. Поля file и line заполняются на этапе компиляции с помощью констант препроцессора __FILE__ и __LINE__, которые раскрываются внутри макросов утверждений. Конструктор инициализирует все поля безопасными пустыми значениями по умолчанию, чтобы только что объявленный объект STestResult никогда не находился в неопределённом состоянии при передаче по ссылке в функцию RecordResult().
ITestSuite.mqh — контракт полиморфного набора тестов
//+------------------------------------------------------------------+ //| ITestSuite.mqh | //| Abstract test suite interface. Every suite implements | //| GetName() and Execute(). The runner owns suite pointers. | //+------------------------------------------------------------------+ #ifndef ITESTSUITE_MQH #define ITESTSUITE_MQH //--- Forward declarations class CTestRunner; //+------------------------------------------------------------------+ //| Class ITestSuite | //| Purpose: Abstract interface that all custom test suites must | //| implement to be executed by the test runner. | //+------------------------------------------------------------------+ class ITestSuite { public: //--- Returns the custom human-readable name of the test suite virtual string GetName(void) = 0; //--- Executes all internal assertion macros defined in the suite virtual void Execute(CTestRunner *runner) = 0; //--- Virtual destructor ensures safe polymorphic deallocation virtual ~ITestSuite(void) {} }; #endif // ITESTSUITE_MQH //+------------------------------------------------------------------+
ITestSuite — это чисто абстрактный класс с двумя чисто виртуальными методами. Функция GetName() возвращает метку, которая отображается в каждой строке [PASS] и [FAIL] в поле "Suite:". Метод Execute() получает указатель на активный экземпляр CTestRunner, который макросы утверждений используют для вызова RecordResult(). Виртуальный деструктор необходим, поскольку средство запуска тестов хранит указатели типа ITestSuite* и вызывает для них оператор delete; без виртуального деструктора деструктор производного класса не вызывался бы, что привело бы к утечке ресурсов, принадлежащих конкретному набору тестов. Предварительное объявление CTestRunner устраняет циклическую зависимость включений, которая в противном случае возникла бы между этим файлом и TestRunner.mqh.
Чтобы добавить новый набор тестов в фреймворк, необходимо написать класс, наследующийся от ITestSuite, реализовать два метода и зарегистрировать экземпляр с помощью AddSuite(). Ни один из существующих файлов не требует каких-либо изменений.
AssertionMacros.mqh — атрибуция исходного кода на этапе компиляции
//+------------------------------------------------------------------+ //| AssertionMacros.mqh | //| Assertion macros for the MQL5 unit testing framework. | //| Each macro captures __FILE__ and __LINE__ at the call site | //| and delegates result storage to _RecordAssertionResult(). | //+------------------------------------------------------------------+ #ifndef ASSERTIONMACROS_MQH #define ASSERTIONMACROS_MQH #include "TestStatus.mqh" //--- Forward declaration to break circular dependency with TestRunner.mqh class CTestRunner; //+------------------------------------------------------------------+ //| _RecordAssertionResult | //| Purpose: Runtime support function called by every assertion macro| //| to commit test metrics into the active runner array. | //+------------------------------------------------------------------+ void _RecordAssertionResult(CTestRunner *runner, string test_name, string suite_name, ENUM_TEST_STATUS status, string expected, string actual, string file, int line); //+------------------------------------------------------------------+ //| ASSERT_EQ | //| Purpose: Validates exact identity equality. Suitable for integer,| //| string, enum type, or boolean matching. | //+------------------------------------------------------------------+ #define ASSERT_EQ(runner,suite_name,test_name,expected,actual) \ { \ ENUM_TEST_STATUS _status = ((expected) == (actual)) ? TEST_PASS : TEST_FAIL;\ _RecordAssertionResult(runner, test_name, suite_name, _status, \ DoubleToString((double)(expected), 5), \ DoubleToString((double)(actual), 5), \ __FILE__, __LINE__); \ } //+------------------------------------------------------------------+ //| ASSERT_NEAR | //| Purpose: Floating-point precision comparison testing floating | //| values down to a defined differential absolute tolerance| //+------------------------------------------------------------------+ #define ASSERT_NEAR(runner,suite_name,test_name,expected,actual,tol) \ { \ ENUM_TEST_STATUS _status = (MathAbs((double)(expected) - (double)(actual)) <= (double)(tol)) \ ? TEST_PASS : TEST_FAIL; \ _RecordAssertionResult(runner, test_name, suite_name, _status, \ DoubleToString((double)(expected), 5), \ DoubleToString((double)(actual), 5), \ __FILE__, __LINE__); \ } //+------------------------------------------------------------------+ //| ASSERT_TRUE | //| Purpose: Verifies that a structural boolean expression or target | //| evaluation evaluates exactly to state truth. | //+------------------------------------------------------------------+ #define ASSERT_TRUE(runner,suite_name,test_name,condition) \ { \ ENUM_TEST_STATUS _status = (condition) ? TEST_PASS : TEST_FAIL; \ _RecordAssertionResult(runner, test_name, suite_name, _status, \ "true", \ (condition) ? "true" : "false", \ __FILE__, __LINE__); \ } //+------------------------------------------------------------------+ //| ASSERT_FALSE | //| Purpose: Verifies that a structural boolean expression or target | //| evaluation evaluates exactly to state falsehood. | //+------------------------------------------------------------------+ #define ASSERT_FALSE(runner,suite_name,test_name,condition) \ { \ ENUM_TEST_STATUS _status = (!(condition)) ? TEST_PASS : TEST_FAIL; \ _RecordAssertionResult(runner, test_name, suite_name, _status, \ "false", \ (condition) ? "true" : "false", \ __FILE__, __LINE__); \ } //+-------------------------------------------------------------------+ //| ASSERT_THROWS | //| Purpose: Captures runtime boundary logic errors by checking | //| whether an error state flag was raised during state run. | //+-------------------------------------------------------------------+ #define ASSERT_THROWS(runner,suite_name,test_name,flag_var) \ { \ ENUM_TEST_STATUS _status = (flag_var) ? TEST_PASS : TEST_FAIL; \ _RecordAssertionResult(runner, test_name, suite_name, _status, \ "error_flag=true", \ (flag_var) ? "error_flag=true" : "error_flag=false",\ __FILE__, __LINE__); \ } #endif // ASSERTIONMACROS_MQH //+------------------------------------------------------------------+
Каждый макрос построен по одной и той же схеме. Препроцессор разворачивает тело макроса в месте его вызова, и именно в этом заключается ключевое проектное решение. Поскольку значения __FILE__ и __LINE__ подставляются при разворачивании макроса в месте его вызова, они фиксируют место в файле набора тестов, где разработчик написал утверждение, а не место внутри функции _RecordAssertionResult(), где сохраняются данные. Именно это позволяет в MetaEditor переходить из отчётов о сбоях непосредственно к соответствующим местам в исходном коде.
Макрос ASSERT_EQ использует оператор == и приводит оба операнда к типу double перед преобразованием в строку для вывода. Благодаря этому макрос можно безопасно использовать с целыми, перечислимыми и логическими значениями без перегрузок для конкретных типов. ASSERT_NEAR вычисляет MathAbs(expected - actual) и сравнивает результат с допуском, что является правильным подходом для всех финансовых значений с плавающей запятой, соответствующих стандарту IEEE 754. Макросы ASSERT_TRUE и ASSERT_FALSE вычисляют значение логического условия и сохраняют "true" или "false" как строковое представление фактического значения, благодаря чему сообщение о сбое сразу становится понятным. ASSERT_THROWS проверяет, имеет ли переданная переменная-флаг значение true после вызова функции, предшествующего этому макросу в теле теста; так обнаружение исключений имитируется с помощью логического сигнального значения, поскольку в MQL5 отсутствует структурированная обработка исключений.
Предварительное объявление функции _RecordAssertionResult() в начале этого файла удовлетворяет требованиям компилятора при разворачивании макроса в файле набора тестов. Полное определение приведено в файле TestRunner.mqh, где класс CTestRunner полностью определён и доступен метод RecordResult().
ReportFormatter.mqh — Структурированный вывод на вкладке "Эксперты"
//+------------------------------------------------------------------+ //| ReportFormatter.mqh | //| CReportFormatter: formats and prints structured pass/fail | //| output from STestResult arrays to the terminal's Experts tab. | //+------------------------------------------------------------------+ #ifndef REPORTFORMATTER_MQH #define REPORTFORMATTER_MQH #include "TestStatus.mqh" //+------------------------------------------------------------------+ //| Class CReportFormatter | //| Purpose: Formats and outputs aggregated unit test results to the | //| MetaTrader terminal's Experts tab. | //+------------------------------------------------------------------+ class CReportFormatter { public: //--- Core reporting interface methods void PrintReport(STestResult &results[], int count); void PrintSummary(int total, int passed, int failed); }; //+------------------------------------------------------------------+ //| PrintReport | //| Purpose: Iterates through the collection of captured assertion | //| results and displays a formatted Experts tab record. | //+------------------------------------------------------------------+ void CReportFormatter::PrintReport(STestResult &results[], int count) { //--- Visual header styling for test logging boundaries Print("=========================================="); Print("=== Unit Test Report ==="); Print("=========================================="); //--- Process individual assertion outcomes sequentially for(int i = 0; i < count; i++) { switch(results[i].status) { case TEST_PASS: Print("[PASS] " + results[i].test_name + " | Suite: " + results[i].suite_name); break; case TEST_FAIL: Print("[FAIL] " + results[i].test_name + " | Suite: " + results[i].suite_name + " | Expected=" + results[i].expected + " | Actual=" + results[i].actual + " | File=" + results[i].file + " | Line=" + IntegerToString(results[i].line)); break; case TEST_ERROR: Print("[ERROR] " + results[i].test_name + " | Suite: " + results[i].suite_name + " | " + results[i].actual + " | Line=" + IntegerToString(results[i].line)); break; } } } //+-------------------------------------------------------------------+ //| PrintSummary | //| Purpose: Computes aggregate success metrics, including absolute | //| pass percentages, and appends a conclusive summary block | //+-------------------------------------------------------------------+ void CReportFormatter::PrintSummary(int total, int passed, int failed) { //--- Prevent division-by-zero runtime errors on empty test sets double pass_rate = (total > 0) ? ((double)passed / total) * 100.0 : 0.0; Print("------------------------------------------"); Print("Total Tests : " + IntegerToString(total)); Print("Passed : " + IntegerToString(passed)); Print("Failed : " + IntegerToString(failed)); Print("Pass Rate : " + DoubleToString(pass_rate, 2) + "%"); Print("------------------------------------------"); } #endif // REPORTFORMATTER_MQH //+------------------------------------------------------------------+
Функция PrintReport() принимает массив m_results[] по ссылке и проходит по нему один раз. Оператор switch по значению results[i].status выбирает для каждой записи один из трёх форматов вывода. Строка [PASS] намеренно сведена к минимуму — она содержит только название теста и название набора тестов, что позволяет сохранить читаемость вкладки "Эксперты" в случае, когда большинство тестов проходят успешно. В строку [FAIL] добавляются поля Expected, Actual, File и Line, что даёт разработчику всю необходимую информацию для обнаружения и устранения дефекта без дополнительного анализа. В строке [ERROR] для передачи описания ошибки выполнения используется поле "actual".
Функция PrintSummary() предотвращает деление на ноль при нулевом значении total; такая ситуация возникает, если вызвать RunAll() для средства запуска тестов, в котором не зарегистрировано ни одного набора тестов. Доля успешных тестов рассчитывается в процентах как значение типа double и выводится с двумя знаками после запятой.
Объект CReportFormatter хранится по значению в поле класса CTestRunner, а не выделяется в куче, поэтому не требует явного создания или удаления.
TestRunner.mqh — Владение наборами тестов, их выполнение и агрегирование результатов
//+------------------------------------------------------------------+ //| TestRunner.mqh | //| CTestRunner: owns registered ITestSuite* instances, executes | //| them in registration order, aggregates STestResult records, | //| and delegates formatted output to CReportFormatter. | //+------------------------------------------------------------------+ #ifndef TESTRUNNER_MQH #define TESTRUNNER_MQH #include "TestStatus.mqh" #include "ITestSuite.mqh" #include "ReportFormatter.mqh" #include "AssertionMacros.mqh" //+------------------------------------------------------------------+ //| Class CTestRunner | //| Purpose: Management core and execution engine of the MQL5 unit | //| testing framework. Handles lifecycle and aggregations. | //+------------------------------------------------------------------+ class CTestRunner { private: ITestSuite *m_suites[]; // Owned registered suite pointers int m_suite_count; // Number of registered suites STestResult m_results[]; // Aggregated assertion results int m_result_count; // Number of stored results bool m_stop_on_fail; // Halt execution after first failure CReportFormatter m_formatter; // Embedded report formatter public: CTestRunner(bool stop_on_first_failure); ~CTestRunner(void); void AddSuite(ITestSuite *suite); void RunAll(void); void RecordResult(STestResult &result); int GetTotalCount(void) const; int GetPassCount(void) const; int GetFailCount(void) const; }; //+------------------------------------------------------------------+ //| Constructor | //| Purpose: Initializes the testing state matrix thresholds. | //+------------------------------------------------------------------+ CTestRunner::CTestRunner(bool stop_on_first_failure) : m_suite_count(0), m_result_count(0), m_stop_on_fail(stop_on_first_failure) { ArrayResize(m_suites, 0); ArrayResize(m_results, 0); } //+------------------------------------------------------------------+ //| Destructor | //| Purpose: Safely disposes of dynamically allocated test suites | //| to guarantee clean zero-leak memory management. | //+------------------------------------------------------------------+ CTestRunner::~CTestRunner(void) { for(int i = 0; i < m_suite_count; i++) { //--- Only delete pointers that were dynamically allocated via 'new' if(CheckPointer(m_suites[i]) == POINTER_DYNAMIC) { delete m_suites[i]; m_suites[i] = NULL; } } ArrayFree(m_suites); ArrayFree(m_results); } //+------------------------------------------------------------------+ //| AddSuite | //| Purpose: Registers a custom test suite into the processing queue.| //+------------------------------------------------------------------+ void CTestRunner::AddSuite(ITestSuite *suite) { if(suite == NULL) return; ArrayResize(m_suites, m_suite_count + 1); m_suites[m_suite_count] = suite; m_suite_count++; } //+------------------------------------------------------------------+ //| RecordResult | //| Purpose: Appends an assertion outcome to the aggregate tracker. | //+------------------------------------------------------------------+ void CTestRunner::RecordResult(STestResult &result) { ArrayResize(m_results, m_result_count + 1); m_results[m_result_count] = result; m_result_count++; } //+------------------------------------------------------------------+ //| RunAll | //| Purpose: Iterates and fires test suites sequentially. Supports | //| early-exit fail-fast logic execution boundaries. | //+------------------------------------------------------------------+ void CTestRunner::RunAll(void) { for(int i = 0; i < m_suite_count; i++) { if(CheckPointer(m_suites[i]) != POINTER_DYNAMIC) continue; Print("[CTestRunner] Executing suite: " + m_suites[i].GetName()); m_suites[i].Execute(&this); //--- Fail-fast optimization check if(m_stop_on_fail && GetFailCount() > 0) { Print("[CTestRunner] Stop-on-failure triggered after suite: " + m_suites[i].GetName()); break; } } //--- Compile and broadcast visual summaries to the terminal's Experts tab m_formatter.PrintReport(m_results, m_result_count); m_formatter.PrintSummary(GetTotalCount(), GetPassCount(), GetFailCount()); } //+------------------------------------------------------------------+ //| GetTotalCount | //+------------------------------------------------------------------+ int CTestRunner::GetTotalCount(void) const { return(m_result_count); } //+------------------------------------------------------------------+ //| GetPassCount | //+------------------------------------------------------------------+ int CTestRunner::GetPassCount(void) const { int count = 0; for(int i = 0; i < m_result_count; i++) { if(m_results[i].status == TEST_PASS) count++; } return(count); } //+------------------------------------------------------------------+ //| GetFailCount | //+------------------------------------------------------------------+ int CTestRunner::GetFailCount(void) const { int count = 0; for(int i = 0; i < m_result_count; i++) { if(m_results[i].status == TEST_FAIL || m_results[i].status == TEST_ERROR) count++; } return(count); } //+------------------------------------------------------------------+ //| _RecordAssertionResult | //| Purpose: Global runtime support injection point allowing macros | //| to seamlessly route call site evaluations back here. | //+------------------------------------------------------------------+ void _RecordAssertionResult(CTestRunner *runner, string test_name, string suite_name, ENUM_TEST_STATUS status, string expected, string actual, string file, int line) { if(runner == NULL) return; STestResult result; result.test_name = test_name; result.suite_name = suite_name; result.status = status; result.expected = expected; result.actual = actual; result.file = file; result.line = line; runner.RecordResult(result); } #endif // TESTRUNNER_MQH //+------------------------------------------------------------------+
CTestRunner — центральный модуль, управляющий всеми операциями фреймворка. m_suites[] представляет собой плоский массив указателей; при вызове AddSuite() владение каждым зарегистрированным объектом, на который указывает ITestSuite*, передаётся средству запуска тестов. Деструктор проходит по этому массиву и применяет оператор delete только к тем указателям, для которых выполняется условие CheckPointer() == POINTER_DYNAMIC, что предотвращает двойное освобождение памяти, если каким-то образом был зарегистрирован нединамический указатель.
При каждой регистрации функция AddSuite() увеличивает размер массива m_suites[] на единицу с помощью ArrayResize(). Это сделано намеренно просто: количество наборов тестов в тестовом проекте невелико, и затраты на изменение размера при регистрации незначительны.
RecordResult() — единственная точка входа, через которую каждый результат утверждения поступает в средство запуска тестов. Эта функция увеличивает размер массива m_results[] на единицу и копирует структуру STestResult. Поскольку структуры в массивах MQL5 копируются по значению, сохранённый результат не зависит от исходной структуры в стеке вызывающей функции.
Функция RunAll() проходит по массиву наборов тестов, вызывает для каждого из них Execute(&this) и проверяет значение GetFailCount() после выполнения каждого набора, если m_stop_on_fail имеет значение true. После выполнения всех наборов тестов функция делегирует вывод результатов объекту m_formatter. Отчет и сводка всегда печатаются после завершения всех наборов тестов, а не чередуются с их выполнением, поэтому вывод на вкладке "Эксперты" представляет собой единый непрерывный блок.
_RecordAssertionResult() определена здесь, а не в файле AssertionMacros.mqh, поскольку для её полной реализации необходимо, чтобы CTestRunner был полным типом, в котором доступна функция RecordResult(). Предварительное объявление в файле AssertionMacros.mqh удовлетворяет требованиям компилятора, когда файлы наборов тестов включают этот заголовочный файл до обработки TestRunner.mqh.
MathUtilities.mqh — тестируемый рабочий код
//+------------------------------------------------------------------+ //| MathUtilities.mqh | //| Financial utility functions under test. | //| These functions are the production code being validated by | //| the unit testing framework. | //+------------------------------------------------------------------+ #ifndef MATHUTILITIES_MQH #define MATHUTILITIES_MQH //+------------------------------------------------------------------+ //| NormalizeLot | //| Purpose: Rounds a computed lot size to the nearest valid lot step| //| and clamps the output within min/max volume boundaries. | //+------------------------------------------------------------------+ double NormalizeLot(double lot, double lot_step, double min_lot, double max_lot) { //--- Prevent division by zero validation failures if(lot_step <= 0.0) return(min_lot); //--- Perform mathematical half-up rounding to nearest step volume double normalized = MathFloor(lot / lot_step + 0.5) * lot_step; //--- Enforce structural protective boundary conditions if(normalized < min_lot) normalized = min_lot; if(normalized > max_lot) normalized = max_lot; return(NormalizeDouble(normalized, 2)); } //+------------------------------------------------------------------+ //| CalcLotSize | //| Purpose: Computes position volume sizing based on account equity,| //| risk parameters, and absolute point evaluations. | //+------------------------------------------------------------------+ double CalcLotSize(double equity, double risk_fraction, double sl_points, double point_value, double lot_step, double min_lot, double max_lot, bool &error_flag) { error_flag = false; //--- Validate operational parameter boundaries to prevent arithmetic faults if(equity <= 0.0 || risk_fraction <= 0.0 || sl_points <= 0.0 || point_value <= 0.0) { error_flag = true; return(0.0); } //--- Derive final normalized risk-adjusted trading lot size double risk_amount = equity * risk_fraction; double raw_lot = risk_amount / (sl_points * point_value); return(NormalizeLot(raw_lot, lot_step, min_lot, max_lot)); } //+------------------------------------------------------------------+ //| ScaleATR | //| Purpose: Multiplies an ATR value by a scaling factor and bounds | //| the output to exact fractional point increments. | //+------------------------------------------------------------------+ double ScaleATR(double atr_value, double multiplier, double point) { if(atr_value <= 0.0 || multiplier <= 0.0 || point <= 0.0) return(0.0); double raw = atr_value * multiplier; long points = (long)MathRound(raw / point); return((double)points * point); } //+------------------------------------------------------------------+ //| NormalizeSpread | //| Purpose: Converts raw terminal spread integer units into matching| //| points adjusting for pricing precision matrices. | //+------------------------------------------------------------------+ double NormalizeSpread(long raw_spread, int symbol_digits) { //--- For five-digit and three-digit pairs, raw spread maps directly to points if(symbol_digits == 5 || symbol_digits == 3) return((double)raw_spread); //--- Normalize traditional two-digit symbols (e.g., standard indices/crypto) if(symbol_digits == 2) return((double)raw_spread * 10.0); return((double)raw_spread); } //+------------------------------------------------------------------+ //| CalcPipValue | //| Purpose: Computes the asset-relative monetary value of a single | //| pip calculation unit per standard lot. | //+------------------------------------------------------------------+ double CalcPipValue(double pip_size, double quote_price, bool is_direct_quote) { if(pip_size <= 0.0 || quote_price <= 0.0) return(0.0); //--- Route computation base conditional on quotation mechanics if(is_direct_quote) return(pip_size); return(pip_size / quote_price); } //+------------------------------------------------------------------+ //| RoundLotToStep | //| Purpose: Floor-truncates a trading volume calculation directly | //| down to its nearest valid transaction step increment. | //+------------------------------------------------------------------+ double RoundLotToStep(double lot, double lot_step) { if(lot_step <= 0.0) return(lot); return(NormalizeDouble(MathFloor(lot / lot_step) * lot_step, 2)); } #endif // MATHUTILITIES_MQH //+------------------------------------------------------------------+
Этот файл содержит пять служебных функций, которые представляют собой пример рабочего кода для расчета риска и нормализации, входящего в состав советников. Они вынесены в отдельный заголовочный файл, чтобы наборы тестов могли включать их независимо от заголовочных файлов фреймворка.
Функция NormalizeLot() использует выражение MathFloor(lot / lot_step + 0.5) для округления до ближайшего значения (при равенстве расстояний — вверх): при шаге лота 0.01 значение 0.105 округляется вверх до 0.11. Это отличается от функции RoundLotToStep(), которая использует обычную функцию MathFloor() и всегда округляет в меньшую сторону. Именно различие между этими двумя стратегиями округления лежит в основе категории дефектов, описанной во введении; обе функции тестируются с помощью утверждений, явно проверяющих направление округления.
Функция CalcLotSize() сообщает о недопустимых входных данных через выходной параметр bool &error_flag, а не возвращает сигнальное значение — именно этот подход позволяет применять ASSERT_THROWS. Если любое из четырёх обязательных входных значений равно нулю или отрицательно, функция устанавливает error_flag = true и возвращает 0.0. Тесты 6 и 7 в наборе тестов CLotCalculationTests проверяют оба условия с помощью ASSERT_THROWS для флага и ASSERT_NEAR для возвращаемого значения.
Функция ScaleATR() преобразует исходный результат в целое число пунктов, а затем обратно в ценовое значение, устраняя тем самым остаток меньше одного пункта, возникающий при вычислениях с плавающей запятой; иначе этот остаток накапливался бы, когда множитель ATR не является точной двоичной дробью.
Функция CalcPipValue() выполняет ветвление в зависимости от значения is_direct_quote. Для пары с прямой котировкой, такой как EURUSD, где валюта счета является валютой котировки, стоимость пункта в валюте счета равна просто величине pip_size. Для пары с обратной котировкой, такой как USDJPY, стоимость пункта равна pip_size / quote_price, поскольку валюта котировки не совпадает с валютой счёта и для пересчёта необходимо использовать цену.
LotCalculationTests.mqh — первый набор тестов с намеренно внедренным дефектом
//+------------------------------------------------------------------+ //| LotCalculationTests.mqh | //| CLotCalculationTests: validates CalcLotSize(), NormalizeLot(), | //| and RoundLotToStep() with deterministic input/output pairs. | //| | //| Test 13 (TestLotRounding_Defect) contains a deliberately | //| injected error in the expected value to demonstrate that the | //| framework detects exactly one failure with line attribution. | //+------------------------------------------------------------------+ #ifndef LOTCALCULATIONTESTS_MQH #define LOTCALCULATIONTESTS_MQH #include "ITestSuite.mqh" #include "TestRunner.mqh" #include "MathUtilities.mqh" //+------------------------------------------------------------------+ //| Class CLotCalculationTests | //| Purpose: Test suite specialized in validating risk algorithms, | //| clamping logic boundaries, and volume step adjustments. | //+------------------------------------------------------------------+ class CLotCalculationTests : public ITestSuite { public: //--- Returns the custom descriptive identifier of this suite virtual string GetName(void) { return("CLotCalculationTests"); } //--- Executes all internal assertion vectors sequentially virtual void Execute(CTestRunner *runner); }; //+------------------------------------------------------------------+ //| Execute | //| Purpose: Houses and processes all structural regression tests. | //+------------------------------------------------------------------+ void CLotCalculationTests::Execute(CTestRunner *runner) { string sn = GetName(); //--- Test 1: Standard lot normalization, half-up rounding { double result = NormalizeLot(0.1049, 0.01, 0.01, 100.0); ASSERT_NEAR(runner, sn, "NormalizeLot_StandardRound", 0.10, result, 0.0001); } //--- Test 2: Rounding up at midpoint { double result = NormalizeLot(0.1050, 0.01, 0.01, 100.0); ASSERT_NEAR(runner, sn, "NormalizeLot_MidpointUp", 0.11, result, 0.0001); } //--- Test 3: Clamp to minimum lot { double result = NormalizeLot(0.001, 0.01, 0.01, 100.0); ASSERT_NEAR(runner, sn, "NormalizeLot_ClampMin", 0.01, result, 0.0001); } //--- Test 4: Clamp to maximum lot { double result = NormalizeLot(150.0, 0.01, 0.01, 100.0); ASSERT_NEAR(runner, sn, "NormalizeLot_ClampMax", 100.0, result, 0.0001); } //--- Test 5: CalcLotSize standard case //--- equity=10000, risk=1%, sl=50pts, point_value=10.0 //--- expected: (10000*0.01)/(50*10.0) = 100/500 = 0.20 { bool err = false; double result = CalcLotSize(10000.0, 0.01, 50.0, 10.0, 0.01, 0.01, 100.0, err); ASSERT_FALSE(runner, sn, "CalcLotSize_NoError", err); ASSERT_NEAR(runner, sn, "CalcLotSize_Standard", 0.20, result, 0.0001); } //--- Test 6: CalcLotSize with zero equity triggers error { bool err = false; double result = CalcLotSize(0.0, 0.01, 50.0, 10.0, 0.01, 0.01, 100.0, err); ASSERT_THROWS(runner, sn, "CalcLotSize_ZeroEquity_Throws", err); ASSERT_NEAR(runner, sn, "CalcLotSize_ZeroEquity_Returns0", 0.0, result, 0.0001); } //--- Test 7: CalcLotSize with zero stop-loss triggers error { bool err = false; double result = CalcLotSize(10000.0, 0.01, 0.0, 10.0, 0.01, 0.01, 100.0, err); ASSERT_THROWS(runner, sn, "CalcLotSize_ZeroSL_Throws", err); } //--- Test 8: CalcLotSize small account risk //--- equity=1000, risk=0.5%, sl=20pts, point_value=10.0 //--- expected: (1000*0.005)/(20*10.0) = 5/200 = 0.025 -> rounds to 0.03 { bool err = false; double result = CalcLotSize(1000.0, 0.005, 20.0, 10.0, 0.01, 0.01, 100.0, err); ASSERT_NEAR(runner, sn, "CalcLotSize_SmallAccount", 0.03, result, 0.0001); } //--- Test 9: RoundLotToStep floor rounding { double result = RoundLotToStep(0.1099, 0.01); ASSERT_NEAR(runner, sn, "RoundLotToStep_Floor", 0.10, result, 0.0001); } //--- Test 10: RoundLotToStep exact step { double result = RoundLotToStep(0.12, 0.01); ASSERT_NEAR(runner, sn, "RoundLotToStep_Exact", 0.12, result, 0.0001); } //--- Test 11: RoundLotToStep at lower boundary of next step { double result = RoundLotToStep(0.1200, 0.01); ASSERT_NEAR(runner, sn, "RoundLotToStep_LowerBoundary", 0.12, result, 0.0001); } //--- Test 12: NormalizeLot with non-standard lot step (0.1) { double result = NormalizeLot(0.74, 0.10, 0.10, 10.0); ASSERT_NEAR(runner, sn, "NormalizeLot_LargeStep", 0.70, result, 0.0001); } //--- Test 13: DELIBERATE DEFECT UNIT //--- RoundLotToStep(0.125, 0.01) should return 0.12 (floor truncation) //--- The expected baseline metric below is intentionally forced to 0.13 to //--- verify the testing framework catches failures with exact line mapping. { double result = RoundLotToStep(0.125, 0.01); ASSERT_NEAR(runner, sn, "TestLotRounding_Defect", 0.13, result, 0.0001); } } #endif // LOTCALCULATIONTESTS_MQH //+------------------------------------------------------------------+
CLotCalculationTests регистрирует тринадцать утверждений в четырёх функциях. Тесты пронумерованы и сопровождаются комментариями в коде, в которых перед утверждением указываются входные данные, ход вычислений и ожидаемый результат. Такой стиль комментирования выбран намеренно: если ожидаемое значение неочевидно, тест без обоснования его расчёта невозможно проверить и ему нельзя доверять.
В тесте 5 результат одного вызова функции проверяется двумя утверждениями. Макрос ASSERT_FALSE, применённый к флагу err, проверяет, что ошибка не возникла, а макрос ASSERT_NEAR, применённый к результату, проверяет вычисленный объём. Оба утверждения необходимы: функция, которая возвращает правильный размер лота, но при этом некорректно устанавливает флаг ошибки, содержит дефект, а функция, которая корректно устанавливает флаг ошибки, но возвращает неправильный размер лота, также содержит дефект.
В тесте № 13 предусмотрен намеренный сбой. Функция RoundLotToStep(0.125, 0.01) возвращает 0,12, поскольку MathFloor(0.125 / 0.01) равно 12, а 12 * 0.01 равно 0,12. Ожидаемое значение в утверждении равно 0,13, что неверно. Когда фреймворк запускает этот тест, значение MathAbs(0.13 - 0.12) равно 0,01, что превышает допуск 0,0001, поэтому ASSERT_NEAR сохраняет результат TEST_FAIL. В строке [FAIL] на вкладке "Эксперты" будут указаны значения Expected=0.13000 | Actual=0.12000 и точный номер строки вызова ASSERT_NEAR, что демонстрирует точность привязки сбоя к месту в исходном коде, обеспечиваемую фреймворком.
ATRScalingTests.mqh — второй набор тестов
//+------------------------------------------------------------------+ //| ATRScalingTests.mqh | //| CATRScalingTests: validates ScaleATR(), NormalizeSpread(), | //| and CalcPipValue() with deterministic input/output pairs. | //+------------------------------------------------------------------+ #ifndef ATRSCALINGTESTS_MQH #define ATRSCALINGTESTS_MQH #include "ITestSuite.mqh" #include "TestRunner.mqh" #include "MathUtilities.mqh" //+------------------------------------------------------------------+ //| Class CATRScalingTests | //| Purpose: Test suite specialized in validating indicator scaling, | //| spread normalizations, and multi-currency pip values. | //+------------------------------------------------------------------+ class CATRScalingTests : public ITestSuite { public: //--- Returns the custom descriptive identifier of this suite virtual string GetName(void) { return("CATRScalingTests"); } //--- Executes all internal asset validation vectors sequentially virtual void Execute(CTestRunner *runner); }; //+------------------------------------------------------------------+ //| Execute | //| Purpose: Houses and processes all scaling regression test steps. | //+------------------------------------------------------------------+ void CATRScalingTests::Execute(CTestRunner *runner) { string sn = GetName(); //--- Test 1: ScaleATR standard case //--- atr=0.0015, multiplier=1.5, point=0.00001 //--- raw = 0.0015 * 1.5 = 0.00225 //--- points = round(0.00225 / 0.00001) = 225 //--- result = 225 * 0.00001 = 0.00225 { double result = ScaleATR(0.0015, 1.5, 0.00001); ASSERT_NEAR(runner, sn, "ScaleATR_Standard", 0.00225, result, 0.000001); } //--- Test 2: ScaleATR with integer multiplier //--- atr=0.0010, multiplier=2.0, point=0.00001 //--- result = 0.0020 { double result = ScaleATR(0.0010, 2.0, 0.00001); ASSERT_NEAR(runner, sn, "ScaleATR_IntegerMultiplier", 0.00200, result, 0.000001); } //--- Test 3: ScaleATR returns zero on invalid ATR boundary condition { double result = ScaleATR(0.0, 1.5, 0.00001); ASSERT_NEAR(runner, sn, "ScaleATR_ZeroATR", 0.0, result, 0.000001); } //--- Test 4: ScaleATR returns zero on zero multiplier boundary condition { double result = ScaleATR(0.0015, 0.0, 0.00001); ASSERT_NEAR(runner, sn, "ScaleATR_ZeroMultiplier", 0.0, result, 0.000001); } //--- Test 5: ScaleATR three-digit point precision (e.g., JPY currency pairs) //--- atr=1.50 (USDJPY in pips), multiplier=2.0, point=0.001 //--- raw = 3.0, points = 3000, result = 3.0 { double result = ScaleATR(1.50, 2.0, 0.001); ASSERT_NEAR(runner, sn, "ScaleATR_JPYPair", 3.0, result, 0.001); } //--- Test 6: NormalizeSpread five-digit broker raw passthrough matrix { double result = NormalizeSpread(12, 5); ASSERT_NEAR(runner, sn, "NormalizeSpread_FiveDigit", 12.0, result, 0.0001); } //--- Test 7: NormalizeSpread two-digit broker scaling multiplier verification { double result = NormalizeSpread(12, 2); ASSERT_NEAR(runner, sn, "NormalizeSpread_TwoDigit", 120.0, result, 0.0001); } //--- Test 8: NormalizeSpread three-digit broker passthrough matrix { double result = NormalizeSpread(8, 3); ASSERT_NEAR(runner, sn, "NormalizeSpread_ThreeDigit", 8.0, result, 0.0001); } //--- Test 9: CalcPipValue direct quote valuation model (e.g., EURUSD) //--- pip_size=0.0001, quote=1.0850, direct=true -> result = 0.0001 { double result = CalcPipValue(0.0001, 1.0850, true); ASSERT_NEAR(runner, sn, "CalcPipValue_DirectQuote", 0.0001, result, 0.000001); } //--- Test 10: CalcPipValue indirect quote valuation model (e.g., USDJPY) //--- pip_size=0.01, quote=149.50, direct=false -> result = 0.01 / 149.50 { double result = CalcPipValue(0.01, 149.50, false); ASSERT_NEAR(runner, sn, "CalcPipValue_IndirectQuote", 0.01 / 149.50, result, 0.0000001); } //--- Test 11: CalcPipValue dynamic protective safety boundary for zero pip sizes { double result = CalcPipValue(0.0, 1.0850, true); ASSERT_NEAR(runner, sn, "CalcPipValue_ZeroPipSize", 0.0, result, 0.000001); } //--- Test 12: CalcPipValue dynamic protective safety boundary for zero quotation price { double result = CalcPipValue(0.0001, 0.0, false); ASSERT_NEAR(runner, sn, "CalcPipValue_ZeroQuote", 0.0, result, 0.000001); } } #endif // ATRSCALINGTESTS_MQH //+------------------------------------------------------------------+
CATRScalingTests проверяет три функции с помощью двенадцати утверждений. Тесты 3 и 4 проверяют граничные условия функции ScaleATR(): при нулевом значении ATR или нулевом множителе функция должна возвращать 0,0, а не передавать дальше бессмысленный масштабированный результат. Тесты с 6 по 8 охватывают все три ветви функции NormalizeSpread(), зависящие от symbol_digits, — для пяти, двух и трёх знаков после запятой, — обеспечивая корректную обработку каждого используемого брокерами формата точности. Тесты 9 и 10 охватывают обе ветви функции CalcPipValue() с конкретными ожидаемыми значениями; ожидаемое значение для обратной котировки записано в виде выражения 0.01 / 149.50, а не заранее вычисленного литерала, что делает связь между входными и выходными данными явно видимой в исходном коде.
UnitTestFramework.mq5 — скрипт точки входа
//+------------------------------------------------------------------+ //| UnitTestFramework.mq5 | //| Entry-point script: registers all test suites, executes the | //| full test run, and prints the structured report and summary | //| to the Experts tab. | //| | //| Expected output: 27 total assertions, 26 PASS, 1 FAIL. | //| The single failure is TestLotRounding_Defect in | //| CLotCalculationTests, line 213 of LotCalculationTests.mqh, | //| demonstrating deliberate defect injection and line attribution. | //+------------------------------------------------------------------+ #property script_show_inputs #include <Compile_Time_Unit_Testing_Framework/TestRunner.mqh> #include <Compile_Time_Unit_Testing_Framework/LotCalculationTests.mqh> #include <Compile_Time_Unit_Testing_Framework/ATRScalingTests.mqh> //--- Input parameters input bool inp_stop_on_first_failure = false; // Halt Test Execution After First Failure input bool inp_enable_test_logging = true; // Enable Detailed Test Output //--- Global test runner tracking pointer CTestRunner *g_test_runner = NULL; //+------------------------------------------------------------------+ //| Script program start function | //+------------------------------------------------------------------+ void OnStart(void) { Print("=========================================="); Print("=== MQL5 Unit Testing Framework ==="); Print("=========================================="); PrintFormat("Stop on failure: %s", inp_stop_on_first_failure ? "enabled" : "disabled"); //--- Construct dynamic test runner instance g_test_runner = new CTestRunner(inp_stop_on_first_failure); if(CheckPointer(g_test_runner) != POINTER_DYNAMIC) { Print("[UnitTestFramework] Failed to allocate CTestRunner."); return; } //--- Register test suites to the processing pipeline //--- Note: AddSuite() transfers heap ownership; do not manually delete these pointers g_test_runner.AddSuite(new CLotCalculationTests()); g_test_runner.AddSuite(new CATRScalingTests()); //--- Execute all registered suites and stream telemetry to formatting outputs g_test_runner.RunAll(); //--- Print final pass/fail summary metrics to the terminal log PrintFormat("[UnitTestFramework] Execution complete. Total=%s | Pass=%s | Fail=%s", IntegerToString(g_test_runner.GetTotalCount()), IntegerToString(g_test_runner.GetPassCount()), IntegerToString(g_test_runner.GetFailCount())); //--- Perform deterministic heap cleanup to eliminate dangling reference risks delete g_test_runner; g_test_runner = NULL; Print("[UnitTestFramework] Cleanup complete."); } //+------------------------------------------------------------------+
OnStart() задаёт полную последовательность выполнения: создание средства запуска тестов, регистрация наборов тестов, выполнение всех утверждений, вывод сводки результатов и удаление средства запуска тестов. Регистрация набора тестов с помощью AddSuite(new CLotCalculationTests()) передаёт средству запуска тестов владение выделенным в куче объектом; оператор delete в конце OnStart() вызывает деструктор средства запуска тестов, который удаляет все зарегистрированные наборы тестов. Разработчик не должен отдельно применять оператор delete к указателям на наборы тестов после вызова AddSuite().
Эти два входных параметра отображаются в диалоговом окне входных параметров скрипта. Параметр inp_stop_on_first_failure управляет прекращением выполнения при первом сбое (fail-fast), описанным в RunAll(). Параметр inp_enable_test_logging добавлен как настраиваемая опция для документирования этой возможности; текущий модуль форматирования всегда выводит результаты. Ожидаемый результат указан в заголовке файла: 27 утверждений, из которых 26 выполнены успешно, и ровно один сбой в TestLotRounding_Defect, что демонстрирует способность фреймворка правильно выявлять дефекты и определять их расположение.
Взаимодействие на этапе компиляции и во время выполнения
Уровень макросов и уровень времени выполнения работают на разных этапах, но взаимодействуют через структуру STestResult. На этапе компиляции препроцессор развертывает каждый макрос утверждения и подставляет в месте вызова литеральные значения __FILE__ и __LINE__. Во время выполнения вычисляется булево значение утверждения, и результат вместе с информацией о файле и строке, полученной на этапе компиляции, сохраняется в виде структуры.
В результате отчеты об ошибках содержат точное имя файла и номер строки без анализа стека во время выполнения. Номер строки в сообщении об ошибке соответствует той самой строке, в которой в файле набора тестов был записан макрос ASSERT_EQ или ASSERT_NEAR. Разработчик может сразу перейти к этой строке в MetaEditor без дополнительного поиска.
Ограничение этого подхода заключается в том, что __LINE__ фиксирует номер строки вызова макроса, а не строки внутри тестируемой функции, где был выполнен ошибочный расчет. Тестовый фреймворк определяет, какое утверждение не прошло проверку; он не определяет, какая именно внутренняя ветвь в функции привела к получению неверного значения. Для этого необходимо проанализировать код функции с учетом ожидаемого и фактического значений, указанных в утверждении.
Расширяемость и ограничения
Для добавления нового типа утверждения необходимо добавить одно определение макроса в файл AssertionMacros.mqh и убедиться, что макрос вызывает _RecordAssertionResult() с правильными аргументами: статусом, строковыми представлениями ожидаемого и фактического значений, именем файла и номером строки. Никакие другие файлы не требуют изменения. Средство запуска тестов, модуль форматирования и все существующие наборы тестов продолжают работать без изменений.
Чтобы добавить новый набор тестов, необходимо написать класс, реализующий интерфейс ITestSuite, создать его экземпляр и зарегистрировать его с помощью метода AddSuite() в обработчике OnStart() скрипта. Никаких изменений в средстве запуска тестов или модуле форматирования не требуется.
Основным ограничением данного фреймворка является отсутствие функции автоматического обнаружения тестов. В фреймворках тестирования на C++, таких как Google Test, функции, имена которых начинаются с определённых префиксов, регистрируются автоматически. В MQL5 отсутствует механизм рефлексии, который бы это поддерживал. Каждый набор тестов должен быть явно зарегистрирован. Если кодовая база содержит десятки наборов тестов, список их явной регистрации в OnStart() приходится поддерживать вручную.
Еще одно ограничение связано с имитацией ASSERT_THROWS. Поскольку MQL5 не поддерживает структурированное распространение исключений, используемый здесь подход требует, чтобы тестируемая функция сообщала о некорректных входных данных с помощью возвращаемых значений или глобальных флагов, а не путём выбрасывания исключения. Функции, которые при некорректных входных данных просто выдают неверный результат, не устанавливая при этом никакого индикатора ошибки, нельзя тестировать с помощью ASSERT_THROWS. Их выходные значения необходимо проверять с помощью утверждений ASSERT_NEAR или ASSERT_TRUE.

Рис. 3. Вид отчёта на вкладке "Эксперты" с результатами тестирования, отформатированными с помощью CReportFormatter
Накладные расходы фреймворка
Фреймворк потребляет объем памяти, пропорциональный количеству зарегистрированных результатов тестирования. Каждая структура STestResult содержит три строки (название теста, ожидаемое значение, фактическое значение), два целых числа (статус, номер строки) и одну строку (путь к файлу). На 64-разрядной платформе при типичной длине строк каждая структура занимает примерно от 200 до 400 байт. Набор тестов с пятьюдесятью утверждениями занимает менее двадцати килобайт. При полном прогоне тестов, включающем двести утверждений в десяти наборах тестов, общий объём выделяемой памяти составляет менее ста килобайт, что пренебрежимо мало по сравнению с доступной памятью терминала.
Затраты на виртуальную диспетчеризацию составляют две операции разыменования указателя на каждый вызов Execute(); на каждый набор тестов приходится один такой вызов. При десяти наборах тестов с пятьюдесятью утверждениями в каждом общие накладные расходы на виртуальную диспетчеризацию сводятся к десяти вызовам, добавляющим лишь наносекунды к общему времени выполнения. Основную долю затрат составляют операции со строками внутри функции _RecordAssertionResult(): для построения строк с ожидаемым и фактическим значениями требуются вызовы функций DoubleToString() или IntegerToString(), а также конкатенация строк. При прогоне тестов с двумястами утверждениями выполняются четыреста операций со строками, которые на любом современном оборудовании занимают значительно меньше одной миллисекунды.
Данный фреймворк не подходит для тестирования производительности, однако он и не предназначен для этих целей. Фреймворк выполняется в контексте скрипта MQL5, запускаемого перед развёртыванием, а не как часть рабочего кода обработки тиков советника.
Заключение
Легкий фреймворк для модульного тестирования, построенный на основе макросов препроцессора, полиморфного интерфейса набора тестов и средства структурированной записи результатов, обеспечивает детерминированную проверку служебных функций MQL5 без использования внешних инструментов и зависимостей от других фреймворков. Макросы утверждений фиксируют расположение в исходном коде на этапе компиляции, благодаря чему отчёты об ошибках указывают точную строку невыполненного утверждения без необходимости анализа стека во время выполнения. Средство запуска тестов и модуль форматирования отчётов объединяют результаты всех зарегистрированных наборов тестов и формируют структурированную сводку, в которой указывается доля успешных тестов и перечисляются все ошибки с соответствующими ожидаемыми и фактическими значениями.
Этот фреймворк соответствует принципу "открытости-закрытости": новые типы утверждений и наборы тестов добавляются без изменения существующих компонентов. При добавлении новых компонентов существующие реализации наборов тестов и логика агрегации средства запуска тестов остаются без изменений.
Практическим ограничением является то, что регистрация наборов тестов осуществляется вручную. Автоматическое обнаружение в MQL5 невозможно без средств рефлексии, которых в этом языке нет. Для команд, занимающихся сопровождением обширных библиотек служебных функций, список регистраций в тестовом скрипте становится вторичным объектом сопровождения, который необходимо обновлять одновременно с самими наборами тестов. Эти затраты приемлемы по сравнению с альтернативой — вводом в эксплуатацию непроверенных функций определения размера лота и расчета риска.
Программы, использованные в статье:
| # | Название | Тип | Описание |
|---|---|---|---|
| 1 | TestStatus.mqh | файл включения | Перечисление ENUM_TEST_STATUS и структура STestResult, содержащая название теста, статус, ожидаемое значение, фактическое значение, файл и номер строки |
| 2 | AssertionMacros.mqh | файл включения | Макросы ASSERT_EQ, ASSERT_NEAR, ASSERT_TRUE, ASSERT_FALSE и ASSERT_THROWS со вспомогательной функцией _RecordAssertionResult(), обеспечивающей их работу во время выполнения |
| 3 | ITestSuite.mqh | файл включения | Абстрактный интерфейс, объявляющий методы GetName() и Execute(CTestRunner*) в качестве контракта для всех реализаций наборов тестов |
| 4 | TestRunner.mqh | файл включения | CTestRunner, владеющий зарегистрированными экземплярами ITestSuite*, последовательно запускающий их, агрегирующий записи STestResult и делегирующий форматирование и вывод результатов классу CReportFormatter |
| 5 | ReportFormatter.mqh | файл включения | CReportFormatter выводит структурированные данные по каждому тесту и сводные результаты на вкладку "Эксперты" с расчетом доли успешных тестов |
| 6 | MathUtilities.mqh | файл включения | Проверяемые служебные функции: CalcLotSize(), NormalizeLot(), ScaleATR(), NormalizeSpread(), CalcPipValue() |
| 7 | LotCalculationTests.mqh | файл включения | CLotCalculationTests, реализующий интерфейс ITestSuite с утверждениями для функций CalcLotSize() и NormalizeLot(), включая намеренно внедренный дефект округления |
| 8 | ATRScalingTests.mqh | файл включения | CATRScalingTests, реализующий интерфейс ITestSuite с утверждениями для функций ScaleATR(), NormalizeSpread() и CalcPipValue() |
| 9 | UnitTestFramework.mq5 | Скрипт | Скрипт точки входа, регистрирующий все наборы тестов, запускающий полный цикл тестирования и выводящий структурированный отчет и сводку на вкладку "Эксперты" |
| 10 | Compile_Time_Unit_Testing_Framework.zip | Архив в формате ZIP | ZIP-архив, содержащий все приложенные файлы и их пути относительно корневой папки терминала. |
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/22967
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.