Pruebas Unitarias

Una prueba unitaria se encarga de probar una unidad de trabajo. Una característica de estas pruebas es que intentan probar una pequeña y aislada parte del software, sin importarle las dependencias externas.

Las pruebas unitarias tienen la particularidad de ser bastante rápidas, por lo que es normal ver test suites compuestos de una alta proporción de pruebas unitarias.

En términos prácticos, una prueba unitaria tiende a probar una función de una clase. Es normal escribir varias pruebas unitarias las cuales verifican distintos comportamientos de una función.

Ejemplo. Supongamos que tenemos un software para hacer transferencias entre cuentas bancarias. Queremos hacer pruebas unitarias a una función de nuestra aplicación. Primero vamos a ver un modelo de nuestro código fuente:

public class Cuenta
{
   public decimal Fondos { get; set; }
}

Este es un modelo bastante simplificado de una cuenta bancaria, el cual solo tiene la información de los fondos de la cuenta, esto es, el dinero que actualmente se encuentra en dicha cuenta. Ahora, vamos a ver la función que se encarga de realizar las transferencias entre cuentas:

public class ServicioDeTransferencias
{

  public void TransferirEntreCuentas(Cuenta origen, Cuenta destino, decimal
  montoATransferir)
  {
    if (montoATransferir > origen.Fondos)
    {
      throw new ApplicationException("La cuenta origen no tiene fondos suficientes para realizar la operación");
    }

    origen.Fondos -= montoATransferir;
    destino.Fondos += montoATransferir;
  }
}

Esta función sirve para transferir fondos entre cuentas, como parámetros recibe una cuenta de origen, una cuenta destino, y un monto a transferir. Si la cuenta de origen no tiene fondos suficientes para realizar la transacción, se arroja un error. Ciertamente esta es una función ficticia la cual simplifica la operación de transferencia de montos entre cuentas, sin embargo, es suficiente para poder crear dos pruebas unitarias.

En lo personal, yo prefiero empezar con pruebas negativas, es decir, pruebas que de un modo u otro verifican que las operaciones inválidas reciben un mensaje de error adecuado. En nuestro caso, una prueba negativa sería intentar transferir fondos desde una cuenta origen sin fondos suficientes, y verificar que el usuario reciba un mensaje de error adecuado. Hagamos una prueba unitaria que verifique esto. Lo primero que necesitamos es crear un proyecto de pruebas, para eso:

– Si usas Visual Studio, debes hacer click derecho en tu solución > Add > New Project. Luego haz click en Test y escoge la opción Unit Test Project. Coloca un nombre al proyecto, y presiona OK.

– Si estás utilizando el dotnet CLI, puedes usar el comando dotnet new mstest -n [Nombre del proyecto] para crear un proyecto de pruebas.

Luego, crea la siguiente clase en dicho proyecto:

[TestClass]
public class TransferenciasTests
{
  [TestMethod]
  public void TransferenciaEntreCuentasConFondosInsuficientesArrojaUnError()
  {
    // Preparación
    Exception expectedException = null;
    Cuenta origen = new Cuenta() { Fondos = 0 };
    Cuenta destino = new Cuenta() { Fondos = 0 };
    decimal montoATransferir = 5m;
    var servicio = new ServicioDeTransferencias();

    // Prueba
    try
    {
      servicio.TransferirEntreCuentas(origen, destino, montoATransferir);
      Assert.Fail("Un error debió ser arrojado");
    }
    catch(Exception ex)
    {
     expectedException = ex;
    }

    // Verificación
    Assert.IsTrue(expectedException is ApplicationException);
    Assert.AreEqual("La cuenta origen no tiene fondos suficientes para realizar la operación", expectedException.Message);
  }
}

En el código anterior tenemos una clase con un método. La clase y el método están decorados con el atributo TestClass y TestMethod, respectivamente. Un TestClass es una clase que utilizamos para agrupar varias pruebas. Un TestMethod es un método el cual utilizamos para probar funcionalidad.

Podemos ver que el TestMethod anterior se divide en las 3 partes que habíamos hablado: preparación, prueba y verificación. En la parte de preparación creamos las cuentas, el monto a transferir e inicializamos la clase que utilizaremos para hacer la transferencia. En la fase de prueba simplemente ejecutamos la funcionalidad que deseamos probar. En nuestro caso la colocamos en un bloque try para atrapar la excepción. Finalmente, en la etapa de verificación, comprobamos que la excepción lanzada es del tipo ApplicationException, y que el mensaje de la excepción es el correcto. Las verificaciones las realizamos haciendo Asserts.

Assert, que significa afirmar, nos sirve para hacer declaraciones las cuales entendemos deben ser correctas, de no serlo, se arroja un error y la prueba falla. Una prueba es exitosa cuando todas sus afirmaciones son acertadas. En el ejemplo anterior afirmamos que la excepción era del tipo ApplicationException y que el mensaje de error era el correcto.

Podemos correr esta prueba de distintas maneras. Si estás utilizando Visual Studio, puedes ir a Test > Windows > Test Explorer, y presionar Run All. Esto hará que corran todas las pruebas encontradas en la solución.

test explorer

En el test explorer podemos ver que la prueba ha sido exitosa

Si quieres, puedes correr las pruebas de tu solución usando el dotnet CLI. Para eso, utiliza el comando dotnet test en la carpeta donde se encuentran tus proyectos de prueba.

Vamos ahora a hacer una prueba donde verifiquemos que la operación es exitosa si existen fondos suficientes:

[TestMethod]
public void TransferenciaEntreCuentasEditaLosFondos()
{
     // Preparación
     Cuenta origen = new Cuenta() { Fondos = 10 };
     Cuenta destino = new Cuenta() { Fondos = 5 };
     decimal montoATransferir = 7m;
     var servicio = new ServicioDeTransferencias();

     // Prueba
     servicio.TransferirEntreCuentas(origen, destino, montoATransferir);

     // Verificación
     Assert.AreEqual(3, origen.Fondos);
     Assert.AreEqual(12, destino.Fondos);
}

En esta segunda prueba la transacción es exitosa, por lo que nuestra verificación consiste en comprobar que los fondos se debitaron de la cuenta de origen, y se acreditaron en la cuenta destino.

Ahora que tenemos los dos métodos de pruebas, posiblemente veas que podemos hacer una pequeña refactorización: Podemos centralizar la instanciación de la clase ServicioDeTransferencias en un sitio común. La idea de esto es que, si algún día el constructor de ServicioDeTransferencias cambia, solamente debamos cambiar un solo lugar, en vez de tener que cambiar varios lugares. Los buenos principios de desarrollo de software aplican también para tus pruebas automáticas. En este caso, hablamos del principio de disminuir el código repetido siempre que sea viable.

Vamos a alterar el constructor de ServicioDeTransferencias para que acepte un parámetro. Este parámetro va a ser una clase la cual va a encapsular las reglas de validación de una transferencia. Así, la clase de ServicioDeTransferencias se va a concentrar en las transferencias, y la validación de estas será delegada a otra clase. Esto sirve para respetar el principio de responsabilidad única. Supongamos que tenemos el siguiente código:

public interface IServicioValidacionesDeTransferencias
{
   string RealizarValidaciones(Cuenta origen, Cuenta destino, decimal montoATransferir);
}

public class ServicioDeTransferencias
{
   private readonly IServicioValidacionesDeTransferencias _servicioValidaciones;
   public ServicioDeTransferencias(IServicioValidacionesDeTransferencias servicioValidaciones)
   {
       _servicioValidaciones = servicioValidaciones;
   }

   public void TransferirEntreCuentas(Cuenta origen, Cuenta destino, decimal montoATransferir)
   {
     var mensajeError = _servicioValidaciones.RealizarValidaciones(origen, destino, montoATransferir);

     if (!string.IsNullOrEmpty(mensajeError))
     {
         throw new ApplicationException(mensajeError);
     }

     origen.Fondos -= montoATransferir;
     destino.Fondos += montoATransferir;
     }
 }

public class ServicioValidacionesDeTransferencias : IServicioValidacionesDeTransferencias
{
   public string RealizarValidaciones(Cuenta origen, Cuenta destino, decimal montoATransferir)
   {
      if (montoATransferir > origen.Fondos)
      {
        return "La cuenta origen no tiene fondos suficientes para realizar la operación";
      }

      // ... otras validaciones
       return string.Empty;
   }
 }

Como podemos ver, hemos encapsulado la lógica de la validación de una transferencia en una clase llamada ServicioValidacionesDeTransferencias, la cual implementa la interfaz IServicioValidacionesDeTransferencias. Luego, la clase ServicioDeTransferencias recibe como parámetro en el constructor la interfaz IServicioValidacionesDeTransferencias. De esta manera, la clase de ServicioDeTransferencias no tiene conocimiento acerca de las reglas de validación que se aplican para validar una transferencia, ya que esto no es de su interés. Esto nos permite incluso cambiar, en tiempo de ejecución, las validaciones a utilizar de manera dinámica.

Si compilamos nuestro proyecto, tendremos dos errores que provienen de nuestras pruebas automáticas. Lo que sucede es que ahora nuestra clase ServicioDeTransferencias requiere un parámetro en el constructor. Sin embargo, debemos hacernos una pregunta, ¿Desde nuestras pruebas automáticas, qué debemos pasarle a la clase que queremos probar? ¿Debemos pasar ServicioValidacionesDeTransferencias?

La respuesta a esta pregunta depende de varios factores. Si por alguna razón sabemos que solamente habrá una clase que implemente IServicioValidacionesDeTransferencias, entonces quizás ni queramos utilizar una interfaz, y prefiramos simplemente pasar como parámetro la clase ServicioValidacionesDeTransferencias. En este caso, podemos pasar ServicioValidacionesDeTransferencias desde nuestras pruebas automáticas a ServicioDeTransferencias.

Sin embargo, hay ocasiones en las que esto quizás no sea ideal. Si planeas tener varias implementaciones de IServicioValidacionesDeTransferencias, o si quieres probar la clase ServicioDeTransferencias sin importar sus dependencias, entonces no debes de pasar ServicioValidacionesDeTransferencias desde tus pruebas automáticas, sino que debes pasar un mock. Hablaremos de mocks en la siguiente entrada.

Conclusión

Las pruebas unitarias prueban una unidad de trabajo. Típicamente esto significa probar una función, aunque pueden ser varias. Con respecto al tema de las dependencias, no siempre es obligatorio tener que inyectar las dependencias de una clase que vamos a intentar probar, sobretodo si estas dependencias son parte de la unidad de trabajo que deseamos probar.

One comment

Leave a comment