← Назад в блог

Структура проекта и тестирование в .NET Core 6 vs .NET Core 5: юнит-тесты, GraphQL и test server fixtures

Опубликовано
5 мин чтения
--- просмотров

Короткая история

Представь, что у тебя есть проект современного приложения, сделанный во времена релиза .NET Core 6. Само собой, его структура немного отличается от старых версий .NET (где было 2 отдельных файла — Startup.cs и Program.cs)

Давай разберём различия между старой и новой структурой — это пригодится для дальнейших объяснений

Структура проекта времён .NET Core 5

Startup.cs

Здесь мы обычно регистрировали все сервисы, конфиги и классы.

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

По сути, это стартовая точка приложения. Здесь, например, я добавил несколько строк кода для создания scope для Entity Context и запуска первоначальных миграций базы данных.

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>(); });
}

Структура проекта .NET Core 6

Оба старых файла — Program.cs и Startup.cs — теперь объединены в один файл Program.cs, и внутри больше нет никаких классов. Только чистые функции для добавления новых модификаций.

Типичный код Program.cs для проекта .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();

Структура .NET Core 5 vs 6 — плюсы и минусы (+ решение проблем)

Плюсы

  • 1 простой файл вместо 2
  • меньше обёрток классов — код проще и чище
  • появился новый webApplicationBuilder

Минусы

  • У нас больше нет классов в program для построения связей. Например, для подключения таких библиотек, как Mediator или FluentValidation, нужно получать тип сборки по имени, пример:
builder.Services.AddMediatR(typeof(Program).Assembly);

Решение проблемы с именем корневой сборки в .NET Core 6

Один из возможных вариантов решения — добавить эту строку в конец файла Program.csz:

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

Теперь можно использовать конструкции вроде typeof(Program) или WebApplicationFactory<Program> (при написании тестов)

Настройка тестового сервера в .NET Core 5

Здесь у нас есть nuget-пакет Microsoft.TestPlatform.TestHost вместе с Microsoft.AspNetCore.TestHost.

Вот TestServerFixture из одного из моих проектов, который связан с Program из реального приложения:

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();
    }
    // ...
}

Типичный сценарий использования:

[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();
    // ...

Настройка тестового сервера в .NET Core 6

Юнит-тесты для контроллеров и утилит — хороший метод, но я также предпочитаю иметь реально запущенный экземпляр приложения для своих тестов, с возможностью отправлять настоящие POST- и GET-запросы.

Поэтому многие в интернете начинают строить отдельную конфигурацию для запуска такого тестового виртуального узла, но на самом деле у нас уже есть все необходимые настройки в файле Program.cs. Давай его и используем! Но нужно внести в него некоторые изменения, например — использовать не настоящую физическую базу данных, а базу в памяти.

Прежде всего, теперь мы можем ссылаться на класс Program.cs и использовать его в сочетании с классом WebApplicationFactory:

Мой класс test fixture выглядит вот так:

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 используется прямо из проекта основного приложения (пример выше);

Также я немного изменил его с этой обёрткой класса. Здесь я добавляю виртуальную базу данных + запускаю первоначальные seed-миграции:

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();
        });
    }
}

Теперь я могу легко использовать этот TestingFixture в своих xuint-тестах. Реальные get- и post-запросы идут сюда:

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);
    }
}

Тестирование GraphQL HotChocolate-запроса через юнит-тесты

Возьмём самый сложный случай — выполнение GraphQL-мутации через приложение, максимально близкое к реальному.

Здесь я буду использовать тот же класс TestServerFixture:

[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);
}

Это полностью рабочий пример, который работает с нашим test server fixture и, таким образом, с внедрённой виртуальной базой данных EntityFramework.

Открыт для работы по контракту

Я доступен для работы по контракту. Если у вас есть интересная идея проекта — запишитесь на звонок через Calendly.

Записаться на 30-минутный звонок