使用测试服务器的 .NET 6 E2E 测试在 TeamCity CI 上失败,但手动工作 运行

.NET 6 E2E tests with test server fail on TeamCity CI, but work run manually

我有一个 .NET 6 应用程序(带有 ASP.NET 核心 Web 部件)。我使用 cypress 进行 E2E 测试。在我们从 .NET Framework 迁移到 .NET 6 之后,我想 运行 在 Web 应用程序上进行 E2E cypress 测试。

我的过程是,我实际上在 C# 中 运行 nUnit 测试初始化​​ in-memory SQLite 数据库,然后将此数据库连接用于我的应用程序。我使用这样的方法在实际测试运行之前创建端到端测试所需的数据。然后我从 C# 单元测试 运行 cypress。这一切在 .NET Framework 中运行良好,其中 Web 应用程序在测试期间实际上 运行,因此它在 IIS 中的真实 URL 上可用。

使用 .NET 6,我们有 WebApplicationFactory class 允许 运行 应用程序 in-memory。但是,我希望我的 Web 应用程序在具有真实端口的真实 URL 下可用,因此我可以针对它启动 cypress 测试。

在探索了 this GitHub discussion 的各种想法后,我创建了一个真实测试应用程序工厂(具有真实 IP 和端口):

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();
        }
    }
}

然后我创建了一个从 RealTestServerWebAppFactory 派生的 class,我在其中设置了一些测试依赖项注入,但这对我们的示例来说并不重要。我们称之为 child class MyServerTestApp.

测试中的实例创建如下所示:

var serverTestApp = new MyServerTestApp();
serverTestApp.ServerAddress;

在第二行,调用了 EnsureServer() 方法,它为应用程序创建了一个默认客户端。它还从 RealTestServerWebAppFactory class 调用 CreateHost(IHostBuilder builder) 来完成所有的魔法。在本地,在这一行之后我有应用程序的 URL,我可以通过网络浏览器(或 E2E 测试)访问它。

现在,问题是测试在本地运行良好。但是,我还在 CI - TeamCity 上执行它们,更准确地说。 TeamCity 用于 运行 那些测试的 dotnet test 命令如下所示:

"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

这实际上是对 dotnet test 命令的直接使用。但是,TeamCity 执行这些测试失败,出现以下异常:

[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

好像是这段代码:

var addresses = server.Features.Get<IServerAddressesFeature>();

returns TeamCity 运行 地址的空连接。因此,这段代码:

ClientOptions.BaseAddress = addresses!.Addresses
            .Select(x => new Uri(x))
            .Last();

失败 System.InvalidOperationException : Sequence contains no elements

奇怪的是,如果我 运行 使用与 TeamCity 手动使用 cmd 相同的 dotnet test 命令进行这些测试,它就可以工作。它在同一台 CI 机器上运行,但从 cmd 手动执行,而不是作为 TeamCity 的构建步骤。 CI 机器 运行s Windows 服务器 2019.

我一直在想这可能与用于 运行ning TeamCity 的用户 rights/permissions 有关。但是,TeamCity 的 Windows 服务使用“本地系统”帐户,因此据我所知它应该具有所有权限。

感谢任何 help/ideas :)

根据 ,您必须使用以下代码在 IServerAddressesFeature 上设置默认地址:

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
}