preview
Building a Compile-Time Unit Testing Framework in MQL5 Using Preprocessor Assertions

Building a Compile-Time Unit Testing Framework in MQL5 Using Preprocessor Assertions

MetaTrader 5Trading |
391 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Introduction

MQL5 lacks a native unit testing infrastructure—there are no built-in test runners, assertion libraries, or test suites. Consequently, developers usually validate core utility functions (like lot-size calculators or pip converters) by visually checking Experts tab logs during live or demo runs. The danger here is that subtle mathematical errors often trigger only under specific input combinations, allowing them to slip into production completely unnoticed.

Consider a lot-size calculator that computes position volume based on account equity, risk percentage, and stop-loss distance. The function might work perfectly for typical inputs, yet fail silently when the calculated lot falls between two valid lot-step increments and rounds in the wrong direction.

This type of defect will not crash the EA or trigger a terminal error code. It simply submits an order with a volume that is slightly off, quietly distorting the strategy's risk profile over hundreds of trades. Without a formal assertion checking exact outputs against known inputs, these compounding defects remain completely invisible to casual observation.

To bridge this gap, this article builds a native, zero-dependency testing framework. The architecture relies on three structural components:

  • Preprocessor Assertion Macros: Automatically capture the exact source file and line number at the moment a test fails.
  • Interface-Based Test Suites: Isolate and group related test logic to maintain a clean development workspace.
  • A Central Test Runner: Aggregates individual test results and prints a structured pass/fail summary.

The entire system executes as a standard MQL5 script, requires no external tools, and outputs directly to the terminal's Experts tab.

Test framework execution flow

Figure 1: Test Framework Execution Flow. The runner registers and runs each suite; suites invoke the system under test and check outputs through assertion macros, which emit STestResult records; the formatter then prints a pass/fail summary to the Experts tab.


Why Utility Functions Ship With Hidden Defects

The specific error categories that unit tests catch most reliably in MQL5 utility code fall into four groups.

Floating-point rounding errors. MQL5 uses double precision for all financial calculations. Operations that appear mathematically exact, such as 0.1 + 0.2, produce values like 0.30000000000000004 in IEEE 754 representation. A lot calculator that compares the computed volume directly with a target using == will fail on inputs where the computation path accumulates representation error. The ASSERT_NEAR macro with an explicit tolerance is the only correct assertion for floating-point output.

Lot-step normalization errors. Brokers enforce a minimum lot step, typically 0.01. A computed lot of 0.105 must be rounded to either 0.10 or 0.11. The direction of rounding is a business decision, but it must be consistent. An incorrect rounding direction changes the risk profile. Without an assertion that specifies the expected normalized output for a given input, the error propagates silently.

Edge-case boundary failures. A pip-value function that works for EURUSD with a standard lot fails for USDJPY where the pip value calculation requires dividing by the current ask price rather than multiplying. A spread normalizer that works for five-digit brokers fails for three-digit brokers where the multiplier is different. These edge cases require explicit test inputs that force the boundary conditions.

Silent overflow and underflow. A risk percentage converter that receives a zero account balance or a zero stop-loss distance may produce a division-by-zero result that MQL5 represents as DBL_MAX or negative infinity rather than raising an error. Without an assertion that verifies the output is within a valid range, these inputs produce silently corrupted lot sizes.

Typical Test Targets and Their Risk Profiles

Function Common Defect Detection Method
Lot Calculator Incorrect lot-step rounding direction ASSERT_NEAR with known input/output pairs
ATR Scaler Multiplier applied before normalization ASSERT_EQ with fixed ATR and multiplier
Spread Normalizer Wrong digit-count assumption ASSERT_EQ with five-digit and three-digit inputs
Pip Value Calculator Currency pair inversion error ASSERT_NEAR with cross-pair inputs
Rounding Algorithm Off-by-one at lot-step boundary ASSERT_EQ with boundary-crossing inputs
Risk Percentage Converter Zero-denominator silent corruption ASSERT_TRUE on output range validity


Assertion Mechanics and Preprocessor Integration

The assertion macros use MQL5's __FILE__ and __LINE__ preprocessor constants to capture the source location at the point of the assertion call. These constants are resolved at compile time. Therefore, the reported line number matches the assertion call site in the source file, regardless of the call depth between the macro and the runner.

