如何在.net Aspire 中执行功能单元测试?

问题描述 投票:0回答:1

如何对依赖 .net Aspire 管理的资源的项目执行功能单元测试,即。 e.如何在需要 Postgres DB 的项目中测试服务?在“正常操作”期间,Aspire 使用数据库启动容器,并将连接字符串注入到依赖项目中。但对于单元测试,使用

DistributedApplicationTestingBuilder
,这不会产生测试项目中定义的服务的句柄。

可以使用

docker-compose
或类似工具设置所有依赖项,但这与使用 .net Aspire 的便利性背道而驰。

下面我回答我的问题,因为这不是一个需要解决的微不足道的任务,也许在它(希望)集成到 Aspire 代码库中之前对其他人有帮助。

xunit dotnet-aspire
1个回答
0
投票

您可以在此处找到一个工作示例。

要进行功能单元测试,请从此自定义构建测试

WebApplicationFactory

/// <summary>
/// Custom WebApplicationFactory for the UnitTestsInAspire.Web project. It spins up the AppHost and injects the environmental 
/// variables from the project (webfrontend). To adapt it for other projects, you need to change the name of the
/// AppHost project, of the required dependencies to spin up and the project's designation defined in the AppHost; see (§§).
/// </summary>
public class CustomWebApplicationFactory<TEntryPoint> : WebApplicationFactory<TEntryPoint> where TEntryPoint : class
{
    // holds the AppHost instance
    // I guess it should be a class variable to keep AppHost running as long as the factory is alive, i.e. the tests are running
    private DistributedApplication? _app;

    // used by the Dispose pattern
    private bool disposedValue;

    /// <summary>
    /// Configure the WebHostBuilder for the test server. Spins up AppHost and waits for required resources to start.
    /// Obtains the environmental variables from the project (webfrontend) and injects them into the test server.
    /// </summary>
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        // local variables
        var environmentVariables = new Dictionary<string, string?>();

        // === Arrange

        // build and configure AppHost (§§)
        var appHost = DistributedApplicationTestingBuilder.CreateAsync<Projects.UnitTestsInAspire_AppHost>().GetAwaiter().GetResult();
        appHost.Services.ConfigureHttpClientDefaults(clientBuilder =>
        {
            clientBuilder.AddStandardResilienceHandler();
        });

        // disable port randomization for tests
        // https://github.com/dotnet/aspire/discussions/6843#discussioncomment-11444891
        //appHost.Configuration["DcpPublisher:RandomizePorts"] = "false";

        // build the AppHost synchronously
        _app = appHost.BuildAsync().GetAwaiter().GetResult();


        // start AppHost
        // Note: As far as I understand, the containers are not run in DockerDesktop but in some in-memory thingy
        var resourceNotificationService = _app.Services.GetRequiredService<ResourceNotificationService>();
        _app.StartAsync().GetAwaiter().GetResult();

        // wait until the required resources are running (§§)
        var stopwatch = System.Diagnostics.Stopwatch.StartNew();
        resourceNotificationService.WaitForResourceAsync("webfrontend", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(20));
        resourceNotificationService.WaitForResourceAsync("PostgresServer", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(20));
        stopwatch.Stop();
        Console.WriteLine($"Waited {stopwatch.ElapsedMilliseconds} ms for required resources to start");



        // = get env variables from project-under-test

        // find UnitTestsInAspire.Web project in AppHost (§§) (adapted from https://github.com/dotnet/aspire/discussions/878#discussioncomment-9596424)
        var testProject = appHost.Resources.First(r => r.Name == "webfrontend");

        // make sure it is a ProjectResource with an environment and
        // get the annotations marking the methods/fields that contain the environmental variables
        if (testProject is IResourceWithEnvironment &&
            testProject.TryGetEnvironmentVariables(out var annotations))
        {
            // To invoke the callback functions to obtain the environmental variables, an EnvironmentCallbackContext
            // is required, which can be created from a DistributedApplicationExecutionContext, but this requires
            // certain services, that can be obtained from the AppHost's service registry. Thus, hijack the AppHosts services
            // to create the DistributedApplicationExecutionContext.
            var options = new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run)
            {
                ServiceProvider = _app.Services
            };

            var executionContext = new DistributedApplicationExecutionContext(options);
            var environmentCallbackContext = new EnvironmentCallbackContext(executionContext);


            // materialize the environmental variables by calling the callbacks in the environmentCallbackContext
            foreach (var annotation in annotations)
            {
                annotation.Callback(environmentCallbackContext).GetAwaiter().GetResult();
            }

            // Translate environment variable __ syntax to :
            foreach (var (key, value) in environmentCallbackContext.EnvironmentVariables)
            {
                if (testProject is ProjectResource && key == "ASPNETCORE_URLS") continue;

                var configValue = value switch
                {
                    string val => val,
                    IValueProvider v => v.GetValueAsync().AsTask().Result,
                    null => null,
                    _ => throw new InvalidOperationException($"Unsupported value, {value.GetType()}")
                };

                if (configValue is not null)
                {
                    environmentVariables[key.Replace("__", ":")] = configValue;
                }
            }
        }

        // inject the environmental variables into the test server
        builder.ConfigureAppConfiguration((_, cb) =>
        {
            cb.AddInMemoryCollection(environmentVariables);
        });
    }


    /// <summary>
    /// This method is part of the recommended dispose pattern. I assume that WebApplicationFactory already implements
    /// <code>public void Dispose()</code>, which calls <code>Dispose(disposing: true)</code>.
    /// </summary>
    protected override void Dispose(bool disposing)
    {
        if (!disposedValue)
        {
            if (disposing)
            {
                // dispose the AppHost
                _app?.Dispose();
            }

            // free unmanaged resources and override finalizer
            // set large fields to null

            // Call the base class's Dispose method to ensure base class resources are disposed of
            base.Dispose(disposing);

            disposedValue = true;
        }
    }
}

