PAYATHON 2026

SignalR Long Polling 的 WebApi 集成测试为何运行失败、调试通过?

排障赵师傅

结论

这个异常说明,请求已经匹配到带有授权元数据的 /hub/notification/negotiate 端点,但在执行端点前,没有经过能够处理该端点授权的 AuthorizationMiddleware。

需要检查测试宿主实际使用的管道,确保请求按以下顺序执行:

UseRouting
UseAuthentication
UseAuthorization
UseEndpoints(执行 MapHub 注册的端点)

现有代码有一处配置缺口:当 IsTestIntegration() 返回 true 时,Program.cs 会跳过 app.UseAuthorization()。测试工厂需要补齐这部分配置。

调试时为什么能通过,仅凭这些日志还无法确定。还需检查 WebApiTestFactory 和 UseNotificationServices() 的实现,尤其要看它们是否根据调试信息决定是否添加中间件。

为什么在 StartAsync() 时才报错

MapHub 负责注册端点,注册成功并不代表请求已经经过正确的认证和授权流程。

await conn.StartAsync() 会启动 SignalR 连接,其中的协商请求会访问:

/hub/notification/negotiate

此时,端点执行中间件发现端点具有授权元数据,却没有发现授权中间件针对该端点执行过,于是抛出异常。授权元数据可能来自 Hub 上的 [Authorize]、映射时的 RequireAuthorization(),也可能由通知组件添加。

JWT 是否有效是另一个问题。即使客户端提供了有效 token,服务端仍然需要配置 UseAuthorization()。

服务注册和请求管道配置也有区别。下面的代码注册授权服务:

services.AddAuthorization();

下面的代码将授权中间件加入请求管道:

app.UseAuthorization();

两者不能互相替代。

如何理解运行与调试日志的差异

__MiddlewareDescriptions 不能作为管道配置依据

__MiddlewareDescriptions 是内部诊断信息,不是适合业务代码依赖的稳定公开契约。它是否存在、何时填充,可能受框架版本及调试或诊断设施影响。

因此,不能按下面的方式判断:

从 __MiddlewareDescriptions 中找不到某个 middleware
所以认定这个 middleware 没有注册

日志中没有这个列表,不能证明没有调用 UseRouting();列表中出现 EndpointRoutingMiddleware,也不能证明授权中间件的位置正确。

两份日志都出现了 __EndpointRouteBuilder 和 __UseRouting,这些内部属性同样不足以证明最终请求管道的执行顺序。

先查清 Endpoint Routing Middleware Added 是怎么计算的

Endpoint Routing Middleware Added False 来自自定义的 WebApiTestFactory。需要先查看计算代码,才能判断这个布尔值是否准确反映了管道状态。

重点检查:

  • 是否通过 __MiddlewareDescriptions 判断中间件是否存在。
  • 是否使用 Debugger.IsAttached 或 #if DEBUG 改变配置流程。
  • 判断结果是否控制了 UseRouting()、UseAuthorization() 或 _configure?.Invoke(builder) 的调用。
  • 是否存在重复配置、分支管道,或者不同的 IApplicationBuilder 实例。

诊断信息如果只用于打印日志,本身不一定会导致失败;如果参与了管道组装的判断,就可能让普通运行和调试运行产生不同结果。

调整测试工厂中的执行顺序

先补齐测试环境的授权配置

原入口中的授权配置位于这个分支:

if (!app.Environment.IsTestIntegration())
{
    // ...
    app.UseAuthorization();
    // ...
}

测试环境不会执行这个分支。可以由测试工厂配置所需的认证和授权流程,也可以将两种环境都需要的配置移到公共位置。

当前测试使用 JWT,还需确认测试服务集合注册了实际使用的认证方案,并且能够验证 GetAuthenticationTokensAsync("bob") 返回的 token。AddNotification() 是否完成了这些注册,要查看组件实现,不能只看方法名称。

在调用自定义配置前加入授权中间件

按题目描述,_configure?.Invoke(builder) 最终会通过 UseEndpoints(...) 调用 MapHub(...)。如果这个回调负责加入端点执行中间件,测试工厂应按以下顺序配置:

// 测试工厂中负责组装请求管道的位置。
// builder 为 IApplicationBuilder,相关服务已完成注册。

