Este post lo creo principalmente porque me llegan preguntas casi cada semana de cómo podemos hacer Test de nuestras APIs, y si es posible hacerlo sin docker, osea en memoria.
La respuesta es sí y ahora vamos a ver como hacerlo.
Índice
Para este post comentar que el grueso del código, está escrito en NetCore 3.1, es importante mencionarlo ya que cambia en .NET6; aún así pondré el snippet de código para que podáis ver la diferencia (es mínima).
1 - Cómo crear Test de una API en .NET?
Para testear nuestras APIs lo que vamos a hacer es utilizar el Tipo TestServer
que nos proporciona Microsoft.
Por supuesto tenemos que tenemos que tener una API disponible en nuestra aplicación, podemos elegir cualquier endpoint, para el primer ejemplo, utilizaré un endpoint que no tiene dependencias:
[Route("api/[controller]")]
[ApiController]
[Produces("application/json")]
public class AcademicProjectsController : ControllerBase
{
[HttpGet("{userId}")]
public Task<AcademicProjectsDto> Get(int userId)
{
if (userId != 1)
throw new NotImplementedException();
//TODO: Demo - this is to simulate a real scenario
var academicProjets = new AcademicProjectsDto()
{
Projects = new List<AcademicProjectDto>()
{
new AcademicProjectDto()
{
Id=1,
Details = "Aplicación para suibr imagenes a internet, con la posiblidad de retocarlas con filtros y redimensionar",
Environment = new List<string>(){"PHP","JavaScript", "Bootstrap"},
Name = "IMGLovely"
}
}
};
return Task.FromResult(academicProjets);
}
}
Ahora debemos crear un proyecto (podemos utilizar uno ya existente) para realizar nuestros test. A mi personalmente me gusta separar los test. hay quien llama a este tipo de test de integración, pero yo ya explique lo que son para mi los test de integración. Así que en mi opinión los podemos llamar test de API o de Componentes.
Y en el proyecto debemos añadir dos referencias, una al proyecto que queremos testear (tu api) y otra a la librería de NuGet Microsoft.AspNetCore.TestHost
.
Y creamos un test donde instanciamos WebHostBuilder
que nos va a permitir construir un web host y posteriormente el tipo TestServer
que construirá dicho web host en memoria:
[Fact]
public async Task WhenCallAPI_withID1_thenResult()
{
var webHostBuilder =
new WebHostBuilder()
.UseStartup<Startup>();
using (var server = new TestServer(webHostBuilder))
{
//Código
}
}
Ahora únicamente debemos llamar al código, para ello TestsServer
nos proporciona un método llamado .CreateClient
el cual nos da un cliente HTTP que interactúa con el servidor en memoria que nos acaba de montar.
Esta funcionalidad es brutal, porque a dicho Cliente HTTP le podemos incluir headers, o diferente información que podamos necesitar, como la autenticación.
En nuestro caso en particular no necesitamos añadir información adicional, con lo que realizamos una llamada al endpoint:
[Fact]
public async Task WhenCallAPI_withID1_thenResult()
{
IWebHostBuilder webHostBuilder =
new WebHostBuilder()
.UseStartup<Startup>();
using (TestServer server = new TestServer(webHostBuilder))
using (HttpClient client = server.CreateClient())
{
AcademicProjectsDto result = await client.GetFromJsonAsync<AcademicProjectsDto>("/api/AcademicProjects/1",
new JsonSerializerOptions(){PropertyNameCaseInsensitive = true});
Assert.Equal(1, result.Projects.First().Id);
}
}
Y con esto ya tenemos nuestro test en la API.
2 - Cómo funciona TestServer
Quiero hacer un pequeño punto para explicar que cuando utilizamos TestServer
estamos pasando toda la configuración a través de WebhostBuilder
el cual nos obliga a utilizar el extension method .UseStartup<T>
donde T
es nuestra clase Startup
en la API a testear
. Esto es debido a que va a utilizar reflection para leer el método ConfigureServices
y cargar toda la información del mismo
2.1 - Añadir configuración a TestServer
Es muy común que en ConfigureServices
accedamos a la información que tenemos dentro de la configuración de la app (el appsettings.json
);
Pero a la vez estamos ejecutando tests, así que es muy probable que queramos cambiar parte de dicha configuración, para ello la librería nos proporciona un método de extensión llamado .ConfigureAppConfiguration()
el cual recibe un delegado para indicar dicha configuración.
Una de las opciones es añadir un fichero que tengamos dentro del proyecto de tests:
IWebHostBuilder webHostBuilder =
new WebHostBuilder()
.ConfigureAppConfiguration(x => x.AddJsonFile("appsettings.tests.json", optional: true))
.UseStartup<Startup>();
Pero también podemos crear dicho IConfiguration
en memoria dentro del test y pasarlo dentro de .ConfigureAppConfiguration
.
2.2 - Cambiar el entorno en TestServer
Existe la posibilidad que dependiendo del entorno en el que estemos nuestro código deba actuar de forma diferente. Para ello tenemos el método .UseEnvironment()
el cual nos permite especificar un entorno que simula el la variable de entorno ASPNETCORE_ENVIRONMENT
.
IWebHostBuilder webHostBuilder =
new WebHostBuilder()
.UseEnvironment("production")
.UseStartup<Startup>();
En nuestro caso es como si estuviéramos ejecutando el código en producción.
3 - Trabajar con dependencias en TestServer
Un punto muy importante es, qué pasa con las dependencias? estamos haciendo tests y es posible que algunas de nuestras API hagan inserciones en la base de datos, manden mensajes a un service bus o llamen a otras APIs.
vimos en vídeos anteriores como utilizar mock para tratar con nuestras dependencias. Y ahora debemos hacer algo similar.
En algunos casos vamos a hacer Mock del elemento que no queramos ejecutar en el código (por ejemplo una base de datos) pero también es muy común realizar un stub
o un fake
.
La diferencia entre ambos es que stub proporciona siempre las mismas respuestas y un fake almacena información, pero tiene funcionalidades limitadas y solo para el test.
Por ejemplo en una base de datos, si haces un fake, almacenarás los elementos en una lista, y luego en tu test puedes comparar dichos elementos.
Mientras que un stub, si insertas o consultas siempre devuelves la misma información.
Para poder configurar esta información WebHostBuilder
nos provee del método .ConfigureTestServices()
- Nota: también tenemos
.ConfigureServices()
; simplemente tener en cuenta que.ConfigureTestServices
se ejecuta después. Es importante porque si estás insertando/modificando información y pones tu mock/fake/stub en configureservices corres el riesgo de afectar a la base de datos real.
Para este ejemplo vamos a utilizar el endpoint con un post:
namespace WebPersonal.BackEnd.API.Controllers
{
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class PerfilPersonalController : ControllerBase
{
[HttpPost("returnonlyid")]
public async Task<Result<int?>> PostId(PersonalProfileDto profileDto)
{
return await _postPersonalProfile.Create(profileDto)
.MapAsync(x=>x.UserId);
}
}
}
El cual crea un perfil dentro del sistema y vamos a crear un stub de las dependencias:
public class StubIPostPersonalProfileDependencies : IPostPersonalProfileDependencies
{
public Task<UserIdEntity> InsertUserId(string name)
=> Task.FromResult(UserIdEntity.Create(name, 1));
public Task<Result<PersonalProfileEntity>> InsertPersonalProfile(PersonalProfileEntity personalProfile)
=> PersonalProfileEntity.Create(personalProfile.UserId, personalProfile.Id, personalProfile.FirstName,
personalProfile.LastName, personalProfile.Description, personalProfile.Phone,
personalProfile.Email, personalProfile.Website, personalProfile.GitHub).Success().Async();
public Task<Result<List<SkillEntity>>> InsertSkills(List<SkillEntity> skills)
=> skills.Select(a => SkillEntity.Create(a.UserId, a.Id, a.Name, a.Punctuation)).ToList().Success().Async();
public Task<Result<List<InterestEntity>>> InsertInterests(List<InterestEntity> interests)
=> interests.Select(a => InterestEntity.Create(a.Id, a.UserId, a.Description)).ToList().Success().Async();
public Task<Result<bool>> SendEmail(string to, string subject, string body)
=> true.Success().Async();
public Task CommitTransaction()
=> Task.CompletedTask;
}
La parte importante es que cuando utilizamos .ConfigureTestServices
lo que estamos haciendo es reemplazar las dependencias que tiene nuestra api por las que especificamos aquí, asi que debemos incluir el siguiente código:
IWebHostBuilder webHostBuilder =
new WebHostBuilder()
.ConfigureTestServices(serviceCollection =>
{
serviceCollection
.AddScoped<IPostPersonalProfileDependencies, StubIPostPersonalProfileDependencies>();
})
.UseStartup<Startup>();
Finalmente realizamos la llamada a la API con el post y el contenido serializado.
[Fact]
public async Task WhenInsertInformation_returnCorrect()
{
IWebHostBuilder webHostBuilder =
new WebHostBuilder()
.ConfigureTestServices(serviceCollection =>
{
serviceCollection
.AddScoped<IPostPersonalProfileDependencies, StubIPostPersonalProfileDependencies>();
})
.UseStartup<Startup>();
PersonalProfileDto defaultPersonalProfileDto = GetPersonalProfile();
string serializedProfile = JsonSerializer.Serialize(defaultPersonalProfileDto);
using (TestServer server = new TestServer(webHostBuilder))
using (HttpClient client = server.CreateClient())
{
var result = await client.PostAsync("/api/PerfilPersonal/returnonlyid",
new StringContent(serializedProfile, Encoding.UTF8, "application/json"));
result.EnsureSuccessStatusCode();
}
}
A la hora de validar es muy común hacer únicamente result.EnsureSuccessStatusCode()
para asegurarnos que la llamada ha funcionado como esperábamos, pero si lo deseas puedes deserializar el contenido, como hemos visto en el ejemplo anterior.
4 - TestServer con .NET6 o minimal APIs
Antes de terminar he de hacer una mención especial al caso de .NET 6; Esto es debido a que microsoft ha cambiado completamente cómo funcionan los proyectos web con la llegada de las minimal api; Ahora ya no disponemos de la clase startup
con el metodo ConfigureServices
, que era el utilizado por .UseStartup<T>
para definir que ws lo que se tiene que configurar.
De hecho la clase program.cs
no es visible desde fuera del propio proyecto, sino que es el compilador el que la crea, entonces, cómo hacemos dichos tests ?
Lo primero que tenemos que hacer es hacer la clase program visible, y para ello tenemos dos opciones:
La primera es modificar el csproj
de nuestra api en net 6 para incluir ue los elementos con modificadores de acceso internal sean visibles a nuestro proyecto de tests:
<ItemGroup>
<InternalsVisibleTo Include="WebPersonal.Backend.ApiTest" />
</ItemGroup>
Y la segunda opción es crear en el mismo fichero program.cs
una partial class de program.
esto es debido a que la que crea el compilador es interna, y la que creamos nosotros pública por lo que podremos acceder desde los test sin modificar el csproj
.
public partial class Program{}
La clase no debe contener nada, simplemente existir.
Y ahora en la creación del test también tenemos dos opciones la primera es crear una clase que herede de WebApplicationFactory<Program>
el cual nos creará un método llamado CreateHost
donde podemos definir la configuración:
class WebPersonalApi : WebApplicationFactory<Program>
{
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureTestServices(serviceCollection =>
{
serviceCollection
.AddScoped<IPostPersonalProfileDependencies, StubIPostPersonalProfileDependencies>();
});
return base.CreateHost(builder);
}
}
Y para llamarla desde los test también es muy sencillo:
WebPersonalApi application = new ();
HttpClient client = application.CreateClient();
AcademicProjectsDto result = await client.GetFromJsonAsync<AcademicProjectsDto>("/api/AcademicProjects/1",
new JsonSerializerOptions() { PropertyNameCaseInsensitive = true });
La otra opción es invocar dentro del test la clase WebApplicationFactory<Program>
directamente:
var application = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(serviceCollection =>
{
serviceCollection
.AddScoped<IPostPersonalProfileDependencies, StubIPostPersonalProfileDependencies>();
});
});
HttpClient client = application.CreateClient();
AcademicProjectsDto result = await client.GetFromJsonAsync<AcademicProjectsDto>("/api/AcademicProjects/1",
new JsonSerializerOptions() { PropertyNameCaseInsensitive = true });
Conclusión
En este post hemos visto cómo crear test de integración en memoria con TestServer
Hemos visto cómo configurar TestServer
Cómo añadir dependencias en TestServer
Y cómo ejecutar TestServer con .NET 6