Personalizando los settings en los test de nuestra API

  • net-core
  • net
  • api
  • dotnet
  • settings
  • tests

Uno de los primeros proyectos que creo al empezar una solución, es un proyecto de test, ya que me ayuda a probar las funcionalidades que implemento, a asegurarme de no romper nada (al menos no romper lo funcionalidades importantes) e incluso a depurar ciertas features sin tener que levantar todo mi sitio web.

Al igual que para el API, me gusta tener los settings de mis test en un archivo json separado. De esta forma, si otra persona lanza los tests en su maquina, bastará con asegurar que los valores de esos settings son correctos. Del mismo modo, puedo cambiar valores para que sean propios de los tests, por ejemplo, las cadenas de conexión de las bases de datos, ya que en los test las creo al vuelo y las elimino al terminar dichos tests. Por último, también considero interesante el poder cambiar los valores de este json de settings en las pipelines de integración continua.

Settings para los tests de integración

A continuación, voy a mostrar un test en el que hago un getAll para obtener una página de una lista de tareas. Este es un método de mi API en cuyos settings tengo un valor para alojar el API Key y una cadena de conexión a una base de datos SQL.

[TestClass]
public class TaskListControllerTests : BaseControllerTests
{
    private static readonly string url = "/api/v1.0/tasklist";
    private HttpClient _client;

    [TestInitialize]
    private async Task InitializeAsync()
    {
        WebApplicationFactory<Program> factory = await BuildWebApplicationFactoryAsync(Guid.NewGuid().ToString());
        _client = factory.CreateClient();
        AddApiKeyHeader(_client);
    }

    [TestCleanup]
    private async Task CleanupAsync()
    {
        await DeleteDatabaseAsync();
    }

    [TestMethod]
    public async Task get_all_ok()
    {
        PaginationRequest pagination = new PaginationRequest(2, 1);
        StringContent content = new StringContent(JsonConvert.SerializeObject(pagination), Encoding.UTF8, "application/json");
        HttpResponseMessage response = await _client.PostAsync($"{url}/getAll", content);

        Assert.AreEqual(System.Net.HttpStatusCode.OK, response.StatusCode);
    }
}

Como puede verse en el código anterior, en la inicialización del test, hago una llamada al método BuildWebApplicationFactoryAsync, en el que construyo la factoría de la aplicación. Ese método está en la clase base, que muestro a continuación.

public class BaseControllerTests
{
    private WebApplicationFactory<Program> _factory;

    protected async Task<WebApplicationFactory<Program>> BuildWebApplicationFactoryAsync(string dbContextName)
    {
        _factory = new WebApplicationFactory<Program>();
        _factory = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                var directory = Directory.GetCurrentDirectory();
                var configuration = new ConfigurationBuilder()
                    .SetBasePath(directory)
                    .AddJsonFile(
                        path: "testappsettings.json",
                        optional: false,
                        reloadOnChange: true)
                    .Build();
                services.AddSingleton<IConfiguration>(configuration);

                ServiceDescriptor descriptorDbContext = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<TasksDbContext>));
                if (descriptorDbContext != null)
                    services.Remove(descriptorDbContext);

                string settingsConnectionString = configuration.GetConnectionString("DataBaseConnection");
                string connectionString = string.Format(settingsConnectionString, dbContextName);
                services.AddDbContext<TasksDbContext>(options => options
                    .UseSqlServer(connectionString, options => options.UseNetTopologySuite()));
            });
        });

        await SeedDatabaseContextAsync(_factory);
        return _factory;
    }
}

En negrita, en el código anterior de la clase base antes comentada, se puede ver como se añade el archivo testappsettings.json, para cargar la configuración a partir de él.

Nótese, que en el punto en el que se añade este archivo de settings, puede haber servicios que ya se hayan configurado, por lo que sería necesario un reconfiguración, que es lo que hago justo después para añadir el contexto de la base de datos usando la cadena de conexión del archivo testappsettings.json

Obviamente, para que esto funcione es necesario añadir el archivo testappsettings.json a nuestro proyecto, en el path apropiado (en root para el ejemplo).

Este archivo tiene que tener un build action igual a EmbeddedResource y tiene que copiarse en el directorio de salida.

Este json, tiene que tener los mismo campos que el de settings del API, pero podemos personalizar los valores para que se adapten a nuestros test.

