Mejorar la calidad del código mediante la prueba unitaria

Андрей | 10 mayo, 2016

Introducción

Al programar con MQL4, había tenido una amplia experiencia manejando algunos programas en MQL4 ya existentes y creando varias docenas por mi cuenta. Al final llegué a la conclusión de que MQL4 es un entorno muy favorable para la creación de programas de baja calidad. Las razones son las siguientes:

  1. MetaTrader 4 no tiene un depurador incorporado. El proceso de búsqueda de errores es a veces muy complicado.
  2. A diferencia de C++ y Java, MQL4 no tiene un control de excepciones incorporado.
  3. Los programas en MQL4 se escriben a menudo a toda prisa y con mayor énfasis en la idea y no en la calidad del código.

Todo esto lleva a una baja calidad del código, lo que a su vez implica lo siguiente:

  1. Errores en el funcionamiento de los Asesores Expertos y un funcionamiento incorrecto del algoritmo (es especialmente crítico al trabajar con cuentas reales).
  2. Ejecución lenta. Optimización muy lenta.
  3. Procesamiento erróneo en caso de error. El Asesor Experto puede resultar inviable.

Quiero señalar que todo lo mencionado antes no se aplica los programadores experimentados en MQL4. Los programadores cualificados encuentran la manera de escribir un código de calidad.

Ya que mi trabajo principal está relacionado con las pruebas de calidad de los programas, estaba interesado en cualquier recurso para las pruebas y depuración de los programas en MQL4. Pero no encontré muchos artículos sobre este tema. Por eso quiero describir en este artículo algunas técnicas para mejorar la calidad de los programas. Si el tema de este artículo resulta interesante, se podrán tratar otros aspectos en los siguientes artículos.


Un poco de teoría acerca de la calidad

Si buscamos un poco por Google, podemos encontrar lo siguiente:

La calidad es un conjunto de propiedades y características de un producto y que le ofrecen la posibilidad de satisfacer las necesidades supuestas o asumidas.

En cuanto a los programas, se puede considerar que el programa es de buena calidad si se ajusta a las necesidades del cliente y desempeña correctamente todas las funciones que se le asignan.

Un programa de calidad requiere por lo general dos tipos de actividades:

La garantía de la calidad es una cuestión muy complicada. Abarca muchas tareas que van desde la creación de un entorno de trabajo agradable para el programador hasta la implementación de procesos de negocio muy complejos. No vamos a abordar esta cuestión aún. Vamos a comentar el control de calidad.

Cuanta más atención prestamos al control de calidad, mayor es la posibilidad de que nuestro programa funcione correctamente. En teoría, hay que realizar el control de calidad (o las pruebas) en cada etapa del desarrollo:

  1. Probar las especificaciones técnicas; es imposible desarrollar un programa que funcione correctamente en base a unas especificaciones técnicas incorrectas.
  2. Revisar el código fuente. Buscar defectos, códigos ineficientes, violaciones de las reglas de codificación y errores obvios.
  3. Probar las distintas funciones del programa en el modo automático (pruebas unitarias o Unit Testing).
  4. Probar todo el programa en el modo manual. Un persona (probador) tiene que comprobar que el programa funciona correctamente.
  5. Probar el programa en el modo automático (Automated Testing o prueba automatizada). Esto es cuando los propios robots prueban la calidad del programa. Puede parecer una utopía, pero a veces funciona.
  6. Probar el programa por un cliente.

etc. Existen muchas otras pruebas...

Pero la que más nos interesa es la prueba unitaria (Unit Testing).


Un poco de teoría acerca de la prueba unitaria

Al buscar por Google encontramos la siguiente definición de la prueba unitaria. La prueba unitaria es un método de validación de una aplicación en el cual un programador comprueba distintas unidades (bloques) de un código fuente para su correcto funcionamiento en el resto del programa. La unidad es la parte más pequeña que se puede probar de un programa. En los lenguajes de aplicaciones (incluyendo MQL4) se puede considerar una función distinta como una unidad.

En la mayoría de los casos, se lleva a cabo la prueba unitaria de manera automática. En otras palabras, se escribe un programa que llama a una función de prueba con diferentes parámetros y luego genera un informe indicando si los valores devueltos por la función son correctos o no.