The macros do not use exceptions in the C++ sense. MQL5 does not support structured exception handling with throw and catch across arbitrary call frames. Instead, ASSERT_THROWS checks a pre/post state flag around the call and simulates exception detection via a boolean sentinel. The function under test is expected to set a global error flag or return a sentinel value on invalid input, and the assertion verifies that this occurred.

Each assertion macro calls a shared internal function _RecordAssertionResult() that stores the result as an STestResult struct in the active suite's result array. The macro is the only entry point for this function; direct calls to _RecordAssertionResult() from user code are not intended and produce no additional benefit.

Assertion Types and Their Semantics

Assertion Comparison Type Appropriate Use
ASSERT_EQ(expected, actual) Exact equality (==) Integer outputs, string outputs, boolean flags
ASSERT_NEAR(expected, actual, tolerance) MathAbs(expected - actual) <= tolerance All floating-point financial calculations
ASSERT_TRUE(condition) condition == true Range validity, flag states, existence checks
ASSERT_FALSE(condition) condition == false Error-state absence, boundary exclusions
ASSERT_THROWS(call, flag_var) flag_var == true after call Invalid input handling, sentinel return verification


Test Suite Architecture

Each test suite is a class that implements ITestSuite. The interface requires two methods: GetName(), which returns a human-readable label for the suite, and Execute(CTestRunner *runner), which calls the assertion macros with the runner as context. Suites are registered with the test runner before execution begins. The runner iterates its registered suites in registration order, calls Execute() on each, and accumulates results.

Suite registration uses a flat pointer array, not a linked list, because MQL5's array operations on an ITestSuite*[] are straightforward and do not require heap-managed node objects. The runner owns all registered suite pointers and deletes them in its destructor.

Test Suite Architecture

Figure 2: Test Suite Architecture. Each concrete suite implements the ITestSuite interface, which defines two methods: GetName() for a human-readable label and Execute(CTestRunner *runner) for invoking the assertion macros. Suites register into the runner's flat ITestSuite*[] pointer array — chosen over a linked list to avoid heap-managed node objects. At execution, CTestRunner iterates the registered suites in order, calls Execute() on each, and accumulates the returned results.


Framework Components

Framework Component Responsibilities

Component Responsibility
TestStatus.mqh Defines ENUM_TEST_STATUS and STestResult struct
AssertionMacros.mqh Defines all five assertion macros and _RecordAssertionResult()
ITestSuite.mqh Abstract interface requiring GetName() and Execute()
TestRunner.mqh Owns suites, executes them, aggregates STestResult records
ReportFormatter.mqh Formats and prints structured pass/fail output to the Experts tab
MathUtilities.mqh Contains the utility functions under test
LotCalculationTests.mqh ITestSuite implementation for lot-sizing validation
ATRScalingTests.mqh ITestSuite implementation for ATR-scaling validation

Test Status Enumeration

Status Numeric Value Meaning
TEST_PASS 0 Assertion condition satisfied
TEST_FAIL 1 Assertion condition not satisfied
TEST_ERROR 2 Unexpected runtime problem during test execution


Code Walkthrough: Every File Explained

The following sections present each framework file in full, with commentary on what each block does and why it is structured that way.

TestStatus.mqh — Result Enumeration and Data Carrier

//+------------------------------------------------------------------+
//|                                                   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 assigns a typed integer to each possible outcome. Using an enum rather than a raw integer prevents a caller from passing an arbitrary number into a result record. The three states map to three distinct display paths inside CReportFormatter::PrintReport(): a [PASS] line, a [FAIL] line with expected/actual/file/line attribution, and an [ERROR] line for runtime problems.

STestResult is the data carrier that travels from the assertion macro call site through _RecordAssertionResult() and into the runner's internal m_results[] array. Every field is meaningful at report time. file and line are filled at compile time by the preprocessor constants __FILE__ and __LINE__ expanded inside the assertion macros. The constructor initializes all fields to safe empty defaults so that a freshly declared STestResult is never in an undefined state when it is passed by reference into RecordResult().

ITestSuite.mqh — The Polymorphic Suite Contract