它启动 AppHost,等待所有依赖项准备就绪,然后将

DistributedApplicationTestingBuilder
内的待测项目的环境变量复制到使用
WebApplicationFactory
构建的待测项目的第二个实例用于实际测试。这样,Aspire 管理的所有连接字符串和其他设置都可用于测试项目。

测试类本身如下所示:

/// <summary>
/// Perform functional unit tests on the HistoryService defined in UnitTestsInAspire.Web.
/// The custom WebApplicationFactory spins up the AppHost and injects the environmental variables from the project (webfrontend).
/// </summary>
/// <remarks>
/// In order for the compiler to find the project to test, you need to add <code>public partial class Program { }</code> to
/// <code>Program.cs</code> in the project you want to test.
/// </remarks>
public sealed class FunctionalTests(CustomWebApplicationFactory<Program> _factory,
                                    ITestOutputHelper _output) : IClassFixture<CustomWebApplicationFactory<Program>>, IAsyncLifetime
{
    // grants access to the Services defined in UnitTestsInAspire.Web
    // InitializeAsync throws an exception if the scope could not be created, so it is never null when used
    private IServiceScope _scope = null!;

    /// <summary>
    /// Initializes the test environment. 
    /// Creates the scope for the UnitTestsInAspire.Web services.
    /// </summary>
    public async Task InitializeAsync()
    {
        // === Spinning up AppHost and copying the environmantal variables to UnitTestsInAspire.Web is 
        // done in the custon WebApplicationFactory


        // get ServiceProvider from UnitTestsInAspire.Web (for obtain dependencies from DI)
        // I store the scope as class variable, so that each test can derive it's own service. I think that if all tests were
        // to use the same service and run in parallel, they might interfere with each other (e.g. concurrent DBcontext access)
        _scope = _factory.Services.CreateScope();

        if (_scope == null)
        {
            throw new InvalidOperationException("ServiceScope could not be created for UnitTestsInAspire.Web.");
        }


        // development: Verify the configuration within the application
        // Note: The connection string is found in this way, even if the ConnectionStrings:applicationDb is not present in the appsettings.json
        // Surprisingly, the HistoryService does not pick up the connection string, unless at least a dummy string is present in the appsettings.json
        var projectConfig = _scope.ServiceProvider.GetRequiredService<IConfiguration>();
        var dbConnectionString = projectConfig.GetConnectionString("applicationDb");
        if (string.IsNullOrEmpty(dbConnectionString))
        {
            throw new InvalidOperationException("ConnectionString for applicationDb is missing.");
        }

        // avoid CS1998 Async method lacks 'await' operators and will run synchronously
        await Task.CompletedTask;
    }

    [Fact]
    public async Task AddAndRetreiveDataPoint()
    {
        // === Arrange
        // is done in InitializeAsync


        // === Act

        // get Services via dependency injection
        var serviceProvider = _scope.ServiceProvider;
        var HistoryService = serviceProvider.GetRequiredService<HistoryService>();


        // perform some tests and assert ...
    }

    /// <summary>
    /// Disposes the scope for the UniTestsInAspire.Web services.
    /// </summary>
    /// <returns></returns>
    public async Task DisposeAsync()
    {
        _scope?.Dispose();

        // avoid CS1998 Async method lacks 'await' operators and will run synchronously
        await Task.CompletedTask;
    }
}

这种方法有一些警告:

  1. 为了能够引用

    Program.cs
    项目的
    UnitTestsInAspire.Web
    ,必须将
    public partial class Program { }
    添加到
    Program.cs

  2. Postgres 数据库的连接字符串可以通过

    GetConnectionString
    获取(参见
    CustomWebApplicationFactory.ConfigureWebHost
    ),但奇怪的是,连接字符串不会被
    HistoryService
    拾取,除非在
    appsettings.json
    中添加了一个虚拟字符串。
    UnitTestsInAspire.Web
    项目?!?

  3. 要添加迁移并更新数据库,必须使用此处此处描述的解决方法:

    • 切换到UnitTestsInAspire.Web目录,然后执行
      dotnet ef migrations add Init --output-dir ./Data/Migrations
    • 运行应用程序并通过 Aspire Dashboard 从 AppHost 中的环境变量中获取连接字符串(查看详细信息页面)
    • 当应用程序仍在运行时,使用
      dotnet ef database update --no-build --connection "..."
    • 更新数据库

此解决方案改编自此处。它使用了一种繁琐的方式来具体化被测项目的环境变量,也许有更直接的方式来访问环境变量。

© www.soinside.com 2019 - 2024. All rights reserved.