Im trying to get E2E/UI testing (selenium, playwright) to work with my unit testing framework.
The basic idea it to use MSTest and the WebApplicationFactory to spin up a "real server" from within my unit tests. The reason for doing this would be to simply avoid having to deploy/release my application for testing (I guess this could be done by using containers etc, sadly.. Im not allowed to use containers). Im also thinking that doing it this wasy would be a "neat" way of mocking any code that calls external services, and be able to create multiple tests with different scenarios for those external calls.
I have searched the web for a way of doing this, but all I can find are posts about how to do this in previous .Net versions (2.1-5), but since .Net 6 the "startup ceremonial" code has changed and the standard way is now to use the minimal API.
Here is a blog post from Scott.H where he is basically doing exactly what Im planing to do, but with .Net 2.1: https://www.hanselman.com/blog/real-browser-integration-testing-with-selenium-standalone-chrome-and-aspnet-core-21
What I have done so far is creating a custom class that inherits from WebApplicationFactory.
basically:
class MyAppFactory : WebApplicationFactory<Program> {
}
And I can use that perfectly fine for integration testing. However.. the server thats initialized when using that class does not accept http-calls, so I cant reach that using a web browser, and neither can selenium.
I tried to follow Scotts blog post. But for some reason the:
protected override TestServer CreateServer(IWebHostBuilder builder)
Is never called.. (not sure if that has with minimal APIs and .Net 6 to do).
Has anyone managed to use the WebApplicationFactory and .Net 6 Minimal API to spin up an "actual server" in memory that accepts http-calls?
I faced the same problem while migrating to .NET 6 and found the solution thanks to this blog post from Marius Steinbach.
Steps:
Program.cs filevar builder = WebApplication.CreateBuilder(args);
// adds services to the container
builder.Services.AddRazorPages();
var app = builder.Build();
// configures the HTTP request pipeline
app.UseExceptionHandler("/Error");
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();
public partial class Program { }
WebApplicationFactoryFixture (sample)public class WebApplicationFactoryFixture<TEntryPoint> : WebApplicationFactory<TEntryPoint>
where TEntryPoint : class
{
public string HostUrl { get; set; } = "https://localhost:5001"; // we can use any free port
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseUrls(HostUrl);
}
protected override IHost CreateHost(IHostBuilder builder)
{
var dummyHost = builder.Build();
builder.ConfigureWebHost(webHostBuilder => webHostBuilder.UseKestrel());
var host = builder.Build();
host.Start();
return dummyHost;
}
}
public class SmokeSeleniumTest : IClassFixture<WebApplicationFactoryFixture<Program>>
{
private readonly string _webUrl = "https://localhost:7112";
public SmokeSeleniumTest(WebApplicationFactoryFixture<Program> factory)
{
factory.HostUrl = _webUrl;
factory.CreateDefaultClient();
var chromeOptions = new ChromeOptions();
// ...
WebDriver = new ChromeDriver(chromeDriverLocation, chromeOptions);
}
protected IWebDriver WebDriver { get; }
[Theory]
[InlineData("/", "Welcome")]
[InlineData("/Index", "Welcome")]
[InlineData("/Privacy", "Privacy Policy")]
[InlineData("/Error", "Error.")]
public void Get_EndpointsReturnSuccessAndCorrectContentType(string url, string expected)
{
// Arrange & Act
WebDriver.Navigate().GoToUrl($"{_webUrl}{url}");
// Assert
WebDriver.FindElement(By.TagName("h1"), 30);
WebDriver.FindElement(By.TagName("h1")).Text.Should().Contain(expected);
}
}
It works!
Samples are validated through a CI pipeline in Azure DevOps in this repository.
The solution provided by @devpro works but it's not ideal, because you actually start two servers at once, one with kestrel which you can then interact with on localhost and one in memory, which you can debug, but they are separate instances! Which can be super confusing while debugging, beucase breakpoints will hit twice once with kestrel instance and once with in-memory.
Here is a version which works with one instance only, the only downside is that we need to use some reflection in order to start the underlying TeseServer, because it implements the IServer interface explicitly.
All the other steps should work as @devprov has explained them.
using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Simulation;
public class KestrelTestServer : TestServer, IServer
{
private readonly KestrelServer _server;
public KestrelTestServer(IServiceProvider serviceProvider) : base(serviceProvider)
{
// We get all the transport factories registered, and the first one is the correct one
// Getting the IConnectionListenerFactory directly from the service provider does not work
var transportFactory = serviceProvider.GetRequiredService<IEnumerable<IConnectionListenerFactory>>().First();
var kestrelOptions = serviceProvider.GetRequiredService<IOptions<KestrelServerOptions>>();
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
_server = new KestrelServer(kestrelOptions, transportFactory, loggerFactory);
}
async Task IServer.StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken)
{
// We need to also invoke the TestServer's StartAsync method to ensure that the test server is started
// Because the TestServer's StartAsync method is implemented explicitly, we need to use reflection to invoke it
await InvokeExplicitInterfaceMethod(nameof(IServer.StartAsync), typeof(TContext), [application, cancellationToken]);
// We also start the Kestrel server in order for localhost to work
await _server.StartAsync(application, cancellationToken);
}
async Task IServer.StopAsync(CancellationToken cancellationToken)
{
await InvokeExplicitInterfaceMethod(nameof(IServer.StopAsync), null, [cancellationToken]);
await _server.StopAsync(cancellationToken);
}
private Task InvokeExplicitInterfaceMethod(string methodName, Type? genericParameter, object[] args)
{
var baseMethod = typeof(TestServer).GetInterfaceMap(typeof(IServer)).TargetMethods.First(m => m.Name.EndsWith(methodName));
var method = genericParameter == null ? baseMethod : baseMethod.MakeGenericMethod(genericParameter);
var task = method.Invoke(this, args) as Task ?? throw new InvalidOperationException("Task not returned");
return task;
}
}
internal class WebApplicationFactoryWithKestrel : WebApplicationFactory<Program>
{
private readonly int _port;
public WebAppFactoryWithKestrel(int port)
{
_port = port;
}
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder.UseKestrel(opt => opt.ListenLocalhost(_port));
webHostBuilder.ConfigureServices(s => s.AddSingleton<IServer, KestrelTestServer>());
});
return base.CreateHost(builder);
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With