miércoles, 29 de agosto de 2007

Introducción a las pruebas unitarias ó Unit Test en C#

En esta ocasión vamos a introducirnos a un concepto que está en boca de todo desde hace un tiempo, las pruebas unitarios, que según algunos son la solución a todos los problemas del desarrollo de software y su complejidad inherente. Pues bien, esto no es tan así, porque si bien las pruebas unitarias nos permiten asegurar que nuestros artefactos pasan las pruebas, ésta últimas deben cubrir los casos correspondientes y esto depende de quien las ha escrito. Pero bueno dejemos la discusión para más adelante, vamos a ver de qué se trata.

Divide y triunfarás

Esta frase de Máxima Maquiavelo tiene mucho de cierto, si algo es muy complejo para enfrentarlo, sobre todo si es a causa de su tamaño, es mejor dividirlo en partes pequeñas; por lo tanto si tenemos un sistema de información complejo es natural que lo descompongamos, en nuestro caso en clases y métodos. Las pruebas unitarias se basan en esto como idea principal, antes que nada nuestras mentes no pueden manipular más de siete ideas simultáneamente (seguramente la mia un poco menos que eso), por lo tanto un sistema complejo es demasíado para comprenderlo completo, es por esto que se separa en partes para poder comprender cada una individualmente. Las pruebas unitarias apuntar a probar estas partes.

Entonces, ¿Cómo que es exáctamente una prueba unitaria?

En software una prueba unitaria no es más que una clase con algún método que permite probar otro método, para evaluar el resultado se utilizan asserts (afirmaciones), del siguiente modo: sabemos que el método Pow de la clase Math nos dará como resultado "a" elevado a "b", bien para probarlo hacemos

Assert.AreEqual(Math.Pow(2,3),8);

En este caso estamos diciendo que la prueba unitaria nos indique si el resultado de Math.Pow(2,3) es igual a 8, en caso de no serlo la prueba unitaria no será exitosa.

Primer ejemplo de código con VSTS

Vamos a ver cómo quedaría una clase para probar esto con VSTS

namespace TestProject1 
{ 
 [TestClass()] 
 public class ProgramTest 
 { 
  [TestMethod()] 
  public void SumarTest() 
  { 
   Assert.AreEqual(8, Math.Pow(2, 3)); 
  } 
 } 
}

Lo único particular de este código es que la clase se encuentra adornada con el atributo TestClass y el método de prueba con TestMethod, dentro del método la línea que realiza la prueba, para probar esto con VSTS es necesario invocar al TestManager

 

 

Corremos la prueba de unidad y vemos que funciona, desde el resultado.

 

 

Ok, esto es muy tonto, sin embargo eficiente, si cambiaramos la implementación de Math.Pow podríamos decir que sigue siendo válida porque pasa esta prueba de unidad, por lo tanto cumple con su cometido, vamos a ver un ejemplo más real.

Eficiencia de las pruebas unitarías

Las pruebas unitarías no son la panacea, ni mucho menos, porque dependen de quién las escribe, vamos a desarrollar un ejemplo completo y demostrar por qué es importante que las pruebas sean correctas y consideren la mayor cantidad de cosas posibles.

Supongamos que necesitamos crear un método que toma dos parámetros numéricos y nos devuelve la suma, bien si escucharon alguna vez acerca de programación comandada por pruebas (TDD) se preguntarás qué es esto, bien, justamente, descomponemos nuestro sistema en partes para finalmente tener operaciones lo más atómicas posibles para una primera aproximación y antes de escribir nada de código las probamos. Pero cómo se hace esto, bien veamos nuestro ejemplo de la operación de suma.

Primero definimos la interfase del método

Como dice el título definimos la interfase del método, no debería ser difícil ya que se desprende de nuestro análisis anterior, hagamos el nuestro.

public int Sumar(int a, int b)
{
 return 0;
}

Bien, tenemos nuestro método sin emplementación ahora sin hacer nada, vamos a la prueba unitaria, nuevamente hacemos botón derecho sobre el método y VSTS nos propone crear la prueba, aceptamos y vemos el código generado

[TestMethod()] 
public void SumarTest() 
{ 
 Program target = new Program(); // TODO: Initialize to an appropriate value 
 int a = 0; // TODO: Initialize to an appropriate value 
 int b = 0; // TODO: Initialize to an appropriate value 
 int expected = 0; // TODO: Initialize to an appropriate value 
 int actual; actual = target.Sumar(a, b); 
 Assert.AreEqual(expected, actual); 
}

Bien, a esto tenemos que configurarlo para que la prueba sea útil tenemos que poner valores en a, b, expected, pongamos 2,3 y 5 sucesivamente.

[TestMethod()] 
public void SumarTest() 
{ 
 Program target = new Program(); 
 int a = 2; 
 int b = 3; 
 int expected = 5; 
 int actual; actual = target.Sumar(a, b); 
 Assert.AreEqual(expected, actual); 
}

Lo corremos y listo, no funciona, nos da error, obviamente porque no implementamos el método, con esto por ejemplo podemos pasarlo a un desarrollador para que implemente el método, pero no seamos tan pacatos, hagámoslo nosotros

public int Sumar(int a, int b) 
{ 
 return a + b; 
}

Sencillo, y corremos el test y pasa con éxito.