//+------------------------------------------------------------------+
//|                                                   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 is a pure abstract class with two pure virtual methods. GetName() returns the label that appears in every [PASS] and [FAIL] line under the Suite: field. Execute() receives a pointer to the live CTestRunner instance, which the assertion macros use to call RecordResult(). The virtual destructor is necessary because the runner holds ITestSuite* pointers and calls delete on them; without a virtual destructor the derived class destructor would not be called, leaking any resources the concrete suite owns. The forward declaration of CTestRunner breaks the circular include dependency that would otherwise form between this file and TestRunner.mqh.

Adding a new test suite to the framework means writing a class that inherits from ITestSuite, implementing the two methods, and registering an instance with AddSuite(). No existing file requires any modification.

AssertionMacros.mqh — Compile-Time Source Attribution

//+------------------------------------------------------------------+
//|                                              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
//+------------------------------------------------------------------+

Each macro follows the same pattern. The preprocessor expands the macro body at the call site, which is the key design decision. Because __FILE__ and __LINE__ are substituted at the call site during expansion, they record the location in the test suite file where the developer wrote the assertion, not the location inside _RecordAssertionResult() where the data is stored. This is what makes failure reports directly navigable in MetaEditor.

ASSERT_EQ uses == and casts both sides to double before converting to string for display. This makes it safe for integer, enum, and boolean inputs without requiring type-specific overloads. ASSERT_NEAR computes MathAbs(expected - actual) and compares it against the tolerance, which is the correct approach for all IEEE 754 floating-point financial values. ASSERT_TRUE and ASSERT_FALSE evaluate a boolean condition and store "true" or "false" as the actual value string, making the failure message immediately readable. ASSERT_THROWS checks whether the supplied flag variable is true after the function call that precedes it in the test body, simulating exception detection through a sentinel boolean since MQL5 has no structured exception handling.

The forward declaration of _RecordAssertionResult() at the top of this file satisfies the compiler when the macro is expanded in a suite file. The full definition appears in TestRunner.mqh, where CTestRunner is fully defined and RecordResult() is accessible.

ReportFormatter.mqh — Structured Experts Tab Output

//+------------------------------------------------------------------+
//|                                              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() receives the m_results[] array by reference and iterates it once. The switch on results[i].status routes each record to one of three output formats. A [PASS] line is intentionally minimal — it contains only the test name and suite name, keeping the Experts tab readable when most tests pass. A [FAIL] line appends Expected, Actual, File, and Line fields, giving the developer every piece of information needed to locate and fix the defect without further investigation. A [ERROR] line uses the actual field to carry the runtime error description.

PrintSummary() guards against division by zero when total is zero, which would occur if RunAll() was called on a runner with no registered suites. The pass rate is computed as a double percentage and printed with two decimal places.

CReportFormatter is embedded as a value member inside CTestRunner, not allocated on the heap, so it requires no explicit construction or deletion.

TestRunner.mqh — Suite Ownership, Execution, and Aggregation

//+------------------------------------------------------------------+
//|                                                   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 is the central authority for all framework operations. m_suites[] is a flat pointer array; ownership of every registered ITestSuite* transfers to the runner at AddSuite(). The destructor iterates this array and calls delete only on pointers that pass CheckPointer() == POINTER_DYNAMIC, which prevents a double-free if a non-dynamic pointer was somehow registered.

AddSuite() grows m_suites[] by one with ArrayResize() on each registration. This is intentionally simple: the number of suites in a test project are small and the resizing cost at registration time is irrelevant.

RecordResult() is the single entry point through which every assertion result enters the runner. It grows m_results[] by one and copies the STestResult struct. Because MQL5 arrays of structs copy by value, the stored result is independent of the original struct on the calling stack.

RunAll() iterates the suite array, calls Execute(&this) on each, and checks GetFailCount() after each suite when m_stop_on_fail is true. After all suites have executed, it delegates to m_formatter for output. The report and summary are always printed after all suites complete, not interleaved with suite execution, so the Experts tab output is a single contiguous block.

_RecordAssertionResult() is defined here rather than in AssertionMacros.mqh because its full implementation requires CTestRunner to be a complete type with RecordResult() accessible. The forward declaration in AssertionMacros.mqh satisfies the compiler when the header is included by suite files before TestRunner.mqh is processed.

