Gerardo Contijoch

Experiencias del día a día trabajando con .NET – ASP.NET, C#, ASP.NET MVC y demas…

Uso de ExpectedExceptionAttribute para testear excepciones

Posted by Gerardo Contijoch en diciembre 2, 2008

Hace tiempo que se viene hablando sobre el uso (¿mal uso?) del atributo ExpectedExceptionAttribute en NUnit o MSTest (y en cualquier otro framework de testing que lo tenga). En realidad el problema esta en el hecho de que se use el atributo y no en la forma de usarse. Según la documentación de NUnit, ExpectedExceptionAttribute “es la manera de indicar que la ejecución de un test va a provocar una excepción”.

Veamos un ejemplo de su uso (usando MSTest). Supongamos que tenemos que modelar un negocio en donde se venden cosas. Este es el código a testear:

    7
    8 public class Negocio {
    9
   10     public int Stock { get; private set; }
   11
   12     public Negocio(int stockInicial) {
   13         if (stockInicial <= 0) {
   14             throw new ArgumentOutOfRangeException("El stock no puede ser menor o igual a 0.");
   15         }
   16
   17         this.Stock = stockInicial;
   18     }
   19
   20     public void Vender(int cantidadDeUnidades) {
   21
   22         if (Stock < cantidadDeUnidades) {
   23             throw new ArgumentOutOfRangeException("No hay suficiente stock para vender.");
   24         }
   25
   26         /* procesamiento de la venta... */
   27
   28         this.Stock -= cantidadDeUnidades;
   29     }
   30 }

Como se ve es muy sencillo e incluye un par de validaciones básicas.

Ahora creamos un test para el método Vender para asegurarnos de que se esta actualizando correctamente el stock luego de realizar una venta.

   36
   37 [TestMethod]
   38 public void Vender_actualiza_stock_luego_de_venta() {
   39
   40     // Inicializamos nuestro negocio con un stock de 10 unidades
   41     var n = new Negocio(10);
   42
   43     // Vendemos 5
   44     n.Vender(5);
   45
   46     Assert.AreEqual(5, n.Stock, "No se actualizó correctamente el stock de unidades luego de una venta.");
   47 }

Si ejecutamos el test, veremos que es pasado sin problemas. Ahora creamos un test para asegurarnos que falla cuando no hay stock suficiente.

   48
   49 [TestMethod]
   50 [ExpectedException(typeof(ArgumentOutOfRangeException))]
   51 public void Vender_falla_si_no_hay_suficiente_stock() {
   52
   53     // Inicializamos nuestro negocio
   54     var n = new Negocio(0);
   55
   56     n.Vender(5);
   57
   58 }

Noten el uso de ExpectedExceptionAttribute para asegurarnos que el método Vender efectivamente falla provocando una excepción del tipo ArgumentOutOfRangeException.

Si ejecutamos el test, el mismo va a ser pasado sin mayores inconvenientes (debido a que va a fallar como es esperado), pero hay un grave problema oculto (que en este ejemplo es bastante evidente, pero no siempre lo es): El test esta fallando, pero no donde nosotros esperamos que falle (en el método Vender), sino que falla en el constructor de la clase Negocio (debido a que la cantidad pasada como stock inicial es inválida). Esto significa que nunca llegamos a ejecutar el método Vender y por lo tanto, no sabemos si efectivamente el mismo falla o no cuando no hay stock suficiente.

Para solucionar este problema, lo que se puede hacer es encerrar el código que debe fallar entre bloques try/catch y verificar que efectivamente se esta lanzando una excepción en ese punto (y no antes o en otro lugar), pero esa no es una solución muy elegante y hay que escribir demasiado código extra. Es por ello que las últimas versiones de los frameworks de testing mas populares incluyen clases y métodos helpers que nos facilitan la tarea de verificar que se esta lanzando una excepción.

Por ejemplo, NUnit 2.5 (aún en etapa beta) tiene el método Throws (de la clase Assert) que nos facilita la tarea haciendo que el código para testear la excepción quede de la siguiente manera:

   56 Assert.Throws<ArgumentOutOfRangeException>( () => n.Vender(5) );

Si la llamada al método Vender NO lanza una excepción del tipo ArgumentOutOfRangeException, entonces se lanza una excepción del tipo AssertionException, o lo que es lo mismo, se aborta el test en curso.

