I have a .NET 6 app (with ASP.NET Core web part). I use cypress for E2E testing. After we migrated to .NET 6 from .NET Framework, I wanted to run E2E cypress tests on the web application.
My process is that I actually run nUnit tests in C# that initialize the in-memory SQLite database and then use this database connection for my app. I use such an approach to create data needed for E2E tests before the actual tests are run. Then I run cypress from C# unit test. This all worked fine in .NET Framework, where the web application was actually run during the tests, so it was available on a real URL within IIS.
With .NET 6, we have WebApplicationFactory
class that allows running the app in-memory. However, I wanted my web application to be available under a real URL with a real port, so I can launch cypress tests against it.
After exploring various ideas from this GitHub discussion, I created a factory of real test apps (with real IP and port):
public abstract class RealTestServerWebAppFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
{
private bool _disposed;
private IHost _host;
public RealTestServerWebAppFactory()
{
ClientOptions.AllowAutoRedirect = false;
ClientOptions.BaseAddress = new Uri("http://localhost");
}
public string ServerAddress
{
get
{
EnsureServer();
return ClientOptions.BaseAddress.ToString();
}
}
public override IServiceProvider Services
{
get
{
EnsureServer();
return _host!.Services!;
}
}
// Code created with advices from https://github.com/dotnet/aspnetcore/issues/4892
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
// Final port will be dynamically assigned
builder.UseUrls("http://127.0.0.1:0");
builder.ConfigureServices(ConfigureServices);
}
protected override IHost CreateHost(IHostBuilder builder)
{
// Create the host for TestServer now before we
// modify the builder to use Kestrel instead.
var testHost = builder.Build();
// Modify the host builder to use Kestrel instead
// of TestServer so we can listen on a real address.
builder.ConfigureWebHost(webHostBuilder => webHostBuilder.UseKestrel());
// Create and start the Kestrel server before the test server,
// otherwise due to the way the deferred host builder works
// for minimal hosting, the server will not get "initialized
// enough" for the address it is listening on to be available.
// See https://github.com/dotnet/aspnetcore/issues/33846.
_host = builder.Build();
_host.Start();
// Extract the selected dynamic port out of the Kestrel server
// and assign it onto the client options for convenience so it
// "just works" as otherwise it'll be the default http://localhost
// URL, which won't route to the Kestrel-hosted HTTP server.
var server = _host.Services.GetRequiredService<IServer>();
var addresses = server.Features.Get<IServerAddressesFeature>();
ClientOptions.BaseAddress = addresses!.Addresses
.Select(x => new Uri(x))
.Last();
// Return the host that uses TestServer, rather than the real one.
// Otherwise the internals will complain about the host's server
// not being an instance of the concrete type TestServer.
// See https://github.com/dotnet/aspnetcore/pull/34702.
testHost.Start();
return testHost;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!_disposed)
{
if (disposing)
{
_host?.Dispose();
}
_disposed = true;
}
}
protected abstract void ConfigureServices(IServiceCollection services);
/// <summary>
/// Makes sure that the server has actually been run.
/// It must be executed at lest once to have the address and port assigned and the app fully working
/// </summary>
private void EnsureServer()
{
if (_host is null)
{
using var _ = CreateDefaultClient();
}
}
}
Then I create a class deriving from RealTestServerWebAppFactory
, where I set up some test dependency injection, but this nothing important for our example. Let's call this child class MyServerTestApp
.
An instance creation in the tests looks as follows:
var serverTestApp = new MyServerTestApp();
serverTestApp.ServerAddress;
At the second line, EnsureServer()
method is called which creates a default client for the app. It also calls CreateHost(IHostBuilder builder)
from RealTestServerWebAppFactory
class which does all the magic. Locally, after this line I have the URL for the app and I can access it via a web browser (or E2E tests).
Now, the problem is that the tests work fine locally. However, I also execute them on CI - TeamCity to be more precise. The dotnet test
command used to run those tests by TeamCity looks as follows:
"C:\Program Files\dotnet\dotnet.exe" test C:\TeamCity\buildAgent\work\c9a5fb5bca7f6666\Tests\E2ETests\bin\Release\net6.0-windows\MyApp.E2ETests.dll /logger:logger://teamcity /TestAdapterPath:C:\TeamCity\buildAgent\plugins\dotnet\tools\vstest15 /logger:console;verbosity=normal
which is in fact quite straightforward use of dotnet test
command. However, these tests fail executed by TeamCity with the following exception:
[12:47:54][dotnet test] A total of 1 test files matched the specified pattern.
[12:47:56][dotnet test] NUnit Adapter 4.2.0.0: Test execution started
[12:47:56][dotnet test] Running all tests in C:\TeamCity\buildAgent\work\c9a5fb5bca7f6666\Tests\E2ETests\bin\Release\net6.0-windows\MyApp.E2ETests.dll
[12:47:58][dotnet test] NUnit3TestExecutor discovered 10 of 10 NUnit test cases using Current Discovery mode, Non-Explicit run
[12:48:07][dotnet test] Setup failed for test fixture MyApp.E2ETests.SomeTestsClass
[12:48:07][dotnet test] System.InvalidOperationException : Sequence contains no elements
[12:48:07][dotnet test] StackTrace: at System.Linq.ThrowHelper.ThrowNoElementsException()
[12:48:07][dotnet test] at System.Linq.Enumerable.Last[TSource](IEnumerable`1 source)
[12:48:07][dotnet test] at MyApp.E2ETests.Infrastructure.RealTestServerWebAppFactory`1.CreateHost(IHostBuilder builder) in C:\TeamCity\buildAgent\work\c9a5fb5bca7f6666\Tests\MyApp.E2ETests\Infrastructure\RealTestServerWebAppFactory.cs:line 85
[12:48:07][dotnet test] at Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`1.ConfigureHostBuilder(IHostBuilder hostBuilder)
[12:48:07][dotnet test] at Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`1.EnsureServer()
[12:48:07][dotnet test] at Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`1.CreateDefaultClient(DelegatingHandler[] handlers)
[12:48:07][dotnet test] at MyApp.E2ETests.Infrastructure.RealTestServerWebAppFactory`1.EnsureServer() in C:\TeamCity\buildAgent\work\c9a5fb5bca7f6666\Tests\MyApp.E2ETests\Infrastructure\RealTestServerWebAppFactory.cs:line 122
[12:48:07][dotnet test] at MyApp.E2ETests.Infrastructure.RealTestServerWebAppFactory`1.get_ServerAddress() in C:\TeamCity\buildAgent\work\c9a5fb5bca7f6666\Tests\MyApp.E2ETests\Infrastructure\RealTestServerWebAppFactory.cs:line 35
System.InvalidOperationException : Sequence contains no elements
It seems that this code:
var addresses = server.Features.Get<IServerAddressesFeature>();
returns an empty connection of addresses being run by TeamCity. As a consequence, this code:
ClientOptions.BaseAddress = addresses!.Addresses
.Select(x => new Uri(x))
.Last();
fails with System.InvalidOperationException : Sequence contains no elements
.
What's weird is that if I run those tests using the same dotnet test
command as TeamCity does manually using cmd
, it works. It works on the same CI machine, but executed manually from cmd
, not as TeamCity's build step. The CI machine runs Windows Server 2019.
I've been thinking it might be something related to the rights/permissions of the user used for running TeamCity. However, the Windows Service for TeamCity uses "Local System" account, so it should have all permissions as far as I know.
Any help/ideas are appreciated :)
CodePudding user response:
According to this answer, you have to set the default address on IServerAddressesFeature
using the following code:
var serverFeatures = featureCollection.Get<IServerAddressesFeature>();
if (serverFeatures.Addresses.Count == 0)
{
ListenOn(DefaultAddress); // Start the server on the default address
serverFeatures.Addresses.Add(DefaultAddress) // Add the default address to the IServerAddressesFeature
}