builder.UseRouting();

builder.UseAuthentication();
builder.UseAuthorization();

// 该回调内部调用 UseNotificationServices(),
// 并最终通过 UseEndpoints(...) 注册、执行 Hub 端点。
_configure?.Invoke(builder);

对应的测试配置可以保持为:

Configure(app =>
{
    app.UseNotificationServices();
});

这段代码的前提是,UseNotificationServices() 内部确实添加了 UseEndpoints(...)。应先检查其实现,再决定由工厂还是扩展方法统一管理路由与端点配置,避免重复添加。

如果回调已经加入了 UseEndpoints(...),再在回调后添加下面的代码就太晚了:

builder.UseAuthorization();

匹配的请求可能已经在端点执行阶段被处理或抛出异常,无法到达后面的授权中间件。

将 UseAuthorization() 放在 UseRouting() 前面也不合适,因为此时尚未选中端点,授权中间件无法正确处理该端点的授权元数据。

分清 MapHub 与 UseEndpoints

MapHub 注册端点,UseEndpoints 将端点执行中间件加入管道。

如果扩展方法只是向 WebApplication 注册 Hub 端点,端点实际在哪里执行,还取决于宿主如何组装管道。如果扩展方法内部调用了 UseEndpoints(...),就必须确保认证和授权中间件排在它前面。

部分使用 WebApplication 的应用会自动加入认证或授权中间件,具体行为取决于宿主配置和服务注册。入口文件使用了 WebApplication,并不代表自定义测试工厂组装的管道也已自动补齐这些步骤。

如何确认两种运行方式使用了同一条管道

  1. 比较实际环境与宿主配置,包括 EnvironmentName、IsTestIntegration() 的结果、构建配置和测试工厂实例。调试运行不等于 Development 环境,普通运行也不等于 Release 构建。
  2. 查看 UseNotificationServices()、_configure 回调及工厂配置代码,确认 UseRouting()、UseAuthorization() 和 UseEndpoints(...) 位于处理 Hub 请求的同一条管道。
  3. 在首次访问工厂的 Services、创建客户端或启动宿主之前,完成服务与管道配置。宿主启动后再修改回调,不能用来改变已经构建的管道。
  4. 在 UseRouting() 之后临时记录请求路径和 HttpContext.GetEndpoint()?.DisplayName,并打开 Routing、Authentication、Authorization 的日志,确认协商请求选中了哪个端点,再追踪授权处理过程。
  5. 单独运行该测试,并检查并行测试是否共用了可变的工厂配置、静态字段或取消令牌源,排除测试间共享状态的影响。

修正后还应在没有附加调试器的情况下验证。调试通过只能说明该次请求成功,不能证明普通运行使用了相同配置。

另一个独立问题:没有可靠地等待消息接收

原测试还有这一行:

DelayAsync(TimeSpan.FromSeconds(60));

如果 DelayAsync 返回 Task,这里缺少 await,断言可能在消息到达前执行。这也可能造成“运行失败、调试通过”,但无法解释 StartAsync() 阶段的授权异常。

相比共享字符串再配合延时,直接等待消息回调更合适。假设 conn 和 notifier 已按原测试创建,可以将连接与消息断言部分改为:

var received = new TaskCompletionSource<string>(
    TaskCreationOptions.RunContinuationsAsynchronously);

using var subscription = conn.On<string>(
    HubMethods.RecieveMessage,
    message =>
    {
        received.TrySetResult(message);
    });

try
{
    await conn.StartAsync();

    conn.ConnectionId.Should().NotBeNull();
    conn.State.Should().Be(HubConnectionState.Connected);

    var expected = "this is a test";

    await notifier.BroadCastAsync(expected);

    var actual = await received.Task.WaitAsync(
        TimeSpan.FromSeconds(60));

    actual.Should().Be(expected);
}
finally
{
    await conn.DisposeAsync();
}

Task.WaitAsync(TimeSpan) 适用于 .NET 6 及以上版本;较早版本需要使用兼容的超时等待实现。代码沿用了原有的 API 名称 HubMethods.RecieveMessage 和 BroadCastAsync。

超时值是等待上限,消息到达后就会立即继续执行。先修正授权管道,再处理异步等待,分别验证连接建立和广播接收。

备注:内容仅供参考。