MathUtilities.mqh — The Production Code Under Test

//+------------------------------------------------------------------+
//|                                                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
//+------------------------------------------------------------------+

This file contains five production utility functions that represent the kind of risk-calculation and normalization code that ships inside Expert Advisors. They are isolated in a separate header so the test suites can include them independently of the framework headers.

NormalizeLot() uses MathFloor(lot / lot_step + 0.5) to achieve half-up rounding, which rounds 0.105 up to 0.11 when the step is 0.01. This is different from RoundLotToStep(), which uses plain MathFloor() and always truncates downward. The distinction between these two rounding strategies is the exact defect category described in the introduction, and both functions are tested with assertions that verify the rounding direction explicitly.

CalcLotSize() communicates invalid input through the bool &error_flag output parameter rather than a return-value sentinel, which is the pattern that makes ASSERT_THROWS applicable. When any of the four required inputs is zero or negative, the function sets error_flag = true and returns 0.0. Tests 6 and 7 in CLotCalculationTests verify both conditions using ASSERT_THROWS on the flag and ASSERT_NEAR on the return value.

ScaleATR() converts the raw result to integer points before converting back to a price value, which eliminates the sub-point floating-point residue that would otherwise accumulate when the ATR multiplier is not an exact binary fraction.

CalcPipValue() branches on is_direct_quote. For a direct-quote pair such as EURUSD where the account currency is the quote currency, the pip value in account currency is simply pip_size. For an indirect pair such as USDJPY, it is pip_size / quote_price, because the quote currency is not the account currency and the price must be used to convert.

LotCalculationTests.mqh — The First Test Suite With a Deliberate Defect

//+------------------------------------------------------------------+
//|                                      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 registers thirteen assertions across four functions. The tests are numbered with inline comments that state the inputs, the arithmetic derivation, and the expected output before the assertion is written. This commentary style is intentional: a test that does not document its expected-value derivation cannot be reviewed or trusted when the expected value is not obvious.

Test 5 uses two assertions on one function call result. ASSERT_FALSE on the err flag verifies no error was triggered, and ASSERT_NEAR on the result verifies the computed volume. Both assertions are necessary: a function that returns the correct lot size but also sets the error flag incorrectly has a defect, and a function that sets the error flag correctly but returns the wrong lot size also has a defect.

Test 13 is the deliberately injected failure. RoundLotToStep(0.125, 0.01) returns 0.12 because MathFloor(0.125 / 0.01) is 12 and 12 * 0.01 is 0.12. The expected value in the assertion is 0.13, which is wrong. When the framework runs this test, MathAbs(0.13 - 0.12) is 0.01, which exceeds the tolerance of 0.0001, so ASSERT_NEAR stores a TEST_FAIL result. The [FAIL] line in the Experts tab will carry Expected=0.13000 | Actual=0.12000 and the exact line number of the ASSERT_NEAR call, demonstrating that the framework's failure attribution is precise.

ATRScalingTests.mqh — The Second Test Suite

//+------------------------------------------------------------------+
//|                                             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 covers three functions across twelve assertions. Tests 3 and 4 verify boundary conditions on ScaleATR(): a zero ATR value or a zero multiplier must return 0.0 rather than propagating a meaningless scaled result. Tests 6 through 8 cover all three symbol_digits branches of NormalizeSpread() — five-digit, two-digit, and three-digit — ensuring that each broker precision format is handled correctly. Tests 9 and 10 cover both CalcPipValue() branches with concrete expected values; the indirect-quote expected value is written as the expression 0.01 / 149.50 rather than a pre-computed literal, which makes the relationship between input and output explicitly visible in the source.

UnitTestFramework.mq5 — The Entry-Point Script

//+------------------------------------------------------------------+
//|                                            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() is the complete execution sequence: allocate the runner, register the suites, run all assertions, print the summary, and delete the runner. Registering a suite with AddSuite(new CLotCalculationTests()) transfers heap ownership to the runner; the delete at the end of OnStart() calls the runner's destructor, which deletes all registered suites. The developer must not call delete on the suite pointers separately after AddSuite().

