Replies: 8 comments 60 replies
-
Yes, I'm supposed to write a sample, but there's currently one here: https://github.com/dotnet/eShop/blob/main/tests/Catalog.FunctionalTests/CatalogApiFixture.cs Still lots of clean up to do but definitely on the roadmap. |
Beta Was this translation helpful? Give feedback.
-
We're noticing that in Rider and VS.net that it will only allow debugging the test itself, we can't debug into the projects with breakpoints etc. And in vs code the tests don't work at all for debugging. Is there documentation on how to get this working? Is there work to get this working? |
Beta Was this translation helpful? Give feedback.
-
Is there some reference on running this in CI where you are already inside docker? |
Beta Was this translation helpful? Give feedback.
-
The solution works from the CatalogApiFixture, but what if I use an integration in the project like Seq? Program.cs for ApiService from AspireStarterProject Program.cs (AppHost)
This is because the reference to the Api project in the csproj of the integration test is
Link to Repo: https://github.com/Pacman1988/AspireAppIntegrationTestWithSeq |
Beta Was this translation helpful? Give feedback.
-
@davidfowl it's been a bit, and just wanted to see if there's any progress or additional thoughts on leveraging WebApplicationFactory in integration tests? I have successfully used your sample on a few different services in our org but it requires some heavier lifting to manually perform service discovery when I have multiple components that want to talk to each other. There's also a hard limit on some local container dependency calling our service because the WebApplicationFactory is only available in-proc - I haven't come across this use case but could see it happening. I know there's a ton of ongoing work with Aspire to prioritize so no worries if no update here. Additionally, I want to commend the team on the overall vision and product - it really is a joy to work with. |
Beta Was this translation helpful? Give feedback.
-
I use something like that with IDistributedApplicationTestingBuilder as Apphost and I clean and modify the "normal" resources (remove persistent lifetime or remove data volumes) It seems to work (I m at the begining) public class AspireFixture : IAsyncLifetime
{
public IDistributedApplicationTestingBuilder AppHost { get; private set; } = default!;
public HttpClient AppHttpClient { get; private set; } = default!;
public HttpClient AuthHttpClient { get; private set; } = default!;
public ResourceNotificationService ResourceNotificationService { get; private set; } = default!;
public DistributedApplication? App { get; private set; }
public async Task InitializeAsync()
{
AppHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.UbikLink_AppHost>();
AppHost.Services.ConfigureHttpClientDefaults(clientBuilder =>
{
clientBuilder.AddStandardResilienceHandler();
});
//Remove useless resources for testing
RemoveNotNeededResourcesForTesting();
//Change config for testing
ModifyResourcesForTesting();
App = await AppHost.BuildAsync();
ResourceNotificationService = App.Services
.GetRequiredService<ResourceNotificationService>();
await App.StartAsync();
AppHttpClient = App.CreateHttpClient("ubiklink-proxy");
AuthHttpClient = App.Services.GetRequiredService<IHttpClientFactory>().CreateClient("auth-httpclient");
//TODO: Change that to be in config
AuthHttpClient.BaseAddress = new Uri("https://myauth/oauth/token");
await ResourceNotificationService.WaitForResourceAsync(
"ubiklink-proxy",
KnownResourceStates.Running
)
.WaitAsync(TimeSpan.FromSeconds(30));
}
private void RemoveNotNeededResourcesForTesting()
{
var pgAdminResources = AppHost.Resources
.Where(r => r.GetType() == typeof(PgAdminContainerResource))
.ToList();
foreach (var pgAdmin in pgAdminResources)
{
AppHost.Resources.Remove(pgAdmin);
}
}
private void ModifyResourcesForTesting()
{
var cache = AppHost.Resources.Where(r => r.Name == "cache")
.FirstOrDefault();
var db = AppHost.Resources.Where(r => r.Name == "ubiklink-postgres")
.FirstOrDefault();
if (cache != null)
{
var containerLifetimeAnnotation = cache.Annotations
.OfType<ContainerLifetimeAnnotation>()
.FirstOrDefault();
if (containerLifetimeAnnotation != null)
{
cache.Annotations.Remove(containerLifetimeAnnotation);
}
}
if (db != null)
{
var containerLifetimeAnnotation = db.Annotations
.OfType<ContainerLifetimeAnnotation>()
.FirstOrDefault();
if (containerLifetimeAnnotation != null)
{
db.Annotations.Remove(containerLifetimeAnnotation);
}
var dataVolumeAnnotation = db.Annotations
.OfType<ContainerMountAnnotation>()
.FirstOrDefault();
if (dataVolumeAnnotation != null)
{
db.Annotations.Remove(dataVolumeAnnotation);
}
}
}
public async Task DisposeAsync()
{
AppHttpClient?.Dispose();
AuthHttpClient?.Dispose();
if (App != null)
{
if (App is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
}
else
{
App.Dispose();
}
}
}
}
[CollectionDefinition("AspireApp collection")]
public class AspireAppCollection : ICollectionFixture<AspireFixture>
{
// This class has no code, and is never created. Its purpose is simply
// to be the place to apply [CollectionDefinition] and all the
// ICollectionFixture<> interfaces.
} In this example, I removed pgadmin container and cleaned persistence annotations (lifecycle + volume). Because the testbuilder is not able to reuse the resources and it creates a big mess. I don't know if it's fine ? It seems to allow me to test my Yarp endpoints with all the "true" resources behind. (without webfactory). |
Beta Was this translation helpful? Give feedback.
-
You might want to have a look at this answer on how to perform functional unit tests with Aspire. It spins up |
Beta Was this translation helpful? Give feedback.
-
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Testing;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
namespace Tests.Integration.Harness;
/// <summary>
/// A custom WebApplicationFactory that bootstraps an Aspire AppHost for integration tests.
/// It starts all required infrastructure/resources declared in the AppHost, removes the API-under-test from the
/// Aspire host, and then injects the API's configuration/environment into the test server.
/// </summary>
/// <typeparam name="TEntryPoint">Program class of the API under test (the SUT).</typeparam>
/// <typeparam name="TAppHost">The Aspire AppHost Program type (e.g., Projects.Pep_Host).</typeparam>
public class AspireWebApplicationFactory<TEntryPoint, TAppHost> : WebApplicationFactory<TEntryPoint>
where TEntryPoint : class
where TAppHost : class
{
private DistributedApplication? _app;
private bool _disposed;
/// <summary>
/// Name of the resource in the AppHost that corresponds to the API under test. If not provided, the
/// factory will try to infer the name from the entrypoint assembly name.
/// </summary>
public string? ApiResourceName { get; init; }
/// <summary>
/// Optional callback invoked after the AppHost is built but before it is started. You can tweak
/// resources (e.g., remove optional dashboards) or adjust configuration.
/// </summary>
public Action<IDistributedApplicationTestingBuilder>? ConfigureAppHost { get; init; }
/// <summary>
/// Optional callback to modify the DI container of the SUT.
/// </summary>
public Action<IServiceCollection>? ConfigureServices { get; init; }
/// <summary>
/// Optional callback invoked after the AppHost is built and started. You can implement readiness waits here.
/// </summary>
public Func<IServiceProvider, CancellationToken, Task>? AfterAppHostStartedAsync { get; init; }
/// <summary>
/// Resources the AppHost should include.
/// </summary>
public List<string> Resources { get; init; } = [];
/// <summary>
/// Determines whether the container lifetimes should be removed from all resources in the app host, if false, this will allow persistence between test runs (useful while adding new tests for speed).
/// </summary>
public bool Ephemeral { get; init; } = true;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
var testingBuilder = DistributedApplicationTestingBuilder.CreateAsync<TAppHost>()
.GetAwaiter().GetResult();
var apiResource = ResolveApiResource(testingBuilder.Resources.ToList());
if (apiResource is null)
throw new InvalidOperationException("Could not resolve the API-under-test resource from the AppHost. Provide ApiResourceName or ApiResourceSelector.");
int added;
do
{
var annotations = testingBuilder.Resources.Where(r => r.Annotations.OfType<ResourceRelationshipAnnotation>().Any(p => Resources.Contains(p.Resource.Name) && p.Type == "Parent" && !Resources.Contains(p.Resource.Name))).Select(r => r.Name);
var parents = testingBuilder.Resources.Where(r => r is IResourceWithParent && !Resources.Contains(r.Name)).Select(r => r.Name);
List<string> adds = [..annotations, ..parents];
Resources.AddRange(adds);
added = adds.Count;
} while (added > 0);
foreach (var resource in testingBuilder.Resources.Where(r => !Resources.Distinct().Contains(r.Name)).ToArray())
testingBuilder.Resources.Remove(resource);
if (Ephemeral)
{
foreach (var resource in testingBuilder.Resources)
{
var lifetime = resource.Annotations.OfType<ContainerLifetimeAnnotation>().FirstOrDefault();
if (lifetime != null) resource.Annotations.Remove(lifetime);
}
}
ConfigureAppHost?.Invoke(testingBuilder);
_app = testingBuilder.BuildAsync().GetAwaiter().GetResult();
foreach (var resource in testingBuilder.Resources)
_app.ResourceNotifications.WaitForResourceHealthyAsync(resource.Name).GetAwaiter().GetResult();
_app.StartAsync().GetAwaiter().GetResult();
AfterAppHostStartedAsync?.Invoke(_app.Services, CancellationToken.None).GetAwaiter().GetResult();
var config = ResolveConfigurationFromResourceAsync(apiResource, _app.Services).GetAwaiter().GetResult();
foreach (var (key, value) in config)
{
if (value is null) continue;
builder.UseSetting(key, value);
}
builder.ConfigureServices(s => ConfigureServices?.Invoke(s));
}
/// <summary>
/// Creates an <see cref="HttpClient"/> configured to communicate with the specified resource.
/// </summary>
/// <param name="resource">The name of the resource.</param>
/// <param name="endpoint">The endpoint on the resource to communicate with.</param>
/// <returns>The <see cref="HttpClient"/>.</returns>
protected HttpClient CreateResourceClient(string resource, string endpoint = "https") => _app!.CreateHttpClient(resource, endpoint);
private IResource? ResolveApiResource(IReadOnlyCollection<IResource> resources)
{
if (!string.IsNullOrWhiteSpace(ApiResourceName))
return resources.FirstOrDefault(r => string.Equals(r.Name, ApiResourceName, StringComparison.OrdinalIgnoreCase));
var inferred = typeof(TEntryPoint).Assembly.GetName().Name;
return !string.IsNullOrWhiteSpace(inferred)
? resources.FirstOrDefault(r => string.Equals(r.Name, inferred, StringComparison.OrdinalIgnoreCase))
: null;
}
/// <summary>
/// Loads environment variables exposed by the resource and translates double-underscore keys to configuration keys.
/// </summary>
private static async Task<Dictionary<string, string?>> ResolveConfigurationFromResourceAsync(IResource resource, IServiceProvider serviceProvider, CancellationToken cancellationToken = default)
{
var config = new Dictionary<string, string?>();
if (resource is not IResourceWithEnvironment resourceWithEnvironment || !resourceWithEnvironment.TryGetEnvironmentVariables(out var annotations))
return config;
var options = new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run)
{
ServiceProvider = serviceProvider
};
var execContext = new DistributedApplicationExecutionContext(options);
var context = new EnvironmentCallbackContext(execContext, cancellationToken: cancellationToken);
foreach (var annotation in annotations)
await annotation.Callback(context);
foreach (var (key, value) in context.EnvironmentVariables)
{
if (resource is ProjectResource && key == "ASPNETCORE_URLS") continue;
string? configValue;
switch (value)
{
case string s:
configValue = s;
break;
case IValueProvider v:
try
{
configValue = await v.GetValueAsync(cancellationToken);
}
catch
{
configValue = null;
}
break;
case null:
configValue = null;
break;
default:
throw new InvalidOperationException($"Unsupported environment value type: {value.GetType()}");
}
if (configValue is not null)
config[key.Replace("__", ":")] = configValue;
}
return config;
}
protected override void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
_app?.Dispose();
base.Dispose(disposing);
}
_disposed = true;
}
} Usage: public class MyWebApplicationFactory : AspireWebApplicationFactory<Program, My_Host>
{
public MyWebApplicationFactory()
{
ApiResourceName = "MyApp"; // If resource name in Aspire is different to assembly name of entry point
Resources = ["Database", "RabbitMQ", "Azurite"]; // Resources I want to keep in the AppHost for this WebAppFactory (default is to remove all)
}
} This gives you a WebApplicationFactory hooked up to services in Aspire with config binding. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
I've only heard about Aspire yesterday but it sounds like the perfect fit for a new project I've been working on. In my current solution I have a distributed system and I do a lot of the stuff Aspire does manually, I run everything in containers and manually wire up url's using docker-compose etc etc. Aspire looks like a great alternative to this as it is, admittedly, a headache.
The only thing that concerns me is there is no mention of integration testing using
WebApplicationFactory
along with the Aspire host. I'm not sure how this would work, but does anyone know if there is support for something similar on the roadmap? It seems like a no brainer that we are provided with something along these lines for Aspire.Thanks
Beta Was this translation helpful? Give feedback.
All reactions