Testy jednostkowe w MQL5
MQL5 nie ma frameworka testowego i nikt go tu nie używa. Pokazujemy działający mikroframework na sto linii i sposób pisania kodu, który da się testować.
2 fragmenty kodu z tej strony przeszły przez kompilator MetaEditor build 6090, 2026-08-05.
W każdym innym języku pytanie „czy ta funkcja zwraca to, co powinna" ma oczywistą odpowiedź: napisz test. W MQL5 standardowa odpowiedź brzmi „uruchom w testerze na roku danych i zobacz, czy krzywa ładnie rośnie".
To nie jest test. To obserwacja układu, w którym twoja funkcja jest jednym z kilkunastu elementów, a wynik zależy od brokera, spreadu i modelu ticków. Jeśli wyliczenie wolumenu ma błąd, krzywa kapitału ci tego nie powie.
Dlaczego prawie nikt tego nie robi
Bo większość kodu MQL5 jest napisana tak, że nie da się go przetestować. Funkcja licząca wolumen sama pyta terminal o właściwości symbolu, sama czyta saldo rachunku i sama wysyła zlecenie. Żeby ją sprawdzić, potrzebujesz połączenia z brokerem, konkretnego instrumentu i konkretnego stanu rachunku.
Rozwiązanie nie jest specyficzne dla MQL5. Oddziel obliczenie od pobierania danych:
double WolumenZRyzyka(const double procent, const int dystansPunktow)
{
double kapital = AccountInfoDouble(ACCOUNT_BALANCE);
double punkt = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
double krok = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
//--- obliczenie splecione z odczytem stanu świata
return 0.0;
}//--- czysta funkcja: te same argumenty zawsze dają ten sam wynik
double WolumenZRyzyka(const double kapital, const double procent,
const int dystansPunktow, const double punkt,
const double rozmiarTicku, const double wartoscTicku,
const double minimum, const double maksimum,
const double krok)
{
//--- samo obliczenie, zero odwołań do terminala
return 0.0;
}Druga wersja ma dziewięć argumentów i wygląda niewygodnie. Owija się ją jedną krótką funkcją, która pobiera dane z terminala i woła tę czystą. Zysk: część licząca jest sprawdzalna w sekundę, na dowolnym instrumencie, bez połączenia.
Mikroframework
Sto linii, jeden plik nagłówkowy, bez zależności.
//+------------------------------------------------------------------+
//| Testy.mqh |
//| Mikroframework do testów jednostkowych w MQL5. |
//| https://finanseodczarowane.pl/mql5/inzynieria/testy-jednostkowe/ |
//| Licencja MIT |
//+------------------------------------------------------------------+
#property copyright "finanseodczarowane.pl/kod"
//+------------------------------------------------------------------+
//| Zbiera wyniki sprawdzeń i wypisuje podsumowanie. |
//| Nie rzuca wyjątkami — MQL5 ich nie ma — tylko liczy porażki |
//| i zwraca ich liczbę, żeby skrypt mógł zakończyć się kodem błędu. |
//+------------------------------------------------------------------+
class CTesty
{
private:
int m_udane;
int m_nieudane;
string m_grupa;
void Zapisz(const bool wynik, const string opis, const string szczegoly)
{
if(wynik)
{
m_udane++;
return;
}
m_nieudane++;
PrintFormat("NIEUDANY %s%s%s",
(m_grupa == "" ? "" : m_grupa + " / "), opis,
(szczegoly == "" ? "" : " -> " + szczegoly));
}
public:
CTesty(void) : m_udane(0), m_nieudane(0), m_grupa("") { }
//--- nazwa grupy pojawia się przy nieudanych sprawdzeniach
void Grupa(const string nazwa) { m_grupa = nazwa; }
void Prawda(const string opis, const bool warunek)
{
Zapisz(warunek, opis, warunek ? "" : "oczekiwano prawdy");
}
void Falsz(const string opis, const bool warunek)
{
Zapisz(!warunek, opis, warunek ? "oczekiwano fałszu" : "");
}
void RowneInt(const string opis, const long oczekiwane, const long otrzymane)
{
bool zgodne = (oczekiwane == otrzymane);
Zapisz(zgodne, opis, zgodne ? "" :
StringFormat("oczekiwano %I64d, otrzymano %I64d", oczekiwane, otrzymane));
}
//--- porównanie liczb zmiennoprzecinkowych ZAWSZE z tolerancją
void RowneDouble(const string opis, const double oczekiwane,
const double otrzymane, const double tolerancja = 1e-8)
{
bool zgodne = (MathAbs(oczekiwane - otrzymane) <= tolerancja);
Zapisz(zgodne, opis, zgodne ? "" :
StringFormat("oczekiwano %.10f, otrzymano %.10f (tolerancja %.10f)",
oczekiwane, otrzymane, tolerancja));
}
void RowneString(const string opis, const string oczekiwane,
const string otrzymane)
{
bool zgodne = (oczekiwane == otrzymane);
Zapisz(zgodne, opis, zgodne ? "" :
StringFormat("oczekiwano '%s', otrzymano '%s'", oczekiwane, otrzymane));
}
void WZakresie(const string opis, const double otrzymane,
const double od, const double doo)
{
bool zgodne = (otrzymane >= od && otrzymane <= doo);
Zapisz(zgodne, opis, zgodne ? "" :
StringFormat("%.10f poza zakresem [%.10f, %.10f]", otrzymane, od, doo));
}
int Udane(void) const { return m_udane; }
int Nieudane(void) const { return m_nieudane; }
//--- zwraca liczbę porażek: zero znaczy, że wszystko przeszło
int Podsumuj(void)
{
PrintFormat("=== Wynik: %d udanych, %d nieudanych, razem %d ===",
m_udane, m_nieudane, m_udane + m_nieudane);
return m_nieudane;
}
};Trzy decyzje projektowe warte wyjaśnienia:
Brak wyjątków. MQL5 ich nie ma, więc nieudane sprawdzenie nie przerywa testu — jest zliczane. To akurat wygodne: jeden przebieg pokazuje wszystkie usterki naraz, a nie pierwszą z brzegu.
RowneDouble zawsze z tolerancją. Nie ma metody porównującej double przez ==, bo taka metoda byłaby pułapką. Domyślna tolerancja to 1e-8, przy cenach warto podawać połowę punktu.
Podsumuj zwraca liczbę porażek. Dzięki temu skrypt może zakończyć się komunikatem, a w przyszłości — jeśli podepniesz to pod automat — kodem wyjścia.
Sprawdzian w działaniu
//+------------------------------------------------------------------+
//| SprawdzianWolumenu.mq5 |
//| Przykład testów jednostkowych funkcji czystej w MQL5. |
//| Uruchom jako skrypt na dowolnym wykresie i zobacz zakładkę |
//| Eksperci. Nie dotyka rachunku i nic nie wysyła na serwer. |
//| https://finanseodczarowane.pl/mql5/inzynieria/testy-jednostkowe/ |
//+------------------------------------------------------------------+
#property copyright "finanseodczarowane.pl/kod"
#property script_show_inputs
#include "Testy.mqh"
//+------------------------------------------------------------------+
//| Funkcja testowana. Parametry instrumentu przyjmuje jako |
//| argumenty, a nie odczytuje z terminala — dzięki temu da się ją |
//| sprawdzić bez połączenia z brokerem i bez konkretnego symbolu. |
//+------------------------------------------------------------------+
double DopasujWolumen(const double wolumen, const double minimum,
const double maksimum, const double krok)
{
if(krok <= 0.0 || minimum <= 0.0 || wolumen <= 0.0)
return 0.0;
double wynik = MathFloor(wolumen / krok) * krok;
if(wynik < minimum)
return 0.0; // świadomie nie podnosimy do minimum
if(wynik > maksimum)
wynik = maksimum;
int cyfry = (int)MathMax(0.0, MathRound(-MathLog(krok) / MathLog(10.0)));
return NormalizeDouble(wynik, cyfry);
}
//+------------------------------------------------------------------+
void OnStart()
{
CTesty testy;
testy.Grupa("wartości typowe");
testy.RowneDouble("0.037 lota schodzi do 0.03",
0.03, DopasujWolumen(0.037, 0.01, 100.0, 0.01));
testy.RowneDouble("wartość zgodna z krokiem zostaje bez zmian",
0.50, DopasujWolumen(0.50, 0.01, 100.0, 0.01));
testy.RowneDouble("zaokrąglamy w dół, nigdy w górę",
0.09, DopasujWolumen(0.099999, 0.01, 100.0, 0.01));
testy.Grupa("granice");
testy.RowneDouble("poniżej minimum zwracamy zero, nie minimum",
0.0, DopasujWolumen(0.004, 0.01, 100.0, 0.01));
testy.RowneDouble("powyżej maksimum przycinamy do maksimum",
100.0, DopasujWolumen(250.0, 0.01, 100.0, 0.01));
testy.RowneDouble("dokładnie minimum przechodzi",
0.01, DopasujWolumen(0.01, 0.01, 100.0, 0.01));
testy.Grupa("instrumenty o innym kroku");
testy.RowneDouble("krok 0.1 na indeksie",
1.4, DopasujWolumen(1.47, 0.1, 50.0, 0.1));
testy.RowneDouble("krok 1.0 na akcjach",
37.0, DopasujWolumen(37.9, 1.0, 500.0, 1.0));
testy.Grupa("dane bezsensowne");
testy.RowneDouble("krok zerowy nie wywraca funkcji",
0.0, DopasujWolumen(1.0, 0.01, 100.0, 0.0));
testy.RowneDouble("wolumen ujemny zwraca zero",
0.0, DopasujWolumen(-5.0, 0.01, 100.0, 0.01));
if(testy.Podsumuj() > 0)
Print("Sprawdzian nie przeszedł. Popraw kod, zanim go opublikujesz.");
}Uruchamiasz go jak każdy skrypt: przeciągasz na wykres, patrzysz w zakładkę Eksperci. Trwa milisekundy, nie dotyka rachunku, nie wymaga historii.
=== Wynik: 10 udanych, 0 nieudanych, razem 10 ===A gdy coś się psuje:
NIEUDANY granice / poniżej minimum zwracamy zero, nie minimum -> oczekiwano 0.0000000000, otrzymano 0.0100000000
=== Wynik: 9 udanych, 1 nieudanych, razem 10 ===Co warto sprawdzać
Kolejność jest odwrotna do intuicyjnej. Wartości typowe zwykle działają — to nie one psują rachunki.
- Granice. Dokładnie minimum, dokładnie maksimum, o jeden krok poniżej.
- Dane bezsensowne. Zero, wartość ujemna, krok równy zeru. Funkcja ma się nie wywrócić i nie zwrócić czegoś, co potem trafi do
OrderSend. - Inne instrumenty. Krok 0.01, 0.1 i 1.0. Trzy linijki testu wyłapują błąd, który inaczej ujawni się dopiero na akcjach, u obcego użytkownika.
- Zaokrąglanie w złą stronę.
0.099999ma dać0.09, nie0.10— w przeciwnym razie ryzyko cicho przekracza założenie. - Wartości typowe. Na końcu, dla porządku.
Co da się testować, a czego nie
| Da się bez problemu | Wymaga zachodu | Nie da się sensownie |
|---|---|---|
| obliczenia wolumenu i poziomów | logika oparta o czas serwera | jakość sygnału strategii |
| filtry czasu i sesji | odczyt właściwości symbolu | zachowanie serwera brokera |
| przeliczenia punktów i cen | pętle po pozycjach | poślizgi i rekwotowania |
| walidacja parametrów wejściowych |
Środkowa kolumna staje się lewą, gdy zamiast czytać stan świata w środku funkcji, przekażesz go argumentem. To jedyna sztuczka w całej tej stronie.