Vamos a estudiar cómo podemos probar nuestro código evitando probar también sus dependencias, asegurándonos que los errores se restringen únicamente a la sección de código que efectivamente queremos probar. Para ello, utilizaremos una herramienta que nos permitirá crear Mocks. La herramienta será Moq.
Los mocks son unos de los varios "test doubles" (es decir, objetos que no son reales respecto a nuestro dominio, y que se usan con finalidades de testing) que existen para probar nuestros sistemas. Los más conocidos son los Mocks y los Stubs, siendo la principal diferencia en ellos, el foco de lo que se está testeando.
Antes de hacer énfasis en tal diferencia, es importante aclarar que nos referiremos a la sección del sistema a probar como SUT (System under test). Los Mocks, nos permiten verificar la interacción del SUT con sus dependencias. Los Stubs, nos permiten verificar el estado de los objetos que se pasan. Como queremos testear el comportamiento de nuestro código, utilizaremos los primeros.
Tipo | Descripción |
---|---|
Dummy | Son objetos se pasan, pero nunca se usan. Por lo general, solo se utilizan para llenar listas de parámetros. |
Fake | Son objetos funcionales, pero generalmente toman algún atajo que los hace inadecuados para la producción (una base de datos en la memoria es un buen ejemplo). |
Stubs | Brindan respuestas predefinidas a las llamadas realizadas en el test, por lo general no responden a nada que no se use en el test. |
Spies | Son Stubs pero que también registran cierta información cuando son invocados. |
Mocks | Son objetos pre-programados con expetativas (son las llamadas que se espera que reciban), de todos estos objetos Mocks son los unicos que verifican el comportamiento. Los otros, solo verifican el estado. |
Imaginense que estamos probando el modulo A. Este modulo A utiliza otro modulo, el modulo B. Hacemos nuestras pruebas unitarias, probando el comportamiento que es esperado. Dentro de nuestro grupo de pruebas, vemos que una falla y no sabemos porque. Resulta que el modulo B tiene un problema, un bug, el cual causa que nuestras pruebas del modulo A no pasen. Esto es un problema, nosotros queremos probar solo el modulo A, no el B.
Cuando hacemos pruebas unitarias, queremos probar objetos y la forma en que estos interactúan con otros objetos. Para ello creamos instancias de Mocks, es decir, objetos que simulen el comportamiento externo (es decir, la interfaz), de un cierto objeto. Son objetos tontos, que no dependen de nadie, siendo útiles para aislar una cierta parte de la aplicación que queramos probar.
Hay ciertos casos en los que incluso los mocks son realmente la forma más adecuada de llevar a cabo pruebas unitarias.
Para poder hacer hacer mocks de las dependencias de un objeto que buscamos testear, es necesario cambiar el codigo que tenemos actualmente. Primero, analizaremos como es que se definen las dependencias en nuestro codigo. Tomemos una clase llamada StudentLogic que estaría Moodle.BusinessLogic
. Esta clase depende de StudentRepository de Moodle.DataAccess
. Como definimos esta dependencia? Simplemente en el constructor de StudentLogic creamos una nueva instancia.
private StudentRepository repository;
public StudentLogic() {
repository = new StudentRepository();
}
El problema que tiene este enfoque es que cuando creamos una instancia de StudentLogic, se creara una instancia real de StudentRepository, y no podremos inyectarle un instancia mockeada del repository. La solución (por ahora) es agregar otro constructor que reciba el StudentRepository. Sin embargo, esto no es suficiente: ya que no debemos recibir por parametro una instancia real del repositorio. Debemos crear una interfaz, la cual el repositorio implemente.
private IStudentRepository repository;
public StudentLogic(IStudentRepository repository = null)
{
if (repository == null)
{
this.repository = new StudentRepository();
}
else
{
this.repository = repository;
}
}
La interfaz IStudentRepository contendra los mismos metodos que tenia nuestro StudentRepository. Esta tambien sera implementada por los mocks que crearemos mas adelante, lo cual nos permite poder crear un StudentLogic con el mock! Debemos repetir esto para todas las clases que dependan de otra clase. Por ejemplo, StudentController debera poder recibir en el constructor una interfaz que sea implementada StudentLogic también.
Al hacer esto, no estamos atando la instancia a un objeto en particular, si no que estamos inyectando la dependencia. Como vimos en la clase anterior. Todo objeto que queramos mockear, debera tener una interfaz bien definida.
En consecuencia, generamos un bajo acoplamiento entre una clase y sus dependencias, lo cual nos facilita utilizar un framework de mocking. Especialmente para aquellos objetos que dependen de un recurso externo (una red, un archivo o una base de datos).
A pesar de que aun se esta instanciando la clase directamente en el constructor, veremos mas adelante como podemos remover eso. Iremos aun mas adelante y removeremos la instanciacion en el constructor. Por ahora, hacemos lo suficiente para que podamos hacer los tests.
Debemos hacer esto para todas las dependencias que tengamos.
Para comenzar a utilizar Moq, comenzaremos probando nuestro paquete de controllers de la WebApi. Para esto, crearemos un nuevo proyecto de MSTests (Moodle.WebApi.Tests) y le instalamos Moq.
dotnet new mstest -n Moodle.WebApi.Test
cd Moodle.WebApi.Test
dotnet add package Moq
Luego al proyecto de tests le agregaremos las referencias a WebApi, Domain y BusinessLogic.Interface
dotnet add reference ../Moodle.WebApi
dotnet add reference ../Moodle.Domain
dotnet add reference ../Moodle.BusinessLogic
Una vez que estos pasos estén prontos, podemos comenzar a realizar nuestro primer test. Creamos entonces la clase StudentControllerTests
, y en ella escribimos el primer TestMethod
.
[TestClass]
public class StudentControllerTests
{
[TestMethod]
public void CreateValidStudentOkTest()
{
//Arrange
//Act
//Assert
}
}
Para ello seguiremos la metodología AAA: Arrange, Act, Assert.
- Arrange: Contruimos el objeto mock y se lo pasamos al sistema a probar
- Act: Ejecutamos el sistema a probar
- Assert: Verificamos la interacción del SUT con el objeto mock.
[TestMethod]
public void CreateValidStudentOkTest()
{
Student student = new Student()
{
Name = "Daniel",
StudentNumber = "123456",
Courses = new List<Course>()
{
new Course()
{
Id = 1,
Name = "DA2"
}
}
};//1
ModelStudent modelStudent = new ModelStudent()
{
Name = "Daniel",
StudentNumber = "123456",
Courses = new List<ModelCourseBasicInfo>()
{
new ModelCourseBasicInfo()
{
Id = 1,
Name = "DA2"
}
}
}
var mock = new Mock<IStudentLogic>(MockBehavior.Strict); // 2
var controller = new StudentController(mock.Object);//3
var result = controller.Post(modelStudent); // 4
var createdResult = result as CreatedAtRouteResult; // 5
var model = createdResult.Value as ModelStudentDetailInfo; // 6
//Assert
}
Veremos que pasa en el test:
- Crea un objeto de
Student
que usaremos para el mock. Este retorna data que no nos importa, es solo para testing - Creamos el mock. La notacion es
new Mock<A>
siendo A la interfaz que queremos mockear. El parametro (MockBehhavior.Strict
) es una parametro de configuracion del mock..Strict
hace que se tire una excepcion cuando se llama un metodo que no fue mockeado, mientras que.Loose
retorna un valor por defecto si se llama un metodo no mockeado. - Se crea el controlador (
StudentController
) con el objeto mockeado. - Se ejecuta el metodo Post del controlador
- Debido a que la clase retorna un
CreatedAtRouteResult
, como se puede ver en la implementacion, casteamos el resultado a esto - Mediante
.Value
obtenemos el resultado de la request
Sin embargo, nos falta definir el comportamiento que debe tener el mock del nuestro IStudentLogic
. Esto es lo que llamamos expectativas y lo que vamos asegurarnos que se cumpla al final de la prueba. Recordemos, los mocks simulan el comportamiento de nuestros objetos, siendo ese comportamiento lo que vamos a especificar a partir de expectativas. Para ello, usamos el método Setup.
Esto va en función del método de prueba. Las expectativas se corresponden al caso de uso particular que estamos probando dentro de nuestro método de prueba. Si esperamos probar el Post()
de nuestro StudentController
, y queremos mockear la clase StudentLogic
, entonces las expectativas se corresponden a las llamadas que hace StudentController
sobre StudentLogic
.
Veamos el método a probar, el POST
de un student:
[HttpPost]
public IActionResult Post([FromBody] ModelStudent modelStudent)
{
try {
Student createdStudent = this.studentLogic.Add(modelStudent.ToEntity());
return CreatedAtRoute("GetStudent", new { id = createdStudent.Id }, new ModelStudentDetailInfo(createdStudent));
} catch(ArgumentException e) {
return BadRequest(e.Message);
}
}
La línea que queremos mockear es la de:
Student createdHomework = this.studentLogic.Add(modelStudent.ToEntity());
Entonces:
- Primero vamos a decirle que esperamos que sobre nuestro Mock que se llame a la función Add().
- Luego vamos a indicarle que esperamos que tal función retorne un student que definimos en otro lado.
[TestMethod]
public void CreateValidHomework()
{
Student student = new Student()new Student()
{
Name = "Daniel",
StudentNumber = "123456",
Courses = new List<Course>()
{
new Course()
{
Id = 1,
Name = "DA2"
}
}
};
ModelStudent modelStudent = new ModelStudent()
{
Name = "Daniel",
StudentNumber = "123456",
Courses = new List<ModelCourseBasicInfo>()
{
new ModelCourseBasicInfo()
{
Id = 1,
Name = "DA2"
}
}
};
var mock = new Mock<IStudentLogic>(MockBehavior.Strict);
mock.Setup(s => s.Add(It.IsAny<Student>())).Returns(student); //1
var controller = new StudentController(mock.Object);
var result = controller.Post(modelStudent);
var createdResult = result as CreatedAtRouteResult;
var model = createdResult.Value as ModelStudentDetailInfo;
mock.VerifyAll();//2
Assert.AreEqual(new ModelStudentDetailInfo(student), model);//3
}
Veamos que le agregamos al metodo de test:
- Seteamos el mock. Cuando decimos setear, queremos decir que le definimos el comportamiento que queremos en el test de un metodo de un mock. El metodo setup recibe una funcion inline de LINQ, la cual recibe el objeto a mockear. Es decir, en este caso
s
es un objeto de tipoIStudentLogic
. Aqui estamos definiendo que para el metodoAdd
, cuando reciba cualquier parametro de tipoStudent
(It.IsAny<Student>()
). En este caso, se retorna elstudent
. Asi como devolvimos el estudiante porque estamos simulando un caso sin errores tambien podemos devolver excepciones con Throw para simular que sucedio algo inesperado en la capa que se esta haciendo uso. - También debemos verificar que se hicieron las llamadas pertinentes. Para esto usamos el método
VerifyAll
del mock. Este revisa que fueron llamadas todas las funciones que mockeamos. - Verificamos que los datos obtenidos sean correctos. Para esto hacemos asserts (aquí estamos probando estado) para ver que los objetos usados son consistentes de acuerdo al resultado esperado. Para que este Assert funcione hay que hacer un override del metodo Equals en la clase ModelStudentDetailInfo para que se ejecute. Otra opcion seria verificar estado por estado sin redefinir el metodo Equals.
Corremos los tests utilizando dotnet test
y vemos que nuestro test pasa 😎!
Como dijimos antes tambien podemos mockear excepciones. Ahora veamos como probar cuando nuestro Post()
del Controller nos devuelve una BadRequest.
Particularmente, en el caso que hemos visto antes nuestro Controller retornaba CreatedAtRoute
para dicha situación. Ahora, nos interesa probar el caso en el que nuestro Controller retorna una BadRequest. Particularmente, esto se da cuando el método Add()
recibe null
. Para probar este caso entonces, seteamos dichas expectativas y probemos.
[TestMethod]
public void CreateInvalidStudentBadRequestTest()
{
var mock = new Mock<IStudentLogic>(MockBehavior.Strict);
mock.Setup(m => m.Add(null)).Throws(new ArgumentException());
var controller = new StudentController(mock.Object);
var result = controller.Post(null);
mock.VerifyAll();
Assert.IsInstanceOfType(result, typeof(BadRequestObjectResult));
}
Lo que hicimos fue indicar que cuando se invoque Add
con el parametro en null
, se lance ArgumentException
. En consecuencia, cuando nuestro controller llame a este mock, se lanzara ArgumentException
causando que nuestro controller la capture y retorne BadRequest
.
Finalmente entonces, verificamos que las expectativas se hayan cumplido (con el VerifyAll()
), y luego que el resultado obtenido sea un BadRequestObjectResult
, usando el metodo de Assert
IsInstanceOfType
.
Creamos nuestro proyecto:
dotnet new mstest -n Moodle.BusinessLogic.Test
cd Moodle.BusinessLogic/Test
dotnet add package Moq
Agregamos las referencias a BusinessLogic
, Domain
y finalmente a DataAccess
Creamos entonces la clase StudentLogicTests.
Entonces:
- Primero vamos a decirle que esperamos que sobre nuestro Mock que se llame a la función Add().
- Luego vamos a indicarle que esperamos que se llame la función Save().
- Invocamos Add
- Verificamos que se hicieron las llamadas pertinentes, y realizamos Asserts
[TestMethod]
public void CreateValidStudentTest()
{
Student student = new Student()new Student()
{
Name = "Daniel",
StudentNumber = "123456",
Courses = new List<Course>()
{
new Course()
{
Id = 1,
Name = "DA2"
}
}
};
var mock = new Mock<IStudentRepository>(MockBehavior.Strict);
mock.Setup(m => m.Add(It.IsAny<Student>()));
mock.Setup(m => m.Save());
var studentLogic = new StudentLogic(mock.Object);
var result = studentLogic.Add(student);
mock.VerifyAll();
Assert.AreEqual(student, result);
}
- Agregar muchos mas tests a todo el sistema!
En la documentación de MOQ, se encuentran varios ejemplos y definiciones de como hacer los mocks:
Tipos de respuesta de Web API. Puede ser importante saberlo para testear las respuestas de los controladores de Web API
Info en general: