Pruebas unitarias en un servicio Web API RESTful de .NET Core
Realizar pruebas unitarias sobre un servicio Web API de .NET es una tarea algo más "complicada" de lo que parecería a simple vista. Esto es debido a que no solamente hay que comprobar la consistencia de los resultados de cada acción, si no que además debemos asegurar que las respuestas (Status Codes) de los Métodos Http del servicio sean las correctas y esperadas en cada momento.
Como continuación al Post Pruebas unitarias en Entity Framework Core - SqLite in-memory, en este caso veremos como realizar las pruebas unitarias a un servicio Web API de .NET Core 2.1, que implementa los métodos Http básicos (GET, POST, PUT, DELETE) a través de un servicio estándar de acceso a datos (CRUD).
El escenario de pruebas
En primer lugar crearemos la clase "controladora" del Web API sobre la cual realizaremos las pruebas unitarias posteriormente (PaisController.cs
). Este Web API se encargará de realizar las operaciones básicas de acceso a datos (CRUD) sobre una entidad de modelo de ejemplo (Pais.cs
), a través del servicio asíncrono PaisesDataService.cs
.
[Produces("application/json")]
[Route("api/Pais")]
public class PaisController : Controller
{
private readonly IPaisesDataService paisesDataService;
public PaisController(IPaisesDataService paisesDataService)
{
this.paisesDataService = paisesDataService;
}
[HttpGet]
public async Task<IActionResult> GetPaises()
{
var paises = await paisesDataService.ReadPaises();
// Ok Response Status 200.
return Ok(paises);
}
// GET: api/Pais/5
[HttpGet("{id}")]
public async Task<IActionResult> GetPais([FromRoute] int id)
{
var pais = await paisesDataService.ReadPais(id);
if (pais == null)
{
// NotFound Response Status 404.
return NotFound();
}
// Ok Response Status 200.
return Ok(pais);
}
// POST: api/Pais
[HttpPost]
public async Task<IActionResult> PostPais([FromBody] Pais pais)
{
if (!ModelState.IsValid)
{
// BadRequest Response Status 400.
return BadRequest(ModelState);
}
if (await paisesDataService.CreatePais(pais) == 0)
{
// NotFound Response Status 404.
return NotFound();
}
// Created Response Status 201.
return CreatedAtAction("GetPais", new { id = pais.Id }, pais);
}
// PUT: api/Pais/5
[HttpPut("{id}")]
public async Task<IActionResult> PutPais([FromRoute] int id, [FromBody] Pais pais)
{
if (!ModelState.IsValid)
{
// BadRequest Response Status 400.
return BadRequest(ModelState);
}
if (id != pais.Id)
{
// BadRequest Response Status 400.
return BadRequest();
}
if (await paisesDataService.UpdatePais(pais) == 0)
{
// NotFound Response Status 404.
return NotFound();
}
// NoContent Response Status 204.
return NoContent();
}
// DELETE: api/Pais/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeletePais([FromRoute] int id)
{
if (await paisesDataService.DeletePais(id) == 0)
{
// NotFound Response Status 404.
return NotFound();
}
// Ok Response Status 200.
return Ok();
}
}
Como podemos observar en el código, el controlador PaisController
recibe como parámetro en el constructor, una instancia del servicio de acceso a datos PaisesDataService
a través de su interfaz IPaisesDataService
.
public interface IPaisesDataService
{
Task<int> CreatePais(Pais pais);
Task<Pais> ReadPais(int id);
Task<IEnumerable<Pais>> ReadPaises();
Task<int> UpdatePais(Pais pais);
Task<int> DeletePais(int id);
}
El servicio PaisesDataService.cs
es exactamente el mismo que ya vimos y explicamos en el Post Pruebas unitarias en Entity Framework Core - SqLite in-memory.
Las pruebas unitarias
Una vez construido el escenario de pruebas de ejemplo, pasaremos a crear un nuevo proyecto de pruebas unitarias del tipo Proyecto de prueba de MSTest(.NET Core). Para este ejemplo se ha utilizado Visual Studio Community 2017 (con todas las actualizaciones al día).
Por supuesto, es necesario añadir una referencia (dependencia) al proyecto sobre el que vamos a realizar las pruebas (el escenario de pruebas anteriormente creado).
El servicio de acceso a datos de pruebas (Fake)
Para realizar las pruebas unitarias, necesitamos "simular" un entorno de acceso a datos que implemente la interfaz IPaisesDataService
. Este entorno o servicio ficticio (Fake) será "inyectado" a través del constructor del Web API (PaisController
) a la hora de realizar las pruebas unitarias, y deberá proporcionar la misma funcionalidad que el servicio PaisesDataService
nos ofrecería en un entorno real de producción.
public class PaisesDataServiceFake : IPaisesDataService
{
private SqLiteDbFake sqLiteDbFake;
public PaisesDataServiceFake()
{
sqLiteDbFake = new SqLiteDbFake();
using (var context = sqLiteDbFake.GetDbContext())
{
context.Paises.Add(new Pais { Id = 1, Nombre = "España", Habitantes = 1000, Provincias = new List<Provincia>() });
context.Paises.Add(new Pais { Id = 2, Nombre = "Francia", Habitantes = 1000, Provincias = new List<Provincia>() });
context.Paises.Add(new Pais { Id = 3, Nombre = "Italia", Habitantes = 1000, Provincias = new List<Provincia>() });
context.SaveChangesAsync();
}
}
public async Task<int> CreatePais(Pais pais)
{
using (var context = sqLiteDbFake.GetDbContext())
{
context.Paises.Add(pais);
try
{
return await context.SaveChangesAsync();
}
catch (Exception)
{
return 0;
}
}
}
public async Task<Pais> ReadPais(int id)
{
using (var context = sqLiteDbFake.GetDbContext())
{
return await context.Paises.Include(x => x.Provincias).SingleOrDefaultAsync(m => m.Id == id);
}
}
public async Task<IEnumerable<Pais>> ReadPaises()
{
using (var context = sqLiteDbFake.GetDbContext())
{
return await context.Paises.ToListAsync();
}
}
public async Task<int> UpdatePais(Pais pais)
{
using (var context = sqLiteDbFake.GetDbContext())
{
context.Entry(pais).State = EntityState.Modified;
try
{
return await context.SaveChangesAsync();
}
catch (Exception)
{
return 0;
}
}
}
public async Task<int> DeletePais(int id)
{
using (var context = sqLiteDbFake.GetDbContext())
{
var pais = context.Paises.SingleOrDefault(m => m.Id == id);
try
{
context.Paises.Remove(pais);
return await context.SaveChangesAsync();
}
catch (Exception)
{
return 0;
}
}
}
}
Como podemos ver, el servicio de pruebas PaisesDataServiceFake.cs
, simula un entorno de acceso a datos CRUD sobre una base de datos en memoria SqLite in-memory. Todo lo relativo a cómo crear bases de datos en memoria y a la clase SqLiteDbFake.cs
, se puede ver en el Post Pruebas unitarias en Entity Framework Core - SqLite in-memory.
La clase de pruebas unitarias (TestClass)
Por último, crearemos las clase de pruebas unitarias (PaisControllerTests.cs
) para el controlador Web API PaisController.cs
.
[TestClass]
public class PaisControllerTests
{
// Servicio de acceso a datos Fake.
private PaisesDataServiceFake paisesDataServiceFake;
public PaisControllerTests()
{
paisesDataServiceFake = new PaisesDataServiceFake();
}
...
...
// Aquí todas las pruebas unitarias...
...
...
}
Como vemos en el código, en el constructor de la clase PaisControllerTests
instanciamos un objeto del servicio de pruebas PaisesDataServiceFake
, el cual utilizaremos posteriormente para realizar las pruebas unitarias (TestMethod
).
Programando las pruebas unitarias (TestMethod)
Para obtener una mejor legibilidad del código y una estructura fácilmente mantenible, organizaremos las pruebas en función de su cometido. En nuestro caso las dividiremos en GET, POST, PUT y DELETE.
Debemos tener también en cuenta que los nombres de las pruebas deben de ser lo suficientemente descriptivos como para saber a primera vista cual es su función.
Pruebas GET
[TestMethod]
public void GetPaises_Retorna_OkResult_con_todos_los_registros_Async()
{
// Arrange
var paisController = new PaisController(paisesDataServiceFake);
// Act
var response = paisController.GetPaises().Result;
// Assert
// El 'Response' es del tipo OkResult y devuelve un Objeto (OkObjectResult).
Assert.IsInstanceOfType(response, typeof(OkObjectResult));
// El Objeto devuelto por el OkResult es del tipo IEnumerable<Pais>.
Assert.IsInstanceOfType((response as OkObjectResult).Value, typeof(IEnumerable<Pais>));
// El Objeto devuelto por el OkResult (IEnumerable<Pais>) contiene todos los registros de la BD.
Assert.AreEqual(3, ((response as OkObjectResult).Value as IEnumerable<Pais>).Count());
}
[TestMethod]
public void GetPais_Retorna_OkResult_con_el_objeto_al_pasar_Id_existente_Async()
{
// Arrange
var paisController = new PaisController(paisesDataServiceFake);
// Act
var response = paisController.GetPais(1).Result;
// Assert
// El 'Response' es del tipo OkResult y devuelve un Objeto (OkObjectResult).
Assert.IsInstanceOfType(response, typeof(OkObjectResult));
// El Objeto devuelto por el OkResult es del tipo Pais.
Assert.IsInstanceOfType((response as OkObjectResult).Value, typeof(Pais));
// El Id del Objeto devuelto por el OkResult (Pais) es igual al Id pasado.
Assert.AreEqual(1, ((response as OkObjectResult).Value as Pais).Id);
}
[TestMethod]
public void GetPais_Retorna_NotFoundResult_al_pasar_Id_inexistente_Async()
{
// Arrange
var paisController = new PaisController(paisesDataServiceFake);
// Act
var response = paisController.GetPais(0).Result;
// Assert
// El 'Response' es del tipo NotFoundResult.
Assert.IsInstanceOfType(response, typeof(NotFoundResult));
}
Como podemos observar en las pruebas, cada vez que instanciemos el controlador del Web API para probar la funcionalidad de alguna de sus acciones, le pasaremos a través del constructor la instancia del servicio de acceso a datos de pruebas (Fake) var paisController = new PaisController(paisesDataServiceFake)
.
La particularidad en este tipo de pruebas, es que no solo debemos comprobar que las funcionalidades CRUD hacia la base de datos funcionan correctamente (Assert.AreEqual(3, ((response as OkObjectResult).Value as IEnumerable<Pais>).Count())
), sino que además las respuestas (Status Codes) de los Métodos Http del servicio sean las correctas y esperadas en cada momento (Assert.IsInstanceOfType(response, typeof(OkObjectResult))
).
Para el resto de las pruebas unitarias, el procedimiento será básicamente el mismo.
Pruebas POST
[TestMethod]
public void PostPais_Retorna_BadRequest_al_pasar_un_objeto_invalido_Async()
{
// Arrange
var paisController = new PaisController(paisesDataServiceFake);
var paisTest = new Pais
{
Habitantes = 1000,
};
// Añadir el error para que el ModelState.IsValid lo detecte
// antes de añadir el registro a la BD.
paisController.ModelState.AddModelError("Nombre", "Required");
// Act
var response = paisController.PostPais(paisTest).Result;
// Assert
Assert.IsInstanceOfType(response, typeof(BadRequestObjectResult));
}
[TestMethod]
public void PostPais_Retorna_NotFoundResult_si_el_objeto_no_puede_ser_creado_Async()
{
// Arrange
var paisController = new PaisController(paisesDataServiceFake);
var paisTest = new Pais
{
Id = 1,
Nombre = "PaisTest",
Habitantes = 1000,
};
// Act
var response = paisController.PostPais(paisTest).Result;
// Assert
Assert.IsInstanceOfType(response, typeof(NotFoundResult));
Assert.AreEqual(3, paisesDataServiceFake.ReadPaises().Result.Count());
}
[TestMethod]
public void PostPais_Retorna_CreatedResponsee_con_objeto_creado_al_pasar_y_crear_un_objeto_valido_Async()
{
// Arrange
var paisController = new PaisController(paisesDataServiceFake);
var paisTest = new Pais
{
Nombre = "PaisTest",
Habitantes = 1000,
};
// Act
var response = paisController.PostPais(paisTest).Result;
//var item = response.Value as Pais;
// Assert
Assert.IsInstanceOfType(response, typeof(CreatedAtActionResult));
Assert.IsInstanceOfType((response as CreatedAtActionResult).Value, typeof(Pais));
Assert.AreEqual("PaisTest", ((response as CreatedAtActionResult).Value as Pais).Nombre);
Assert.AreEqual(4, paisesDataServiceFake.ReadPaises().Result.Count());
}
Pruebas PUT
[TestMethod]
public void PutPais_Retorna_BadRequest_al_pasar_un_objeto_invalido_Async()
{
// Arrange
var paisController = new PaisController(paisesDataServiceFake);
var paisTest = new Pais
{
Id = 1,
Habitantes = 1000,
};
// Añadir el error para que el ModelState.IsValid lo detecte
// antes de añadir el registro a la BD.
paisController.ModelState.AddModelError("Nombre", "Required");
// Act
var response = paisController.PutPais(paisTest.Id, paisTest).Result;
// Assert
Assert.IsInstanceOfType(response, typeof(BadRequestObjectResult));
}
[TestMethod]
public void PutPais_Retorna_BadRequest_al_pasar_Id_diferente_a_Id_de_objeto_valido_Async()
{
// Arrange
var paisController = new PaisController(paisesDataServiceFake);
var paisTest = new Pais
{
Id = 1,
Nombre = "PaisTestModificado",
Habitantes = 1000,
};
// Act
var response = paisController.PutPais(0, paisTest).Result;
// Assert
Assert.IsInstanceOfType(response, typeof(BadRequestResult));
}
[TestMethod]
public void PutPais_Retorna_NotFoundResult_si_el_objeto_no_puede_ser_modificado_Async()
{
// Arrange
var paisController = new PaisController(paisesDataServiceFake);
var paisTest = new Pais
{
Id = 10,
Nombre = "PaisTestModificado",
Habitantes = 1000,
};
// Act
var response = paisController.PutPais(paisTest.Id, paisTest).Result;
// Assert
Assert.IsInstanceOfType(response, typeof(NotFoundResult));
}
[TestMethod]
public void PutPais_Retorna_NoContentResponse_si_el_objeto_es_modificado_Async()
{
// Arrange
var paisController = new PaisController(paisesDataServiceFake);
var paisTest = new Pais
{
Id = 1,
Nombre = "PaisTestModificado",
Habitantes = 1000,
};
// Act
var response = paisController.PutPais(paisTest.Id, paisTest).Result;
var pais = paisesDataServiceFake.ReadPais(1).Result;
// Assert
Assert.IsInstanceOfType(response, typeof(NoContentResult));
Assert.AreEqual("PaisTestModificado", pais.Nombre);
}
Pruebas DELETE
[TestMethod]
public void DeletePais_Retorna_NotFoundResult_si_el_objeto_no_puede_ser_eliminado_Async()
{
// Arrange
var paisController = new PaisController(paisesDataServiceFake);
// Act
var response = paisController.DeletePais(10).Result;
// Assert
Assert.IsInstanceOfType(response, typeof(NotFoundResult));
Assert.AreEqual(3, paisesDataServiceFake.ReadPaises().Result.Count());
}
[TestMethod]
public void DeletePais_Retorna_OkResult_si_el_objeto_es_eliminado_Async()
{
// Arrange
var paisController = new PaisController(paisesDataServiceFake);
// Act
var response = paisController.DeletePais(1).Result;
// Assert
Assert.IsInstanceOfType(response, typeof(OkResult));
Assert.AreEqual(2, paisesDataServiceFake.ReadPaises().Result.Count());
}
Nuevo comentario
Comentarios
excelente blog, me ayudo mucho. gracias!!!