如何对依赖 .net Aspire 管理的资源的项目执行功能单元测试,即。 e.如何在需要 Postgres DB 的项目中测试服务?在“正常操作”期间,Aspire 使用数据库启动容器,并将连接字符串注入到依赖项目中。但对于单元测试,使用
DistributedApplicationTestingBuilder
,这不会产生测试项目中定义的服务的句柄。
可以使用
docker-compose
或类似工具设置所有依赖项,但这与使用 .net Aspire 的便利性背道而驰。
下面我回答我的问题,因为这不是一个需要解决的微不足道的任务,也许在它(希望)集成到 Aspire 代码库中之前对其他人有帮助。
您可以在此处找到一个工作示例。
要进行功能单元测试,请从此自定义构建测试
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;
}
}
这种方法有一些警告:
为了能够引用
Program.cs
项目的 UnitTestsInAspire.Web
,必须将 public partial class Program { }
添加到 Program.cs
。
Postgres 数据库的连接字符串可以通过
GetConnectionString
获取(参见 CustomWebApplicationFactory.ConfigureWebHost
),但奇怪的是,连接字符串不会被 HistoryService
拾取,除非在 appsettings.json
中添加了一个虚拟字符串。 UnitTestsInAspire.Web
项目?!?
dotnet ef migrations add Init --output-dir ./Data/Migrations
dotnet ef database update --no-build --connection "..."
此解决方案改编自此处。它使用了一种繁琐的方式来具体化被测项目的环境变量,也许有更直接的方式来访问环境变量。