Lamentablemente MSTest no incluye ningún método similar y es por ello que tome la idea e hice un ‘port’ de ese método a MSTest:

    5
    6 static class CustomAssert {
    7
    8     /// <summary>
    9     /// Verifica que la ejecución de <paramref name="accion"/> lance una excepción.
   10     /// </summary>
   11     /// <typeparam name="TExcepcion">Tipo de la excepción esperada.</typeparam>
   12     /// <param name="accion"><see cref="Action"/> a ejecutar.</param>
   13     /// <returns>Excepción lanzada.</returns>
   14     /// <exception cref="AssertFailedException">La ejecución de <paramref name="accion"/> no produjo una excepción del tipo esperado.</exception>
   15     public static TExcepcion Throws<TExcepcion>(Action accion)
   16         where TExcepcion : System.Exception {
   17
   18         return Throws<TExcepcion>(accion, null);
   19     }
   20
   21     /// <summary>
   22     /// Verifica que la ejecución de <paramref name="accion"/> lance una excepción.
   23     /// </summary>
   24     /// <typeparam name="TExcepcion">Tipo de la excepción esperada.</typeparam>
   25     /// <param name="accion"><see cref="Action"/> a ejecutar.</param>
   26     /// <param name="mensaje">Mensaje a mostrar en caso de que no se lance la excepción esperada.</param>
   27     /// <returns>Excepción lanzada.</returns>
   28     /// <exception cref="AssertFailedException">La ejecucion de <paramref name="accion"/> no produjo una excepción del tipo esperado.</exception>
   29     public static TExcepcion Throws<TExcepcion>(Action accion, string mensaje)
   30         where TExcepcion : System.Exception {
   31         try {
   32             accion.Invoke();
   33         } catch (Exception ex) {
   34             CompararTiposYFallarSiSonDistintos(typeof(TExcepcion), ex.GetType(), mensaje);
   35             return ex as TExcepcion;
   36         }
   37
   38         Assert.Fail(string.Format("Se esperaba una excepción del tipo {0}, pero no se produjo ninguna.", typeof(TExcepcion).ToString()));
   39
   40         // Llamada necesaria para que compile
   41         return null;
   42     }
   43
   44     private static void CompararTiposYFallarSiSonDistintos(Type tipoEsperado, Type tipoActual, string mensaje) {
   45         if (tipoEsperado != tipoActual) {
   46             if (string.IsNullOrEmpty(mensaje)) {
   47                 mensaje = string.Format("Se esperaba una excepción del tipo {0}, pero se produjo una excepción del tipo {1}.", tipoEsperado.ToString(), tipoActual.GetType().ToString());
   48             }
   49
   50             Assert.Fail(mensaje);
   51         }
   52     }
   53
   54 }

Tuve que crear la clase CustomAsserts debido a que la clase Assert es estática y por lo tanto no se le pueden agregar métodos de extensión. Un detalle a tener en cuenta es que este método siempre devuelve la excepción producida por lo que se puede verificar que los valores de las propiedades de la misma sean los correctos. Esto ultimo es muy útil con excepciones del tipo ArgumentException (y derivadas) ya que normalmente querremos verificar que los argumentos que tiene problemas sean los que esperamos y no otros. Veamos un ejemplo:

   17
   18 public void MetodoConParametros(string param1, int param2) {
   19     if (param1 == string.Empty) {
   20         throw new ArgumentException("param1 no puede ser vacío.", "param1");
   21     }
   22
   23     if (param2 > 5) {
   24         throw new ArgumentException("param2 no puede ser mayor a 5.", "param2");
   25     }
   26 }
   27
   28 [TestMethod]
   29 public void MetodoConParametros_falla_si_param1_es_vacio() {
   30
   31     var ex = CustomAssert.Throws<ArgumentException>(() => MetodoConParametros("", 4));
   32
   33     Assert.AreEqual<string>("param1", ex.ParamName, "Se esperaba que param1 fuese el parámetro con problemas.");
   34 }
   35
   36 [TestMethod]
   37 public void MetodoConParametros_falla_si_param2_es_mayor_a_5() {
   38
   39     var ex = CustomAssert.Throws<ArgumentException>(() => MetodoConParametros("test", 80));
   40
   41     Assert.AreEqual<string>("param2", ex.ParamName, "Se esperaba que param2 fuese el parámetro con problemas.");
   42 }

Bien, eso es todo por el momento, espero que esta clase les sea tan útil como me resulta a mi (ya forma parte de mi ‘core’ para testing).

Pueden bajar la clase CustomAsserts aquí.

¡Nos vemos en el próximo post!

Publicado originalmente en https://gerardocontijoch.wordpress.com.

3 comentarios to “Uso de ExpectedExceptionAttribute para testear excepciones”

  1. Javi said

    Muy útil.
    Muchas gracias, llevo horas buscando la forma de testear que se ha lanzado la excepción adecuada.
    Tu post me lo ha resuelto despues de pasar por muchos, muchos sitios.

  2. Rodrigo said

    Consulta. En este caso no estas partiendo de un Test erroneo?
    Ya que titulas tu Test como Vender_falla_si_no_hay_suficiente_stock() Pero a la vez estas esperando una ArgumentOutOfRangeException.
    Esto no deberias testearlo en 2 tests diferentes y en el primero asegurarte de pasar un valor mayor a 0 en la instanciacion de Negocio?

  3. Lo que decís es verdad, tenes toda la razón, el test es erroneo, pero es solo un ejemplo de un test que falla en un punto inesperado. Lógicamente tenemos que asegurarnos de ingresar bien los datos para el test, ya que si los mismos son incorrectos, el test pierde validez. El problema está cuando ingresamos mal un valor y no nos damos cuenta porque el test parece funcionar correctamente, como en el ejemplo.

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s

 
A %d blogueros les gusta esto: