NewLife/NewLife.Remoting

重构集成测试为9个有序独立步骤方法

将 IoTZero/ZeroServer 集成测试由单一串行方法重构为 9 个有序 [Fact] 测试方法,提升可维护性和定位效率。WebFactory 增加共享测试状态,步骤间复用客户端与上下文,简化参数传递。统一异步等待写法,优化注释与日志,确保资源正确释放。事件上报等辅助流程也独立为单测,整体风格更一致。
大石头 authored at 2026-04-27 15:50:31
adfd52d
Tree
1 Parent(s) 2cffd2f
Summary: 4 changed files with 180 additions and 162 deletions.
Modified +57 -64
Modified +17 -0
Modified +87 -98
Modified +19 -0
Modified +57 -64
diff --git a/XUnitTest.Samples/IoTZero/IoTZeroIntegrationTests.cs b/XUnitTest.Samples/IoTZero/IoTZeroIntegrationTests.cs
index 5a688e2..9956108 100644
--- a/XUnitTest.Samples/IoTZero/IoTZeroIntegrationTests.cs
+++ b/XUnitTest.Samples/IoTZero/IoTZeroIntegrationTests.cs
@@ -15,8 +15,9 @@ namespace XUnitTest.Samples.IoTZero;
 /// <summary>
 /// IoTZero HTTP 全链路集成测试。
 /// 使用 WebApplicationFactory 启动真实 Kestrel(端口自动分配),HttpDevice 通过 HTTP 连接。
-/// 单个测试方法串行执行 9 步,覆盖:自动注册登录、配置持久化、服务端实体验证、注销/重登、
-/// 心跳计数、WebSocket 通知建立、SendCommand 投递、升级检查、事件上报。
+/// 9 个有序 [Fact] 方法(由 DefaultOrderer 按源码顺序执行),共用 IoTZeroWebFactory 持有的客户端状态。
+/// 覆盖:自动注册登录、配置持久化、服务端实体验证、注销/重登、心跳计数、
+///        WebSocket 通知建立、SendCommand 投递、升级检查、事件上报。
 /// </summary>
 [Collection("SamplesIntegration")]
 [TestCaseOrderer("NewLife.UnitTest.DefaultOrderer", "NewLife.UnitTest")]
@@ -26,58 +27,21 @@ public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
 
     public IoTZeroIntegrationTests(IoTZeroWebFactory factory) => _factory = factory;
 
-    #region 9 步串行集成测试
-    [Fact(DisplayName = "IoTZero_9步全链路集成测试")]
-    public async Task FullIntegrationFlow_AllNineSteps()
+    #region Step 1 — 自动注册登录
+    [Fact(DisplayName = "IoTZero_Step1_自动注册登录")]
+    public async Task Step1_AutoRegisterLogin()
     {
-        // 确保 Factory 已启动(首次访问时触发 ConfigureWebHost + CreateHost)
+        // 确保 Factory 已启动
         Assert.False(_factory.BaseUrl.IsNullOrEmpty(), "BaseUrl 未初始化,Factory 启动失败");
         XTrace.WriteLine("IoTZero 测试服务地址:{0}", _factory.BaseUrl);
 
-        // 清空上次测试残留数据:XCode DAL 连接 IoT 持久化在 AppBase/Data 目录,
-        // 跨测试运行累积;若不清理,Step3 的 Assert.Empty(DeviceOnline) 会因旧记录而误判失败
+        // Factory 的 CleanTestData() 已在 InitializeAsync 中执行;
+        // 此处再次清理 DeviceOnline/DeviceHistory,防止同一进程内多次测试运行时的残留(步骤级清理)
         DeviceOnline.Delete("1=1");
         DeviceHistory.Delete("1=1");
 
-        // 创建新的 ClientSetting(空 DeviceCode/DeviceSecret,触发服务端自动注册)
-        var setting = new ClientSetting { Server = _factory.BaseUrl };
-
-        using var client = new HttpDevice(setting);
-        client.Log = XTrace.Log;
-
-        // Step 1 - 自动注册登录
-        var (code, secret) = await Step1_AutoRegisterLogin(client, setting);
-
-        // Step 2 - 服务端实体验证(通过 XCode 直接查询,同进程内共享 DAL 连接)
-        var deviceId = await Step2_VerifyServerEntities(code);
-
-        // Step 3 - 注销
-        await Step3_Logout(client, deviceId);
-
-        // Step 4 - 修改密钥后重登
-        await Step4_ReLoginWithNewSecret(client, setting, code, deviceId);
-
-        // Step 5 - 心跳
-        await Step5_Ping(client, deviceId);
-
-        // Step 6 - WebSocket 建立确认
-        await Step6_WaitWebSocket(deviceId);
-
-        // Step 7 - SendCommand 投递(Timeout=0,服务端立即返回,客户端通过 WebSocket 收到命令)
-        await Step7_SendCommandAndReceive(client, code);
-
-        // Step 8 - 升级检查
-        await Step8_Upgrade(client);
-
-        // Step 9 - 事件上报
-        await Step9_PostEvents(client);
-    }
-    #endregion
-
-    #region Step 1 — 自动注册登录
-    private async Task<(String code, String secret)> Step1_AutoRegisterLogin(HttpDevice client, ClientSetting setting)
-    {
-        XTrace.WriteLine("=== Step 1: 自动注册登录 ===");
+        var client  = _factory.TestClient;
+        var setting = _factory.TestSetting;
 
         // 确保以空 DeviceCode/DeviceSecret 开始,触发服务端自动注册
         setting.DeviceCode   = null!;
@@ -105,15 +69,19 @@ public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
 
         XTrace.WriteLine("配置文件已写入,内容长度={0}", configContent!.Length);
 
-        return (setting.DeviceCode, setting.DeviceSecret);
+        // 保存 DeviceCode 到 Factory,供后续步骤使用
+        _factory.TestCode = setting.DeviceCode;
     }
     #endregion
 
     #region Step 2 — 服务端实体验证
-    private async Task<Int32> Step2_VerifyServerEntities(String code)
+    [Fact(DisplayName = "IoTZero_Step2_验证服务端实体")]
+    public async Task Step2_VerifyServerEntities()
     {
         XTrace.WriteLine("=== Step 2: 验证服务端实体 ===");
 
+        var code = _factory.TestCode;
+
         // 等待服务端异步写库完成(Device/DeviceOnline 由 Save 同步写入,无需长等待)
         await Task.Delay(300).ConfigureAwait(false);
 
@@ -141,23 +109,27 @@ public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
 
         XTrace.WriteLine("DeviceOnline 数量={0}", onlines.Count);
 
-        // 验证 DeviceHistory 已记录登录(DefaultDeviceService.OnLogin 写 source+"登录",source=Http)
-        // WriteHistory 调用 SaveAsync,XCode EntityQueue ~1秒刷新,需轮询等待
+        // 验证 DeviceHistory 已记录登录(WriteHistory 使用 EntityQueue 异步写库,需轮询等待)
         var logins = await WaitForDeviceHistory(device.Id, "Http登录").ConfigureAwait(false);
         Assert.NotEmpty(logins);
         Assert.True(logins[0].Success, "第一次登录的 DeviceHistory 应为成功");
 
         XTrace.WriteLine("DeviceHistory 登录记录={0}", logins.Count);
 
-        return device.Id;
+        // 保存 DeviceId 到 Factory,供后续步骤使用
+        _factory.TestDeviceId = device.Id;
     }
     #endregion
 
     #region Step 3 — 注销
-    private async Task Step3_Logout(HttpDevice client, Int32 deviceId)
+    [Fact(DisplayName = "IoTZero_Step3_注销")]
+    public async Task Step3_Logout()
     {
         XTrace.WriteLine("=== Step 3: 注销 ===");
 
+        var client   = _factory.TestClient;
+        var deviceId = _factory.TestDeviceId;
+
         await client.Logout("集成测试注销", CancellationToken.None).ConfigureAwait(false);
 
         Assert.False(client.Logined, "Logout 后 Logined 应为 false");
@@ -174,8 +146,7 @@ public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
         }
         Assert.True(onlines.Count == 0, $"DeviceOnline 应已清除,但有 {onlines.Count} 条记录,SessionIds=[{String.Join(",", onlines.Select(o => o.SessionId))}]");
 
-        // DeviceHistory 应有下线记录(DefaultDeviceService.Logout 写 source+"设备下线",source=Http)
-        // WriteHistory 调用 SaveAsync,XCode EntityQueue ~1秒刷新,需轮询等待
+        // DeviceHistory 应有下线记录(WriteHistory 使用 EntityQueue 异步写库,需轮询等待)
         var logouts = await WaitForDeviceHistory(deviceId, "Http设备下线").ConfigureAwait(false);
         Assert.NotEmpty(logouts);
 
