← Volver al blog

Estructura de proyecto y pruebas en .NET Core 6 vs .NET Core 5: pruebas unitarias, GraphQL y fixtures de test server

Publicado el
6 min de lectura
--- vistas

Resumen breve

Imaginemos que tienes un proyecto de aplicación moderno hecho en la época del lanzamiento de .NET core 6. No hace falta decir que su estructura es un poco diferente de las versiones anteriores de .net (donde tenías 2 archivos separados - Startup.cs y Program.cs)

Repasemos las diferencias entre la estructura antigua y la nueva, que serán útiles para explicaciones posteriores

Estructura de proyecto en la época de .NET Core 5

Startup.cs

Aquí solíamos registrar todos los servicios, configuraciones y clases.

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }
    //...
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {
        if(env.IsDevelopment()) {
            app.UseDeveloperExceptionPage();
            app.UseSwagger();
            // ...
        }
        // ...
    }
}

Program.cs

De hecho, este es el punto de partida de la aplicación. Aquí, por ejemplo, añadí unas pocas líneas de código para crear un scope para el Entity Context y ejecutar las migraciones iniciales de la base de datos.

public class Program
{
    public static void Main(string[] args)
    {
        var host = CreateHostBuilder(args).Build();

        using (var scope = host.Services.CreateScope())
        {
            var services = scope.ServiceProvider;
            try
            {
                var context = services.GetRequiredService<DataContext>();
                // context.Database.Migrate();
                DataSeed.SeedDataAsync(context, services).Wait();
            }
            catch (Exception ex)
            {
                var logger = services.GetRequiredService<ILogger<Program>>();
                logger.LogError(ex, "An error occurred during migration");
            }
        }

        host.Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });
}

Estructura de proyecto en .NET Core 6

Los dos archivos antiguos Program.cs y Startup.cs ahora están fusionados en un único archivo Program.cs, y ya no hay ninguna clase dentro. Solo funciones puras para añadir nuevas modificaciones.

Código típico de Program.cs en un proyecto .NET Core 6+

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.AddControllers();

// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();
//...
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseAuthorization();

Estructura .NET Core 5 vs 6 - ventajas y desventajas (+ resolución de problemas)

Ventajas

  • 1 archivo simple en lugar de 2
  • menos envoltorios de clases - código más simple y limpio
  • se presentó el nuevo webApplicationBuilder

Desventajas

  • No tenemos ninguna clase en el programa para crear relaciones. Por ejemplo, para añadir librerías como Mediator o FluentValidation necesitas obtener el tipo del assembly por nombre, ejemplo:
builder.Services.AddMediatR(typeof(Program).Assembly);

Resolver el nombre del assembly raíz en .NET Core 6

Una de las posibles soluciones es añadir esta línea al final del archivo Program.csz:

// Make the implicit Program class public so test projects can access it
public partial class Program { }

Ahora puedes usar estructuras como typeof(Program) o WebApplicationFactory<Program> (al hacer pruebas)

Configurar el Test server de .NET Core 5

Aquí tenemos un paquete nu-get llamado Microsoft.TestPlatform.TestHost junto con Microsoft.AspNetCore.TestHost.

Aquí está el TestServerFixture de uno de mis proyectos, que se relaciona con el Program de la aplicación real:

public class TestServerFixture : IDisposable
{
    private readonly TestServer _testServer;
    public HttpClient Client { get; }

    public TestServerFixture()
    {
        var builder = new WebHostBuilder()
            // .UseContentRoot(GetContentRootPath())
            .UseEnvironment("Development")
            .UseConfiguration(FakeConfiguration.GetInstance())
            .UseStartup<Startup>();  // Uses Start up class from your API Host project to configure the test server

        _testServer = new TestServer(builder);
        Client = _testServer.CreateClient();
    }
    // ...
}

Caso de uso típico:

[Fact]
public async void ExceptionIfPasswordNotValid()
{
    using var testServer = new TestServerFixture();

    // Arrange
    const string password = "123";

    var command = new RegisterCommand()
    {
        Email = Faker.Internet.Email(),
        Password = password,
        PasswordConfirmation = password,
        RuleAgreement = true
    };

    // Act
    var (response, _) = await PostAsync<ValidationException>("api/auth/Register", command);
    var responseData = await response.Content.ReadAsStringAsync();
    // ...

Configurar el Test server de .NET Core 6

Las pruebas unitarias para controladores y utilidades son un buen método, pero también prefiero tener una instancia real en ejecución de la aplicación para mis pruebas, con la capacidad de enviar peticiones POST y GET reales.

Así que mucha gente en internet empieza a construir otra configuración separada para ejecutar ese nodo virtual de pruebas, pero en realidad ya tenemos todas las configuraciones necesarias en nuestro archivo Program.cs. ¡Usémoslo! Pero necesitamos hacer algunos cambios en él, por ejemplo - usar no una base de datos física real, sino una en memoria.

Antes que nada, ahora podemos relacionarnos con la clase Program.cs y usarla en combinación con la clase WebApplicationFactory:

Mi clase de fixture de pruebas se ve así:

using Microsoft.AspNetCore.Mvc.Testing;

namespace TestHelpers
{
    public class TestServerFixture : IDisposable
    {
        protected readonly WebApplicationFactory<Program> WebApplicationFactory;
        protected HttpClient Client { get; }

        public TestServerFixture()
        {
            WebApplicationFactory = new TestingWebAppFactory();
            Client = WebApplicationFactory.CreateDefaultClient();
        }

        public void Dispose()
        {
            Client.Dispose();
            WebApplicationFactory.Dispose();
        }
    }
}

Program ha sido usado directamente desde el proyecto principal de la aplicación (ejemplo de arriba);

Además, lo cambié un poco con ese envoltorio de clase. Aquí añado una base de datos virtual + ejecuto las migraciones iniciales de seeding:

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace TestHelpers;

public class TestingWebAppFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        base.ConfigureWebHost(builder);
        builder.ConfigureServices(services =>
        {
            var descriptor =
                services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<DatabaseContext>));

            if (descriptor != null)
            {
                services.Remove(descriptor);
            }

            services.AddEntityFrameworkInMemoryDatabase();
            services.AddDbContext<DatabaseContext>(o =>
            {
                o.UseInMemoryDatabase("InMemoryAynnTest");
            });

            var sp = services.BuildServiceProvider();

            using var scope = sp.CreateScope();
            using var appContext = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
            appContext.Database.EnsureCreated();
        });
    }
}

Ahora puedo usar fácilmente este TestingFixture en mis pruebas xuint. Las peticiones get y post reales van aquí:

using TestHelpers;
using Xunit;

namespace Tests;

public class TestTestController: TestServerFixture
{
    [Fact]
    public async Task Ping_OnSuccess_ReturnsTrue()
    {
        var response = await Client.GetAsync("/api/Test/Ping");
        var stringResult = await response.Content.ReadAsStringAsync();
        Assert.Equal("Pong", stringResult);
    }
}

Probar una petición GraphQL HotChocolate a través de pruebas unitarias

Tomemos el caso más exigente - ejecutar una mutación GraphQL a través de una aplicación muy cercana a la real.

Usaré la misma clase TestServerFixture aquí:

[Fact]
public async Task TestRegister_OnSuccess_ReturnsUser()
{
    // arrange
    var query = @"
        mutation register {
            register(
                payload: {
                    email: ""test@t10.com""
                    password: ""1A?a456""
                    passwordConfirmation: ""1A?a456""
                    ruleAgreement: true
            }
            ) {
                id
            }
        }
    ";

    // act
    var request = QueryRequestBuilder.New()
        .SetQuery(query)
        .Create();

    var result = await WebApplicationFactory.Services.ExecuteRequestAsync(request);
    var json = await result.ToJsonAsync();

    // assert
    Assert.Null(result.Errors);
    Assert.Contains("data", json);
    Assert.Contains("id", json);

    Assert.Matches(
        @"(\{){0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\}){0,1}",
        json);
}

Este es un ejemplo completamente funcional, que trabaja con nuestro fixture de test server y, de esa manera, con la base de datos virtual de EntityFramework inyectada.

Disponible para colaboración por contrato

Estoy disponible para colaborar por contrato. Si tiene una idea de proyecto interesante, reserve una llamada por Calendly.

Agenda una llamada de 30 min