{
  "XApiKey": "testApiKey",
  "ConnectionStrings": {
    "DataBaseConnection": "Server=localhost,1433\\mssqllocaldb;Database={0};User Id=sa;Password=Sql_S3rv3r;Encrypt=false"
  }
}

En mi ejemplo, solo tengo esos settings y los he cambiado para tener un API Key de test y una base de datos que se generará en local con nombres distintos para cada test (lo hago así para crear y borrar bases de datos por cada test)

Utilizando settings en test unitarios

En el punto anterior, he cargado un archivo de settings completo, para lanzar mis tests de integración pero, ¿qué pasa si quiero usar esos setting en un test unitario?

Para explicarlo, como ya sabréis que me gusta hacer, voy a mostrar un ejemplo. En mi proyecto, tengo tests unitarios para probar mis repositorios (entre otras cosas), por lo que al igual que en el punto anterior, me gusta crear una base de datos al vuelo, llenarla con los datos que me interesen y eliminarla después de ejecutar el test. Para ello, es interesante poder coger la cadena de conexión de la base de datos de un archivo de settings. De esta manera, al igual que para los tests de integración, cualquiera puede cambiarla para hacer pruebas en su equipo o para lanzar una pipeline.

[TestClass]
public class TaskListRepositoryTests : BaseRepositoryTests
{
    private TasksDbContext _context;
    private TaskListRepository _repository;

    [TestInitialize]
    private async Task InitializeAsync()
    {
        _context = await GetLocalTasksDbContextAsync(Guid.NewGuid().ToString());
        await _context.TaskLists.AddRangeAsync(new List<TaskListEntity>
        {
            new() { Name = "List 1" },
            new() { Name = "List 3" },
            new() { Name = "List 5" },
            new() { Name = "List 7" },
            new() { Name = "List 6" },
            new() { Name = "List 4" },
            new()
            {
                Name = "List 2", Tasks = new List<TaskEntity>()
                {
                    new() { Description = "Task 1", Notes = "This is the task 1"},
                    new() { Description = "Task 2", Notes = "This is the task 2"},
                }
            }
        });
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        _repository = new TaskListRepository(_context);
    }

    [TestCleanup]
    private async Task CleanupAsync()
    {
        await DeleteDatabaseAsync(_context);
    }

    [TestMethod]
    [DataRow(2, 1, new[] { 1, 7 })]
    [DataRow(2, 2, new[] { 2, 6 })]
    [DataRow(2, 4, new[] { 4 })]
    public async Task get_all_task_list(int pageSize, int pageNumber, int[] hopeIds)
    {
        var result = await _repository.GetAllAsync(pageSize, pageNumber);
        var resultList = result.ToList();

        Assert.AreEqual(hopeIds.Length, resultList.Count);
        for (int i = 0; i < resultList.Count; i++)
            Assert.AreEqual(hopeIds[i], resultList[i].Id);
    }
}

public class BaseRepositoryTests
{
    protected async Task<TasksDbContext> GetLocalTasksDbContextAsync(string dbContextName)
    {
        string testAppSettingJson = await File.ReadAllTextAsync(@"./testappsettings.json");
        var testAppSetting = JsonConvert.DeserializeObject<TestAppSettings>(testAppSettingJson);

        string connectionString = string.Format(testAppSetting.ConnectionStrings.DataBaseConnection, dbContextName);
        DbContextOptions<TasksDbContext> options = new DbContextOptionsBuilder<TasksDbContext>()
            .UseSqlServer(connectionString, options => options.UseNetTopologySuite())
            .Options;

        var dbContext = CreateDatabaseContext(options);
        await dbContext.Database.EnsureCreatedAsync();
        return dbContext;
    }
}

A diferencia de los test de integración, aquí no cargo los valores de configuración a partir del json, ya que no serviría de nada, puesto que no levanto el API. Por ello, cargo el json en una clase y accedo a esta para obtener el valor deseado (la cadena de conexión en esta caso).

Para conseguirlo, debemos crear la clase sobre la que se des-serialice el json.

public class TestAppSettings
{
    public string XApiKey { get; set; }
    public ConnectionStrings ConnectionStrings { get; set; }
}

public class ConnectionStrings
{
    public string DataBaseConnection { get; set; }
}

Con todo lo anterior, ya podemos usar settings personalizados en nuestros test, con todas las ventajas que esto nos ofrece.

Foto de Nguyen Dang Hoang Nhu en Unsplash