Bueno, ahora sabemos qué es el desarrollo guiado por pruebas, pero...(siempre hay un pero) nuestra prueba no es del todo eficiente, por ejemplo hagamos lo siguiente en la prueba de unidad.

Imaginemos que le damos la prueba de unidad al programador y nos devuelve el código que pasa la prueba, buenísimo, pero se nos ocurre probar los siguiente

[TestMethod()] 
public void SumarTest() 
{ 
 Program target = new Program(); 
 int a = 6; 
 int b = 3; 
 int expected = 9; 
 int actual; 
 actual = target.Sumar(a, b); 
 Assert.AreEqual(expected, actual); 
}

No pasa, vamos al código de método y vemos esto:

public int Sumar(int a, int b) 
{ 
 return 5; 
}

Caramba, algo falló y no fué el programador, el test estaba mal planteado, como regla deberíamos considerar un par de casos de éxito y por qué no, de error, de este modo

        [TestMethod()]
        public void SumarTest()
        {
            Program target = new Program(); 
            int a = 6;
            int b = 3;
            int expected = 9;
            int actual;
            actual = target.Sumar(a, b);
            Assert.AreEqual(expected, actual);
 
            a = 2;
            b = 3;
            expected = 5;
            actual = target.Sumar(a, b);
            Assert.AreEqual(expected, actual);
 
            a = 6;
            b = 3;
            expected = 8;
            actual = target.Sumar(a, b);
            Assert.AreNotEqual(expected, actual);
 
            a = 2;
            b = 3;
            expected = 9;
            actual = target.Sumar(a, b);
            Assert.AreNotEqual(expected, actual);
        }

Con esta prueba de unidad nos aseguramos un par de casos de éxito y de error, se puede considerar correcta para lo que necesitamos.

Conclusión

Las pruebas unitarias son de mucha ayuda, permiten detectar problemas de diseño, nos ayudan a hacer código más atómico, a tener componentes probados antes de la integración, pero es necesario detenerse a pensarlas un momento para que sean correctas. Saludos.

2 comentarios:

JoseluisGV dijo...

Hola Leo.

Siempre me he preguntado la ventaja de usar un desarrollo guiado por las pruebas unitarias. Me explico:

Para casos/algoritmos resueltos por métodos medianamente simples, numéricos o de otro tipo de operaciones aritméticas (alfanuméricas o numéricas), me parece bien, incluso automatizar los test con juegos de prueba al azar y revisar los resultados manualmente.

Pero... ¿qué pasa con aquellas operaciones NO aritméticas? Por ejemplo: Queremos testear la bondad de un método de inserción de registros en una BBDD, y comprobar si se manejan excepciones en caso de campos NULL, con tipo de datos o longitudes erróneas... etc. ya sabes...

Es probable que tardes más en desarrollar el método de prueba y contemplar todos los posibles casos que revisar manualmente el código del método directamente. Es más, el número de combinaciones posibles haría imposible automatizar todos los test.

Sin embargo, estas pruebas deben realizarse (de alguna otra forma), al menos que cubran un 90% de los casos posibles de la operación (en operaciones NO aritméticas) antes de llegar a la fase en la que sea el usuario el que prueba la aplicación.

Aun estoy en la búsqueda de "algo" que implemente estas pruebas... algo que le puedas decir el método al que tiene que llamar de qué clase. Ese algo te recoja los parámetros de entrada y su tipo, y el solito se cree juegos de pruebas a saco... eso sí, no te queda más remedio que picarte tu a mano el resultado de la operación.

¿Alguien sabe de algun método/producto de este tipo? Lo que más se acerca a algo parecido es "jenny", producto Open Source del que he oído hablar pero no lo he probado.

Si alguien sabe algo... ya sabe... que lo diga ahora....

Leonardo Micheloni dijo...

Hola José Luís, gracias por dejar tu comentario. Estoy de acuerdo contigo acerca de las pruebas unitarias, son útiles, no es para nada práctico probar una librería que es utilizada en varias partes de un proyecto simplemente agregándola y viendo qué pasa, sin duda las pruebas unitarias son buenas en estos casos. Sin embargo como tú dices el caso del acceso a datos es complejo, a lo que tú dices hay que agregar que los datos de la base de datos deberían quedar como al principio de la prueba. Existe el framework de pruebas de Microsoft para base de datos dentro del VSTS, pero no he tenido acceso a él, aquí hay un par de link interesantes acerca de los test sobre la base de datos.

http://geeks.ms/blogs/rcorral/archive/2006/12/02/beneficios-y-carater-iacute-sticas-de-un-buen-test-unitario.aspx
http://geeks.ms/blogs/rcorral/archive/2006/07/03/626.aspx

El otro punto que señalas, el de los casos en concreto, a mi también me parece que debería existir algo que cree los casos de prueba, voy a mirar "jenny" realmente no lo conocía a ver qué tal, ya que hoy por hoy como pongo en el ejemplo depende mucho de la habilidad de quién escribe las pruebas la eficiencia de las misma. Sin embargo creo que son muy buenas en un punto que no es justamente probar los métodos, y es el de validar los requerimientos, con un poco de TDD estamos seguros de qué es lo que se espera de un método y es más, podemos detectar problemas de diseño del mismo antes de ir a producción. Saludos.