Las pruebas unitarias pueden resultar muy útiles por los siguientes motivos:

  1. Si se detecta un fallo, puede encontrar fácilmente su origen ya que está probando una sola función. Si se detecta un fallo en toda la aplicación, sólo tiene que dedicar un poco más de tiempo para encontrar la función que causo el fallo.
  2. Es muy fácil comprobar si se ha eliminado un defecto o no. Sólo hay que ejecutar la prueba unitaria una vez más. No hace falta reiniciar toda la aplicación. Por ejemplo, se pueden producir raramente algunos errores bajo ciertas condiciones difíciles de recrear. Las pruebas unitarias eliminan este problema.
  3. Puede optimizar fácilmente el código sin preocuparse de que algo irá mal. Las pruebas unitarias mostrarán siempre si una función sigue trabajando correctamente o no.
  4. También se pueden identificar problemas que no aparecen en seguida pero le pueden surgir al cliente y requerir muchas horas de investigación y de depuración.
  5. Se puede utilizar el método Test Driven cuando se crea primero la prueba unitaria y sólo después se desarrolla la función. Se desarrolla la función hasta que se lleve a término la prueba unitaria. He probado este método por primera vez en una aplicación de C++ y ha proporcionado un buen resultado. He sentido una gran satisfacción ya que estaba completamente seguro de su usabilidad al finalizar su creación y su uso posterior era impecable.

Veamos su aspecto. Supongamos que hemos creado la función para calcular la raíz cuadrada:
y=sqrt(x)

Por lo tanto, vamos a crear otra función para nuestra prueba que funcionará según el siguiente algoritmo:


Podemos crear una función de prueba antes de crear la función principal. Por consiguiente, tenemos que definir los requisitos que debe cumplir la función creada. Así es como usamos nuestro método Test Driven. Sólo después de que las pruebas unitarias hayan funcionado correctamente podemos usar la función con seguridad en nuestro programa principal.

Sigue habiendo una pregunta sin responder: ¿cómo elegir un conjunto de parámetros de prueba para las funciones de las pruebas? Por supuesto, hay que usar todos los valores posibles, pero en la mayoría de los casos es imposible o requiere demasiado tiempo. Se puede escribir otro artículo sobre la elección de los valores para las pruebas. Voy a tratar de proporcionar algunas pautas generales:

  1. Además de usar los datos correctos usamos los datos que llevan a errores, ya que no sólo comprobamos que la función cumple con los requisitos, sino de qué manera procesa los errores.
  2. Tenemos que utilizar los valores límite. Por ejemplo, si el rango de valores va desde 0 hasta 100, hay que usar los valores 0 y 100. Si los datos de entrada son cadenas, necesitamos la cadena vacía y la cadena con la longitud máxima.
  3. Hay que utilizar los valores que van más allá de los límites. En el ejemplo anterior hay que usar los valores 101 y -1, y con las cadenas hay que usar el valor max+1.
  4. Hay que tratar de dividir el conjunto de todos los valores posibles en subconjuntos equivalentes (clases equivalentes) en los cuales la función tiene un comportamiento similar. Hay que seleccionar un valor para cada clase. Por ejemplo, no tiene mucho sentido comprobar sqrt(4) y sqrt(9). Es mucho mejor comprobar sqrt(4) y sqrt(5), ya que en el último caso la función devolverá un número irracional, mientras que en el primero será un entero.
  5. Si la función tiene ramificaciones (if, switch), hay que asegurarse de que todas las ramas han sido procesadas por la prueba unitaria.

Voy a tratar de mostrar un ejemplo concreto en el siguiente apartado.


Un poco de práctica acerca de la creación de la prueba unitaria

¡Vamos a definir un objetivo! Supongamos que nuestra tarea consiste en desarrollar una librería con una función cuyas entradas son dos matrices. La función elimina de la primera matriz los elementos que no están presentes en la segunda matriz. Como resultado, la primera matriz será un subconjunto de la segunda matriz.

Vamos a definir el prototipo de nuestra función:

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

Trataremos de utilizar el método Test Driven para el desarrollo de la función. Vamos a definir el conjunto de datos para la prueba. Para ello, hay que elegir una serie de clases de equivalencia de los datos de entrada:

  1. Ambas matrices están vacías.
  2. A1 está vacía, A2 contiene elementos.
  3. A1 contiene elementos, A2 está vacía.
  4. Ambas contienen el mismo conjunto de elementos y tienen el mismo tamaño.
  5. A1 contiene elementos que no están presentes en A2.
  6. Parte de los elementos de A1 están presentes en A2, y parte de A2 están en A1 (ambas tiene elementos en común).
  7. Todos los elementos de A1 están presentes en A2, pero A2 es de mayor tamaño.
  8. Un pequeña parte de los elementos de A1 está presente en A2. Los elementos se encuentran dispersos por toda la matriz.
  9. Un pequeña parte de los elementos de A1 está presente en A2. Los elementos están concentrados al principio de la matriz.
  10. Un pequeña parte de los elementos de A1 está presente en A2. Los elementos están concentrados al final de la matriz.

Si nuestra función funciona correctamente con los 10 casos, podemos estar seguros de que los Asesores Expertos que usan esta función no se verán afectados por sus defectos. Pero debemos entender que es imposible probar algo al cien por cien y que siempre pueden quedar algunos defectos ocultos.

Para mayor comodidad, he creado la librería mql4unit. He incluido en ella las funciones necesarias para las pruebas unitarias:

//-------------------------------------------------------------------+

//Current test conditions are kept by the global variables
//-------------------------------------------------------------------+
int tests_passed;    //Number of successful tests
int tests_failed;    //Number of unsuccessful tests
int tests_total;     //Total number of tests

string test_name;    //Test name

//-------------------------------------------------------------------+
//The function initializes test environment for one test
//-------------------------------------------------------------------+
void UnitTestStart(string TestName)
{

   test_name = TestName;
   tests_passed = 0;
   tests_failed = 0;
   tests_total = 0;
   Print("*--------------------------------------------------*");

   Print("Starting unit test execution ", test_name);
}

//-------------------------------------------------------------------+
//the function is called at the end of the test. Brings back true if all the tests
//are successful. Otherwise - False.
//-------------------------------------------------------------------+
bool UnitTestEnd()
{
   if (tests_failed == 0)

   {
      Print("HURRAY!!! ", test_name, " PASSED. ", tests_passed, " tests are successful.");
   }
   else
   {

      Print(":((( ", test_name, " FAILED. ", tests_passed,"/",tests_total, " tests are successful.");   
   }
   Print("*--------------------------------------------------*");
}


//-------------------------------------------------------------------+
//The function executes the test for two arrays of int type
//Brings back true, if the arrays are equal
//-------------------------------------------------------------------+
bool TestIntArray(int actual[], int expected[]){

   tests_total++;
   //Comparing arrays' sizes
   if (ArraySize(actual) != ArraySize(expected))
   {
      Print("Test #", tests_total," ERROR. Array size ", ArraySize(actual), " instead of ", ArraySize(expected));

      tests_failed++;
      return(false);      
   }
   //Then comparing element by element
   for (int i=0; i<ArraySize(actual);i++)

   {
      if (actual[i]!=expected[i]){
         Print("Test #", tests_total," ERROR. Element value #",i,"=", actual, " instead of ", expected);
         tests_failed++;

         return(false);
      }
   }
   //If all the elements are equal, the test is passed
   Print("Test #", tests_total," OK: Passed!");  

   tests_passed++;
   return(true);
}
Vamos a crear un script de prueba "mytests" con el texto del código de nuestra función vacío. Creamos la función de prueba en él y describimos todas las pruebas unitarias en él.
bool Test()
{
   UnitTestStart("CreateSubset function testing");
   Print("1. Both arrays are empty.");

   int a1_1[], a1_2[];
   int result_1[]; //Waiting for an empty array as a result of the function execution
   CreateSubset(a1_1, a1_2);
   TestIntArray(a1_1, result_1);
   
   Print("2. A1 is empty, A2 contains the elements");

   int a2_1[], a2_2[] = {1,2,3};
   int result_2[]; //Waiting for an empty array as a result of the function execution
   CreateSubset(a2_1, a2_2);

   TestIntArray(a2_1, result_2);

   Print("3. A1 contains the elements, A2 is empty");
   int a3_1[] = {1,2,3}, a3_2[];

   int result_3[]; //Waiting for an empty array as a result of the function execution
   CreateSubset(a3_1, a3_2);
   TestIntArray(a3_1, result_3);

   Print("4. Both contain similar set of the elements and have similar size");
   int a4_1[] = {1,2,3}, a4_2[] = {1,2,3};

   int result_4[] = {1,2,3}; //Waiting for an unchanged array as a result of the function execution
   CreateSubset(a4_1, a4_2);
   TestIntArray(a4_1, result_4);

   Print("5. A1 contains the elements that are not present in A2");

   int a5_1[] = {4,5,6}, a5_2[] = {1,2,3};
   int result_5[]; //Waiting for an empty array as a result of the function execution

   CreateSubset(a5_1, a5_2);
   TestIntArray(a5_1, result_5);
   
   Print("6. Part of the elements in A1 are present in A2, A2 part is contained in A1 (both multitudes have an intersection)");
   int a6_1[] = {1,2,3,4,5,6,7,8,9,10}, a6_2[] = {3,5,7,9,11,13,15};

   int result_6[] = {3,5,7,9}; //Waiting for arrays intersection as a result of the function execution
   CreateSubset(a6_1, a6_2);
   TestIntArray(a6_1, result_6);

   
   Print("7. All A1 elements are present in A2, but A2 size is bigger");
   int a7_1[] = {3,4,5}, a7_2[] = {1,2,3,4,5,6,7,8,9,10};

   int result_7[] = {3,4,5}; //Waiting for arrays intersection as a result of the function execution
   CreateSubset(a7_1, a7_2);
   TestIntArray(a7_1, result_7);
   

   Print("8. A small part of A1 elements is present in A2. Besides, the elements are scattered all over an array.");
   int a8_1[] = {1,2,3,4,5,6,7,8,9,10}, a8_2[] = {2,5,9};

   int result_8[] = {2,5,9}; //Waiting for arrays intersection as a result of the function execution
   CreateSubset(a8_1, a8_2);
   TestIntArray(a8_1, result_8);
   

   Print("9. A small part of A1 elements is present in A2. Besides, the elements are concentrated at an array leader.");
   int a9_1[] = {1,2,3,4,5,6,7,8,9,10}, a9_2[] = {1,2,3};

   int result_9[] = {1,2,3}; //Waiting for arrays intersection as a result of the function execution
   CreateSubset(a9_1, a9_2);
   TestIntArray(a9_1, result_9);

   Print("10. A small part of A1 elements is present in A2. Besides, the elements are concentrated at an array's end.");

   int a10_1[] = {1,2,3,4,5,6,7,8,9,10}, a10_2[] = {8,9,10};

   int result_10[] = {8,9,10}; //Waiting for arrays intersection as a result of the function execution
   CreateSubset(a10_1, a10_2);
   TestIntArray(a10_1, result_10);
   

   return (UnitTestEnd());
}