The two input parameters appear in the script's input dialog. inp_stop_on_first_failure controls the fail-fast behaviour described in RunAll(). inp_enable_test_logging is present as a configurable option for documentation purposes; the current formatter always prints output. The expected output is stated in the file header: 27 assertions, 26 passing, and exactly one failure in TestLotRounding_Defect, which demonstrates that the framework identifies and attributes defects correctly.


Compile-Time and Runtime Cooperation

The macro layer and the runtime layer operate at different phases but cooperate through the STestResult struct. At compile time, the preprocessor expands each assertion macro and substitutes the literal values of __FILE__ and __LINE__ at the call site. At runtime, the assertion's boolean evaluation occurs and the result, together with the compile-time file and line information, is stored as a struct record.

As a result, failure reports include exact file/line data without runtime stack inspection. The line number in a failure message is the exact line where the ASSERT_EQ or ASSERT_NEAR macro was written in the test suite file. The developer can navigate directly to that line in MetaEditor without any intermediate investigation.

The limitation of this approach is that __LINE__ captures the line of the macro call, not the line inside the function under test where the incorrect computation occurred. The test framework identifies which assertion failed; it does not identify which internal branch in the function produced the wrong value. That determination requires reading the function's code with the assertion's expected and actual values as context.


Extensibility and Limitations

Adding a new assertion type requires adding one macro definition to AssertionMacros.mqh and ensuring it calls _RecordAssertionResult() with the correct status, expected string, actual string, file, and line arguments. No other file requires modification. The runner, the formatter, and all existing suites continue to work without change.

Adding a new test suite requires writing a class that implements ITestSuite, creating an instance, and registering it with AddSuite() in the script's OnStart(). No modification to the runner or the formatter is required.

The framework's primary limitation is the absence of automatic test discovery. In C++ testing frameworks like Google Test, functions named with specific prefixes are automatically registered. MQL5 provides no reflection mechanism that would support this. Every test suite must be explicitly registered. For a codebase with dozens of suites, this manual registration list in OnStart() becomes a maintenance item.

A secondary limitation is the ASSERT_THROWS simulation. Because MQL5 does not support structured exception propagation, the pattern used here requires the function under test to communicate invalid-input conditions through return values or global flags rather than through a thrown exception. Functions that simply produce wrong output on invalid input without setting any error indicator cannot be tested with ASSERT_THROWS. They require ASSERT_NEAR or ASSERT_TRUE assertions on their output values.

Figure 3: Experts tab report layout showing test output formatted by CReportFormatter.

Figure 3: Experts tab report layout showing test output formatted by CReportFormatter.


Framework Overhead

The framework consumes memory proportional to the number of registered test results. Each STestResult struct holds three strings (test name, expected, actual), two integers (status, line), and one string (file path). On a 64-bit platform with typical string lengths, each struct occupies approximately 200 to 400 bytes. A suite with fifty assertions consumes under twenty kilobytes. For a complete test run with two hundred assertions across ten suites, the total allocation is under one hundred kilobytes, which is negligible relative to the terminal's available memory.

Virtual dispatch cost is two pointer dereferences per Execute() call, one per suite. With ten suites and fifty assertions each, the total virtual dispatch overhead is ten calls, contributing nanoseconds to the overall execution time. The dominant cost is the string operations inside _RecordAssertionResult(): constructing the expected and actual value strings requires DoubleToString() or IntegerToString() calls and string concatenation. For a two-hundred-assertion run, this produces four hundred string operations, completing in well under one millisecond on any modern hardware.

The framework is not suitable for performance benchmarking, but it is not intended for that purpose. Its execution context is an MQL5 script run before deployment, not a component of the EA's production tick-handling path.


Conclusion

A lightweight unit testing framework built from preprocessor macros, a polymorphic suite interface, and a structured result recorder provides deterministic validation of MQL5 utility functions without external tools or framework dependencies. The assertion macros capture source location at compile time, ensuring that failure reports identify the exact line of the failed assertion rather than requiring runtime stack inspection. The test runner and report formatter aggregate results across all registered suites and produce a structured summary that quantifies the pass rate and enumerates every failure with its expected and actual values.

The framework follows the Open-Closed Principle: new assertion types and test suites are added without modifying existing components. Existing suite implementations and the runner's aggregation logic remain unchanged when new components are introduced.

The practical limitation is that test suite registration is manual. Automatic discovery is not achievable in MQL5 without reflection facilities the language does not provide. For teams maintaining large utility libraries, the registration list in the test script becomes a secondary maintenance artifact that must be updated alongside the test suites themselves. This cost is acceptable relative to the alternative of deploying unvalidated lot-sizing and risk-calculation functions into production.


Programs used in the article:

# Name Type Description
1 TestStatus.mqh Include File ENUM_TEST_STATUS enumeration and STestResult struct carrying test name, status, expected value, actual value, file, and line number
2 AssertionMacros.mqh Include File ASSERT_EQ, ASSERT_NEAR, ASSERT_TRUE, ASSERT_FALSE, and ASSERT_THROWS macros with _RecordAssertionResult() runtime support function
3 ITestSuite.mqh Include File Abstract interface declaring GetName() and Execute(CTestRunner*) as the contract for all test suite implementations
4 TestRunner.mqh Include File CTestRunner owning registered ITestSuite* instances, executing them in sequence, aggregating STestResult records, and delegating formatted output to CReportFormatter
5 ReportFormatter.mqh Include File CReportFormatter printing structured per-test and summary output to the Experts tab with pass rate calculation
6 MathUtilities.mqh Include File Utility functions under test: CalcLotSize(), NormalizeLot(), ScaleATR(), NormalizeSpread(), CalcPipValue()
7 LotCalculationTests.mqh Include File CLotCalculationTests implementing ITestSuite with assertions against CalcLotSize() and NormalizeLot(), including a deliberately injected rounding defect
8 ATRScalingTests.mqh Include File CATRScalingTests implementing ITestSuite with assertions against ScaleATR(), NormalizeSpread(), and CalcPipValue()
9 UnitTestFramework.mq5 Script Entry-point script registering all suites, executing the full test run, and printing the structured report and summary to the Experts tab
10 Compile_Time_Unit_Testing_Framework.zip Zip Archive Zip archive containing all the attached files and their paths relative to the terminal's root folder.
Building a Gold Volatility Regime Monitor from Options Data in MQL5 Building a Gold Volatility Regime Monitor from Options Data in MQL5
A practical bridge from the options market into MetaTrader 5 for gold. We compute near-the-money implied volatility by solving Black-Scholes from quoted prices, compare it with 30-day realized volatility, and use the ratio as a regime proxy. A Python feed publishes the value, an MQL5 script consumes it with WebRequest, and a background service keeps a panel current and alerts on changes. Source code for all parts is provided.
Neural Networks in Trading: Probabilistic Time Series Forecasting (Conclusion) Neural Networks in Trading: Probabilistic Time Series Forecasting (Conclusion)
We invite you to learn about the K²VAE framework and how the proposed approaches can be integrated into a trading system. You will learn how the hybrid Koopman–Kalman–VAE approach helps build adaptive and interpretable models. The article concludes with practical results from using the implemented solutions.
Larry Williams Market Secrets (Part 17) : Detecting Oops Signals Using a Custom Indicator Larry Williams Market Secrets (Part 17) : Detecting Oops Signals Using a Custom Indicator
This article implements an MQL5 custom indicator that detects Larry Williams Oops gap reversals and marks bullish and bearish arrows on the chart. It details configurable gap and validity thresholds, same-bar or later confirmation, first-fill-only logic, historical backfilling, and incremental updates so signals remain consistent on both history and newly completed bars.
Distribution-Free Price Channels in MQL5: Quantile Regression by Iteratively Reweighted Least Squares Distribution-Free Price Channels in MQL5: Quantile Regression by Iteratively Reweighted Least Squares
We build a rolling price channel by fitting the 0.1, 0.5 and 0.9 conditional quantile lines via IRLS with pinball loss, packaged as a reusable class and two MetaTrader 5 indicators. We verify in-sample coverage, examine quantile crossing, and compare the channel width with ATR, Bollinger and regression widths on matched horizons. Tests in the Strategy Tester show the edges are descriptive, while the normalized width works as a volatility/regime feature.