@@ -184,10 +155,16 @@ public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
     #endregion
 
     #region Step 4 — 修改密钥后重登
-    private async Task Step4_ReLoginWithNewSecret(HttpDevice client, ClientSetting setting, String code, Int32 deviceId)
+    [Fact(DisplayName = "IoTZero_Step4_修改密钥后重登")]
+    public async Task Step4_ReLoginWithNewSecret()
     {
         XTrace.WriteLine("=== Step 4: 修改密钥后重登 ===");
 
+        var client   = _factory.TestClient;
+        var setting  = _factory.TestSetting;
+        var code     = _factory.TestCode;
+        var deviceId = _factory.TestDeviceId;
+
         // 在服务端直接修改 Device.Secret(模拟运维修改密钥场景)
         var device = Device.FindByCode(code)!;
         var newSecret = Rand.NextString(16);
@@ -209,8 +186,7 @@ public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
         var onlines = DeviceOnline.FindAll(DeviceOnline._.DeviceId == deviceId);
         Assert.NotEmpty(onlines);
 
-        // 登录历史应有 2 条记录(第一次自动注册 + 本次)
-        // WriteHistory 调用 SaveAsync,XCode EntityQueue ~1秒刷新,需轮询等待
+        // 登录历史应有 2 条记录(第一次自动注册 + 本次);EntityQueue 异步写入,需轮询等待
         var logins = await WaitForDeviceHistory(deviceId, "Http登录", 2).ConfigureAwait(false);
         Assert.True(logins.Count >= 2, $"应有至少 2 条登录历史,实际={logins.Count}");
 
@@ -219,10 +195,14 @@ public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
     #endregion
 
     #region Step 5 — 心跳
-    private async Task Step5_Ping(HttpDevice client, Int32 deviceId)
+    [Fact(DisplayName = "IoTZero_Step5_心跳")]
+    public async Task Step5_Ping()
     {
         XTrace.WriteLine("=== Step 5: 心跳 ===");
 
+        var client   = _factory.TestClient;
+        var deviceId = _factory.TestDeviceId;
+
         // 记录心跳前的 Pings(DeviceOnline 的 AdditionalFields 有 Pings 累加字段)
         var onlineBefore = DeviceOnline.Find(DeviceOnline._.DeviceId == deviceId);
         var pingsBefore  = onlineBefore?.Pings ?? 0;
@@ -244,10 +224,13 @@ public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
     #endregion
 
     #region Step 6 — WebSocket 建立确认
-    private async Task Step6_WaitWebSocket(Int32 deviceId)
+    [Fact(DisplayName = "IoTZero_Step6_WebSocket建立确认")]
+    public async Task Step6_WaitWebSocket()
     {
         XTrace.WriteLine("=== Step 6: 等待 WebSocket 建立 ===");
 
+        var deviceId = _factory.TestDeviceId;
+
         // HttpDevice 登录后会自动建立 WebSocket 通知连接(Features 包含 Notify)
         // 轮询最长 5 秒等待 DeviceOnline.WebSocket = true
         var deadline = DateTime.Now.AddSeconds(5);
@@ -267,10 +250,14 @@ public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
     #endregion
 
     #region Step 7 — SendCommand 投递
-    private async Task Step7_SendCommandAndReceive(HttpDevice client, String code)
+    [Fact(DisplayName = "IoTZero_Step7_SendCommand投递")]
+    public async Task Step7_SendCommandAndReceive()
     {
         XTrace.WriteLine("=== Step 7: SendCommand 投递 ===");
 
+        var client = _factory.TestClient;
+        var code   = _factory.TestCode;
+
         var tcs = new TaskCompletionSource<CommandEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
 
         client.Received += (_, e) =>
@@ -311,10 +298,13 @@ public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
     #endregion
 
     #region Step 8 — 升级检查
-    private async Task Step8_Upgrade(HttpDevice client)
+    [Fact(DisplayName = "IoTZero_Step8_升级检查")]
+    public async Task Step8_Upgrade()
     {
         XTrace.WriteLine("=== Step 8: 升级检查 ===");
 
+        var client = _factory.TestClient;
+
         // 无新版本时应返回 null,不应抛出异常
         IUpgradeInfo? info = null;
         var ex = await Record.ExceptionAsync(async () =>
@@ -329,10 +319,13 @@ public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
     #endregion
 
     #region Step 9 — 事件上报
-    private async Task Step9_PostEvents(HttpDevice client)
+    [Fact(DisplayName = "IoTZero_Step9_事件上报")]
+    public async Task Step9_PostEvents()
     {
         XTrace.WriteLine("=== Step 9: 事件上报 ===");
 
+        var client = _factory.TestClient;
+
         var events = new[]
         {
             new EventModel { Name = "TestEvent1", Type = "info",  Remark = "IoTZero 集成测试事件1" },
@@ -365,4 +358,4 @@ public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
         return result;
     }
     #endregion
-}
+}
\ No newline at end of file
Modified +17 -0
diff --git a/XUnitTest.Samples/IoTZero/IoTZeroWebFactory.cs b/XUnitTest.Samples/IoTZero/IoTZeroWebFactory.cs
index 0b77aec..331ca6f 100644
--- a/XUnitTest.Samples/IoTZero/IoTZeroWebFactory.cs
+++ b/XUnitTest.Samples/IoTZero/IoTZeroWebFactory.cs
@@ -1,6 +1,7 @@
 extern alias IoTZero;
 
 using IoTZero::IoT.Data;
+using IoTZero::IoTEdge;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Mvc.Testing;
 using Microsoft.Extensions.Configuration;
@@ -22,6 +23,18 @@ public sealed class IoTZeroWebFactory : WebApplicationFactory<IoTZero::Program>,
 
     /// <summary>原始工作目录(析构时恢复)</summary>
     private String _origCurrentDir = null!;
+
+    /// <summary>共享测试状态:已登录的客户端(贯穿全部 9 个测试步骤)</summary>
+    public HttpDevice TestClient { get; private set; } = null!;
+
+    /// <summary>共享测试状态:客户端设置(DeviceCode/DeviceSecret 在 Step1 登录后回填)</summary>
+    public ClientSetting TestSetting { get; private set; } = null!;
+
+    /// <summary>共享测试状态:设备编码(Step1 登录后填充,后续步骤使用)</summary>
+    public String TestCode { get; set; } = null!;
+
+    /// <summary>共享测试状态:设备 ID(Step2 验证实体后填充,后续步骤使用)</summary>
+    public Int32 TestDeviceId { get; set; }
     #endregion
 
     #region WebApplicationFactory 重写
@@ -77,11 +90,15 @@ public sealed class IoTZeroWebFactory : WebApplicationFactory<IoTZero::Program>,
         CleanTestData();
         // 预置测试所需的 Product(EdgeGateway),否则 OnRegister 会抛出“无效产品”
         SeedProduct();
+        // 初始化共享测试状态:创建客户端(Step1 时以空 DeviceCode/DeviceSecret 触发自动注册)
+        TestSetting = new ClientSetting { Server = BaseUrl };
+        TestClient  = new HttpDevice(TestSetting) { Log = XTrace.Log };
         await Task.CompletedTask;
     }
 
     async Task IAsyncLifetime.DisposeAsync()
     {
+        (TestClient as IDisposable)?.Dispose();
         Dispose();
         await Task.CompletedTask;
     }
Modified +87 -98
diff --git a/XUnitTest.Samples/ZeroServer/ZeroServerIntegrationTests.cs b/XUnitTest.Samples/ZeroServer/ZeroServerIntegrationTests.cs
index 6914136..06b3709 100644
--- a/XUnitTest.Samples/ZeroServer/ZeroServerIntegrationTests.cs
+++ b/XUnitTest.Samples/ZeroServer/ZeroServerIntegrationTests.cs
@@ -15,8 +15,9 @@ namespace XUnitTest.Samples.ZeroServer;
 /// <summary>
 /// ZeroServer HTTP 全链路集成测试。
 /// 使用 WebApplicationFactory 启动真实 Kestrel(端口自动分配),NodeClient 通过 HTTP 连接。
-/// 单个测试方法串行执行 9 步,覆盖:自动注册登录、配置持久化、服务端实体验证、注销/重登、
-/// 心跳计数、WebSocket 通知建立、SendCommand + CommandReply、升级检查、事件上报。
+/// 9 个有序 [Fact] 方法(由 DefaultOrderer 按源码顺序执行),共用 ZeroServerWebFactory 持有的客户端状态。
+/// 覆盖:自动注册登录、配置持久化、服务端实体验证、注销/重登、心跳计数、
+///        WebSocket 通知建立、SendCommand + CommandReply、升级检查、事件上报。
 /// </summary>
 [Collection("SamplesIntegration")]
 [TestCaseOrderer("NewLife.UnitTest.DefaultOrderer", "NewLife.UnitTest")]
@@ -26,11 +27,11 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
 
     public ZeroServerIntegrationTests(ZeroServerWebFactory factory) => _factory = factory;
 
-    #region 9 步串行集成测试
-    [Fact(DisplayName = "ZeroServer_9步全链路集成测试")]
-    public async Task FullIntegrationFlow_AllNineSteps()
+    #region Step 1 — 自动注册登录
+    [Fact(DisplayName = "ZeroServer_Step1_自动注册登录")]
+    public async Task Step1_AutoRegisterLogin()
     {
-        // 确保 Factory 已启动(首次访问时触发 ConfigureWebHost + CreateHost)
+        // 确保 Factory 已启动
         Assert.False(_factory.BaseUrl.IsNullOrEmpty(), "BaseUrl 未初始化,Factory 启动失败");
         XTrace.WriteLine("ZeroServer 测试服务地址:{0}", _factory.BaseUrl);
 
@@ -39,51 +40,14 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
         NodeOnline.Delete("1=1");
         NodeHistory.Delete("1=1");
 
-        // 创建新的 ClientSetting(空 Code/Secret,触发服务端自动注册)
-        var setting = new ClientSetting { Server = _factory.BaseUrl };
-
-        using var client = new NodeClient(setting);
-        client.Log = XTrace.Log;
-
-        // Step 1 - 自动注册登录
-        var (code, secret) = await Step1_AutoRegisterLogin(client, setting);
-
-        // Step 2 - 服务端实体验证(通过 XCode 直接查询,同进程内共享 DAL 连接)
-        var nodeId = await Step2_VerifyServerEntities(code);
-
-        // Step 3 - 注销
-        await Step3_Logout(client, nodeId);
-
-        // Step 4 - 修改密钥后重登
-        await Step4_ReLoginWithNewSecret(client, setting, code, nodeId);
-
-        // Step 5 - 心跳
-        await Step5_Ping(client, nodeId);
-
-        // Step 6 - WebSocket 建立确认
-        await Step6_WaitWebSocket(nodeId);
-
-        // Step 7 - SendCommand + CommandReply
-        await Step7_SendCommandAndReply(client, code);
-
-        // Step 8 - 升级检查
-        await Step8_Upgrade(client);
-
-        // Step 9 - 事件上报
-        await Step9_PostEvents(client);
-    }
-    #endregion
-
-    #region Step 1 — 自动注册登录
-    private async Task<(String code, String secret)> Step1_AutoRegisterLogin(NodeClient client, ClientSetting setting)
-    {
-        XTrace.WriteLine("=== Step 1: 自动注册登录 ===");
+        var client  = _factory.TestClient;
+        var setting = _factory.TestSetting;
 
         // 确保以空 Code/Secret 开始,触发服务端自动注册
         setting.Code   = null!;
         setting.Secret = null!;
 
-        await client.Login(null, CancellationToken.None).ConfigureAwait(false);
+        await client.Login(null, CancellationToken.None);
 
         // 客户端状态验证
         Assert.True(client.Logined, "Login 后 Logined 应为 true");
@@ -95,7 +59,7 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
         XTrace.WriteLine("自动注册成功,Code={0}", setting.Code);
 
         // 等待 ClientSetting.Save() 写文件(异步 IO,稍作等待)
-        await Task.Delay(300).ConfigureAwait(false);
+        await Task.Delay(300);
 
         // 配置文件持久化验证
         var configContent = _factory.ReadClientConfigFile();
@@ -105,17 +69,21 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
 
         XTrace.WriteLine("配置文件已写入,内容长度={0}", configContent!.Length);
 
-        return (setting.Code, setting.Secret);
+        // 保存 Code 到 Factory,供后续步骤使用
+        _factory.TestCode = setting.Code;
     }
     #endregion
 
     #region Step 2 — 服务端实体验证
-    private async Task<Int32> Step2_VerifyServerEntities(String code)
+    [Fact(DisplayName = "ZeroServer_Step2_验证服务端实体")]
+    public async Task Step2_VerifyServerEntities()
     {
         XTrace.WriteLine("=== Step 2: 验证服务端实体 ===");
 
-        // 等待服务端异步写库完成(Node 实体同步写入,短暂等待即可)
-        await Task.Delay(300).ConfigureAwait(false);
+        var code = _factory.TestCode;
+
+        // 等待服务端异步写库完成
+        await Task.Delay(300);
 
         // 验证 Node 实体已创建
         var node = Node.FindByCodeWithCache(code, false);
@@ -139,20 +107,25 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
 
         XTrace.WriteLine("NodeHistory 登录记录={0}", logins.Count);
 
-        return node.Id;
+        // 保存 NodeId 到 Factory,供后续步骤使用
+        _factory.TestNodeId = node.Id;
     }
     #endregion
 
     #region Step 3 — 注销
-    private async Task Step3_Logout(NodeClient client, Int32 nodeId)
+    [Fact(DisplayName = "ZeroServer_Step3_注销")]
+    public async Task Step3_Logout()
     {
         XTrace.WriteLine("=== Step 3: 注销 ===");
 
-        await client.Logout("集成测试注销", CancellationToken.None).ConfigureAwait(false);
+        var client = _factory.TestClient;
+        var nodeId = _factory.TestNodeId;
+
+        await client.Logout("集成测试注销", CancellationToken.None);
 
         Assert.False(client.Logined, "Logout 后 Logined 应为 false");
 
-        // 轮询等待 NodeOnline 被删除(RemoveOnline 同步执行,但直接 SQL 读取可能有短暂延迟)
+        // 轮询等待 NodeOnline 被删除
         var emptyOnlines = await WaitForNodeOnlineEmpty(nodeId);
         Assert.Empty(emptyOnlines);
 
@@ -165,10 +138,16 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
     #endregion
 
     #region Step 4 — 修改密钥后重登
-    private async Task Step4_ReLoginWithNewSecret(NodeClient client, ClientSetting setting, String code, Int32 nodeId)
+    [Fact(DisplayName = "ZeroServer_Step4_修改密钥后重登")]
+    public async Task Step4_ReLoginWithNewSecret()
     {
         XTrace.WriteLine("=== Step 4: 修改密钥后重登 ===");
 
+        var client  = _factory.TestClient;
+        var setting = _factory.TestSetting;
+        var code    = _factory.TestCode;
+        var nodeId  = _factory.TestNodeId;
+
         // 在服务端直接修改 Node.Secret(模拟运维修改密钥场景)
         var node = Node.FindByCodeWithCache(code, false)!;
         var newSecret = Rand.NextString(16);
@@ -179,7 +158,7 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
         setting.Secret = newSecret;
 
         // 重新登录
-        await client.Login("重登测试", CancellationToken.None).ConfigureAwait(false);
+        await client.Login("重登测试", CancellationToken.None);
 
         Assert.True(client.Logined, "修改密钥后重登,Logined 应为 true");
 
@@ -196,19 +175,23 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
     #endregion
 
     #region Step 5 — 心跳
-    private async Task Step5_Ping(NodeClient client, Int32 nodeId)
+    [Fact(DisplayName = "ZeroServer_Step5_心跳")]
+    public async Task Step5_Ping()
     {
         XTrace.WriteLine("=== Step 5: 心跳 ===");
 
+        var client = _factory.TestClient;
+        var nodeId = _factory.TestNodeId;
+
         // 记录心跳前的 PingCount
-        var onlineBefore = NodeOnline.FindByNodeId(nodeId);
+        var onlineBefore    = NodeOnline.FindByNodeId(nodeId);
         var pingCountBefore = onlineBefore?.PingCount ?? 0;
 
-        var rs = await client.Ping(CancellationToken.None).ConfigureAwait(false);
+        var rs = await client.Ping(CancellationToken.None);
         Assert.NotNull(rs);
 
         // 等待服务端写库
-        await Task.Delay(300).ConfigureAwait(false);
+        await Task.Delay(300);
 
         // 刷新实体缓存后验证 PingCount 增加
         var onlineAfter = NodeOnline.Find(NodeOnline._.NodeId == nodeId);
@@ -220,12 +203,15 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
     }
     #endregion
 
-    #region Step 6 — WebSocket 通知建立
-    private async Task Step6_WaitWebSocket(Int32 nodeId)
+    #region Step 6 — WebSocket 建立确认
+    [Fact(DisplayName = "ZeroServer_Step6_WebSocket建立确认")]
+    public async Task Step6_WaitWebSocket()
     {
         XTrace.WriteLine("=== Step 6: 等待 WebSocket 建立 ===");
 
-        // NodeClient 登录后会自动建立 WebSocket 通知连接(因为 Features 包含 Notify)
+        var nodeId = _factory.TestNodeId;
+
+        // NodeClient 登录后会自动建立 WebSocket 通知连接
         // 轮询最长 5 秒等待 NodeOnline.WebSocket = true
         var deadline = DateTime.Now.AddSeconds(5);
         NodeOnline? online = null;
@@ -233,7 +219,7 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
         {
             online = NodeOnline.Find(NodeOnline._.NodeId == nodeId);
             if (online?.WebSocket == true) break;
-            await Task.Delay(200).ConfigureAwait(false);
+            await Task.Delay(200);
         }
 
         Assert.NotNull(online);
@@ -244,21 +230,22 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
     #endregion
 
     #region Step 7 — SendCommand + CommandReply
-    private async Task Step7_SendCommandAndReply(NodeClient client, String code)
+    [Fact(DisplayName = "ZeroServer_Step7_SendCommand与CommandReply")]
+    public async Task Step7_SendCommandAndReply()
     {
         XTrace.WriteLine("=== Step 7: SendCommand + CommandReply ===");
 
+        var client = _factory.TestClient;
+        var code   = _factory.TestCode;
+
         var tcs = new TaskCompletionSource<CommandEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
 
         client.Received += (_, e) =>
         {
-            // 只接受我们发出的命令
             if (e.Model?.Command == "test:echo")
                 tcs.TrySetResult(e);
         };
 
-        // 通过 HttpClient 调用 [AllowAnonymous] 的 Node/SendCommand 接口
-        // SendCommand 在 Service 层使用应用令牌验证,此处用已登录的设备令牌代替(测试环境 TokenService 不区分令牌来源)
         using var http = new HttpClient();
         var deviceToken = ((NewLife.Remoting.IApiClient)client).Token;
         if (!deviceToken.IsNullOrEmpty())
@@ -268,16 +255,15 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
             Code     = code,
             Command  = "test:echo",
             Argument = "hello",
-            Timeout  = 10,   // 等待响应最多 10 秒
+            Timeout  = 10,
         });
         var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
 
-        var response = await http.PostAsync($"{_factory.BaseUrl}/Node/SendCommand", content).ConfigureAwait(false);
+        var response = await http.PostAsync($"{_factory.BaseUrl}/Node/SendCommand", content);
         XTrace.WriteLine("SendCommand HTTP 状态={0}", response.StatusCode);
 
-        // 等待客户端收到命令(最多 10 秒)
         using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
-        var cmdEvt = await tcs.Task.WaitAsync(cts.Token).ConfigureAwait(false);
+        var cmdEvt = await tcs.Task.WaitAsync(cts.Token);
 
         Assert.NotNull(cmdEvt.Model);
         Assert.Equal("test:echo", cmdEvt.Model!.Command);
@@ -288,23 +274,45 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
     #endregion
 
     #region Step 8 — 升级检查
-    private async Task Step8_Upgrade(NodeClient client)
+    [Fact(DisplayName = "ZeroServer_Step8_升级检查")]
+    public async Task Step8_Upgrade()
     {
         XTrace.WriteLine("=== Step 8: 升级检查 ===");
 
-        // 无新版本时应返回 null,不应抛出异常
+        var client = _factory.TestClient;
+
         IUpgradeInfo? info = null;
         var ex = await Record.ExceptionAsync(async () =>
         {
-            info = await client.Upgrade(null, CancellationToken.None).ConfigureAwait(false);
+            info = await client.Upgrade(null, CancellationToken.None);
         });
 
         Assert.Null(ex);
-        // info 为 null 表示无可用升级,合法
         XTrace.WriteLine("升级检查成功,info={0}", info == null ? "null(无升级)" : info.Version);
     }
     #endregion
 
+    #region Step 9 — 事件上报
+    [Fact(DisplayName = "ZeroServer_Step9_事件上报")]
+    public async Task Step9_PostEvents()
+    {
+        XTrace.WriteLine("=== Step 9: 事件上报 ===");
+
+        var client = _factory.TestClient;
+
+        var events = new[]
+        {
+            new EventModel { Name = "TestEvent1", Type = "info",  Remark = "集成测试事件1" },
+            new EventModel { Name = "TestEvent2", Type = "alert", Remark = "集成测试事件2" },
+        };
+
+        var count = await client.PostEvents(events);
+
+        Assert.Equal(2, count);
+        XTrace.WriteLine("事件上报成功,返回数量={0}", count);
+    }
+    #endregion
+
     #region 辅助方法
     /// <summary>轮询等待 NodeHistory 出现指定条数记录(绕过实体缓存,直接 SQL 查询)</summary>
     /// <param name="nodeId">节点 ID</param>
@@ -316,10 +324,9 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
         var deadline = DateTime.Now.AddSeconds(5);
         while (DateTime.Now < deadline)
         {
-            // 直接 SQL,绕过 EntityListCache(EntityQueue 异步写入,缓存尚未更新)
             var list = NodeHistory.FindAll(NodeHistory._.NodeId == nodeId & NodeHistory._.Action == action);
             if (list.Count >= minCount) return list;
-            await Task.Delay(200).ConfigureAwait(false);
+            await Task.Delay(200);
         }
         return NodeHistory.FindAll(NodeHistory._.NodeId == nodeId & NodeHistory._.Action == action);
     }
@@ -334,27 +341,9 @@ public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
         {
             var list = NodeOnline.FindAll(NodeOnline._.NodeId == nodeId);
             if (list.Count == 0) return list;
-            await Task.Delay(200).ConfigureAwait(false);
+            await Task.Delay(200);
         }
         return NodeOnline.FindAll(NodeOnline._.NodeId == nodeId);
     }
     #endregion
-
-    #region Step 9 — 事件上报
-    private async Task Step9_PostEvents(NodeClient client)
-    {
-        XTrace.WriteLine("=== Step 9: 事件上报 ===");
-
-        var events = new[]
-        {
-            new EventModel { Name = "TestEvent1", Type = "info",  Remark = "集成测试事件1" },
-            new EventModel { Name = "TestEvent2", Type = "alert", Remark = "集成测试事件2" },
-        };
-
-        var count = await client.PostEvents(events).ConfigureAwait(false);
-
-        Assert.Equal(2, count);
-        XTrace.WriteLine("事件上报成功,返回数量={0}", count);
-    }
-    #endregion
 }
Modified +19 -0
diff --git a/XUnitTest.Samples/ZeroServer/ZeroServerWebFactory.cs b/XUnitTest.Samples/ZeroServer/ZeroServerWebFactory.cs
index f089d47..d578623 100644
--- a/XUnitTest.Samples/ZeroServer/ZeroServerWebFactory.cs
+++ b/XUnitTest.Samples/ZeroServer/ZeroServerWebFactory.cs
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Configuration;
 using NewLife;
 using NewLife.Log;
 using Xunit;
+using ZeroServer::ZeroClient;
 
 namespace XUnitTest.Samples.ZeroServer;
 
@@ -21,6 +22,18 @@ public sealed class ZeroServerWebFactory : WebApplicationFactory<ZeroServer::Pro
 
     /// <summary>原始工作目录(析构时恢复)</summary>
     private String _origCurrentDir = null!;
+
+    /// <summary>共享测试状态:已登录的客户端(贯穿全部 9 个测试步骤)</summary>
+    public NodeClient TestClient { get; private set; } = null!;
+
+    /// <summary>共享测试状态:客户端设置(Code/Secret 在 Step1 登录后回填)</summary>
+    public ClientSetting TestSetting { get; private set; } = null!;
+
+    /// <summary>共享测试状态:设备编码(Step1 登录后填充,后续步骤使用)</summary>
+    public String TestCode { get; set; } = null!;
+
+    /// <summary>共享测试状态:节点 ID(Step2 验证实体后填充,后续步骤使用)</summary>
+    public Int32 TestNodeId { get; set; }
     #endregion
 
     #region WebApplicationFactory 重写
@@ -75,11 +88,17 @@ public sealed class ZeroServerWebFactory : WebApplicationFactory<ZeroServer::Pro
         // WAF 在 CreateHost 后调用 TryExtractHostAddress,自动将实际端口写入 ClientOptions.BaseAddress
         BaseUrl = ClientOptions.BaseAddress?.ToString()?.TrimEnd('/') ?? "";
         XTrace.WriteLine("ZeroServerWebFactory 启动,地址:{0}", BaseUrl);
+
+        // 初始化共享测试状态:创建客户端(Step1 时以空 Code/Secret 触发自动注册)
+        TestSetting = new ClientSetting { Server = BaseUrl };
+        TestClient  = new NodeClient(TestSetting) { Log = XTrace.Log };
+
         await Task.CompletedTask;
     }
 
     async Task IAsyncLifetime.DisposeAsync()
     {
+        (TestClient as IDisposable)?.Dispose();
         Dispose();
         await Task.CompletedTask;
     }