Para llevar a cabo la prueba unitaria tenemos que llamar a la función de prueba en la función principal y ejecutar el script.

Vamos a ejecutar nuestra prueba.


Como se puede observar, los resultados son decepcionantes. Esto no es extraño, ya que la función aún no está lista. ¡Sin embargo! Hay 4 pruebas entre 10 que se han superado con éxito. Esto significa que en teoría, es posible que se haya pasado por alto el hecho de que la función está vacía ya que en algunos casos ha funcionado con normalidad.

En otras palabras, puede haber un subconjunto de datos de entrada con los cuales una función errónea funciona correctamente. Si el programador usa únicamente los datos de prueba que llevan a un funcionamiento correcto, es posible que una función errónea acabe entre las manos del cliente.

Vamos a crear ahora la función CreateSubset en sí. No vamos a hablar de lo eficiente y bonita que es la función aquí.

void CreateSubset(int & a1[], int a2[]){
   int i=0;

   while(i<ArraySize(a1)){
      bool b_exist = false;
      for (int j=0; j<ArraySize(a2);j++){

         if (a1[i] == a2[j]) b_exist = true;
      }
      if (!b_exist){
         for (j=i; j<ArraySize(a1)-1;j++){
            a1[j] = a1[j+1];   

         }
         ArrayResize(a1, ArraySize(a1)-1);
      }else{
         i++;
      }
   }
}
Ejecutemos la prueba otra vez:


Se puede ejecutar la función desde cualquier parte. Se puede definir en un Asesor Experto y ejecutar durante la inicialización. Si se procesa un modelo por separado, es posible definir una o varias funciones en él y llamarlas desde el script. Dejamos la puerta abierta a la imaginación.

Por supuesto, lo ideal sería poder ejecutar la prueba unitaria justo después de compilar la librería, pero estoy seguro de que se pueda hacer esto en MQL4. Es muy poco probable. Si sabe cómo hacerlo, por favor, envíeme un mensaje.

Cada vez que se ejecuta la prueba, podemos respirar profundamente y asegurarnos de que todo funciona correctamente.


Algunas observaciones

  1. Le puede parecer que la escritura de pruebas es sólo una pérdida de tiempo. Pero puede estar seguro que el tiempo dedicado al desarrollo de las pruebas unitarias le valdrá la pena.
  2. Está claro que desarrollar pruebas unitarias para todas las funciones no vale la pena. Hay que mantener un equilibrio entre la importancia de la función, la probabilidad de fallos y la cantidad de código en la función. Por ejemplo, no hace falta escribir una prueba para una función de un par de líneas.
  3. Puede hacer cualquier cosa en las pruebas unitarias: abrir y cerrar órdenes, usar indicadores, objetos gráficos, etc. El único límite aquí es su imaginación.


Y por último:

Espero que este material le sea útil. Me encantaría responder a todas sus preguntas. Además, estoy abierto a cualquier sugerencia sobre posibles formas de mejorar este artículo y escribir otros.

Le deseo buena suerte y un código perfecto!