PAYATHON 2026

xUnit v3 集成测试为何在 Release 超时、Debug 正常?

排障赵师傅

结论

这条异常表示:WebApplicationFactory<Program> 启动应用时,等待入口点构建 IHost 超时。现有代码还不足以确定 Release 下阻塞的具体原因,也不足以认定这是 xUnit v3、Testcontainers 或 Respawn 的缺陷。

建议先检查这段初始化逻辑:

using var scope = services.BuildServiceProvider().CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BookContext>();
dbContext.Database.Migrate();

这段代码在应用构建服务容器期间,额外创建了一个服务提供程序,并同步执行数据库迁移。迁移、数据库连接或解析 BookContext 时触发的依赖初始化,只要其中一处阻塞,外层就可能报出“等待构建 IHost 超时”。

先把迁移移出 ConfigureServices,记录各个启动阶段,再查看 Release 进程的线程调用栈,确认阻塞位置。移出迁移能减少初始化过程中的干扰,但仅凭这一调整,还不能确认 Release 下的根因。

这个超时发生在哪一步

WebApplicationFactory 会延迟启动应用。下面这段代码主要用于保存配置回调:

Factory = new WebApplicationFactory<Program>()
    .WithWebHostBuilder(...);

调用 CreateClient() 时才会触发启动:

Client = Factory.CreateClient();

访问 Factory.Services 也会触发启动。

题目中的执行顺序是:

启动 SQL Server 容器
获取连接字符串
配置 WebApplicationFactory
CreateClient,触发应用启动
创建 Respawner

如果异常确实出现在 CreateClient(),就能确定以下几点:

  • _sqlContainer.StartAsync() 已经返回,但应用实际使用的数据库连接仍需单独验证。
  • Respawner.CreateAsync() 尚未执行,因此不应先把当前启动超时归因于 Respawn。
  • CollectionFixture 和 ClassFixture 都会执行这段启动逻辑,更换 fixture 类型通常解决不了这个问题。

异常外层的 fixture 类型名,只说明异常发生在 fixture 初始化期间。

先定位最后完成的启动阶段

看不到 Console.WriteLine 的输出,不代表代码没有执行。输出是否可见,取决于 xUnit 的捕获设置、运行器,以及输出时是否存在可关联的测试上下文。fixture 初始化阶段尤其容易遇到这个问题。

xUnit v3 支持通过程序集属性启用控制台捕获:

using Xunit;

[assembly: CaptureConsole]

排查 fixture 启动问题时,临时写入文件更方便确认执行进度。可以在 fixture 中加入:

private readonly string _startupLog = Path.Combine(
    Path.GetTempPath(),
    $"book-api-startup-{Guid.NewGuid():N}.log");

private void Mark(string message)
{
    File.AppendAllText(
        _startupLog,
        $"{DateTimeOffset.UtcNow:O} " +
        $"thread={Environment.CurrentManagedThreadId} " +
        $"{message}{Environment.NewLine}");
}

在各个步骤前后分别记录“开始”和“完成”:

Mark("SQL container: starting");
await _sqlContainer.StartAsync();
Mark("SQL container: started");

// 配置 Factory……

Mark("Factory.CreateClient: starting");
Client = Factory.CreateClient();
Mark("Factory.CreateClient: completed");

Mark("Respawner: creating");
_respawner = await Respawner.CreateAsync(
    ConnectionString,
    new RespawnerOptions
    {
        TablesToIgnore = _ignoredTables
    });
Mark("Respawner: created");

在 Program.cs 的以下位置也加入同类记录,并让两个进程或执行路径的日志文件能够相互区分:

  • WebApplication.CreateBuilder() 前后。
  • 自定义拦截器和遥测注册前后。
  • builder.Build() 前后。
  • app.Run() 之前。

异常要记录完整内容:

catch (Exception ex)
{
    Mark(ex.ToString());
    throw;
}

不要记录包含密码的完整连接字符串。比较连接目标时,记录服务器、端口和数据库名即可。

Debug.WriteLine 的调用可能受 DEBUG 条件编译影响,这一点与 Console.WriteLine 不同,因此不能用它判断 Release 的执行进度。

调整 fixture:先注册服务,再执行迁移

在 ConfigureServices 中调用 BuildServiceProvider() 会创建额外的服务提供程序,可能产生另一组单例。这样,数据库初始化使用的依赖就可能与最终应用使用的依赖不一致。

题目中使用的是普通的 AddDbContext<BookContext> 注册,可以按下面的方式调整。示例以 EF Core 8 的常见注册方式为前提;如果项目使用了池化上下文、IDbContextFactory 或其他数据库注册扩展,还需要检查对应的替换逻辑。

using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

public async ValueTask InitializeAsync()
{
    _sqlContainer = new MsSqlBuilder()
        .WithPassword("Strong_password_123!")
        .Build();

    await _sqlContainer.StartAsync();
    ConnectionString = _sqlContainer.GetConnectionString();

    Factory = new WebApplicationFactory<Program>()
        .WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                services.RemoveAll<BookContext>();
                services.RemoveAll<DbContextOptions<BookContext>>();

                services.AddDbContext<BookContext>(options =>
                {
                    options.UseSqlServer(ConnectionString);
                });

                services.RemoveAll<IActivitySink>();
                services.AddSingleton<IActivitySink, NoOpActivitySink>();
            });
        });

    // 访问 Services 会触发应用启动。
    await using (var scope = Factory.Services.CreateAsyncScope())
    {
        var dbContext =
            scope.ServiceProvider.GetRequiredService<BookContext>();

        await dbContext.Database.MigrateAsync();
    }

    Client = Factory.CreateClient();

    _respawner = await Respawner.CreateAsync(
        ConnectionString,
        new RespawnerOptions
        {
            TablesToIgnore = _ignoredTables
        });
}

MigrateAsync() 能避免同步占用执行线程,但不会自动解决数据库锁等待、连接错误或依赖初始化阻塞。迁移开始和完成的时间仍然要记录。

这个顺序要求应用能在数据库迁移前完成启动。如果某个 IHostedService 或其他启动逻辑必须查询已经建好的表,就应先用独立的迁移上下文完成迁移,再启动应用。不要为此又在应用的服务注册回调中构建服务提供程序。

释放资源时,先释放客户端和应用工厂,再停止数据库容器:

public async ValueTask DisposeAsync()
{
    Client?.Dispose();

    if (Factory is not null)
    {
        await Factory.DisposeAsync();
    }

    if (_sqlContainer is not null)
    {
        await _sqlContainer.DisposeAsync();
    }
}

这样可以让应用在关闭期间继续访问数据库。这是资源管理上的修正,不能解释当前的初始化超时。

检查遥测替换是否真的生效

原代码中的这行代码会追加注册,已有的 IActivitySink 注册仍然保留:

services.AddSingleton<IActivitySink, NoOpActivitySink>();

默认依赖注入容器解析单个 IActivitySink 时,通常使用最后一个注册。如果组件解析的是 IEnumerable<IActivitySink>,原来的 sink 仍可能被调用。因此,示例先移除已有注册,再添加替代实现:

services.RemoveAll<IActivitySink>();
services.AddSingleton<IActivitySink, NoOpActivitySink>();

这仍不能保证关闭所有遥测路径。OpenTelemetry 的 instrumentation、exporter,以及第三方组件内部注册的服务,都需要分别检查。

可以在相同的 Release 运行条件下做一次临时对照:分别停用 Application Insights、AddTelemetryClientActivitySink()、OpenTelemetry 和自定义拦截器,再逐项恢复。如果恢复某一项后再次阻塞,就继续检查它的初始化过程和回调调用栈。这个对照用于缩小排查范围,目前还没有证据确认遥测是根因。

分开比较构建配置与调试器

“Debug 正常、Release 超时”往往同时涉及两个条件的变化:

  • 编译配置是 Debug 还是 Release。
  • 运行时是否附加调试器。

应分别比较 Debug 直接运行、Debug 附加调试器、Release 直接运行和 Release 附加调试器这四种情况。

如果只在未附加调试器时失败,重点检查时序和并发。如果始终只有 Release 失败,再检查条件编译、构建属性、配置文件复制规则,以及两种配置实际加载的程序集。

编译配置与 ASP.NET Core 环境也要分开看:

Debug / Release
Development / Production / 自定义环境名

这两组设置没有固定对应关系,因此不能推断 Release 一定不会进入:

if (app.Environment.IsDevelopment())

直接记录实际环境名:

app.Environment.EnvironmentName

同时记录两次运行的 .NET SDK、目标框架、测试运行器及相关包的精确版本。只说“最新版本”,不足以复现版本相关问题。

卡住时查看线程调用栈

阶段日志可以确认执行停在哪一段,线程调用栈则能进一步说明它在等待什么。

Release 运行卡住时,附加到实际执行测试的进程,暂停全部线程,检查:

  • 是否停在 SQL 连接、迁移命令或数据库锁等待。
  • 是否卡在服务构造函数、静态初始化或自定义依赖注册中。
  • 是否存在 .Result、.Wait() 或相互等待的任务。
  • 是否停在遥测回调、控制台输出或其他锁操作中。

如果怀疑与并行执行有关,可以临时只运行这组测试进行对照。若单独运行恢复正常,再检查其他测试是否共享静态状态、数据库或进程级遥测配置。

还可以做一个不使用 WebApplicationFactory 的最小验证:只启动同样的容器,用同样的连接字符串创建 BookContext,然后执行 MigrateAsync(),以区分数据库初始化问题和应用启动问题。

五分钟只是这次等待的超时边界,不能说明应用只是启动较慢。先找到最后一条阶段日志,在超时前取得调用栈,再据此判断该修复数据库初始化、依赖注册还是遥测代码,并解释 Debug 与 Release 的差异。

备注:内容仅供参考。