# LicenseManager Platform - 测试策略

| 项目 | 内容 |
| --- | --- |
| 文档版本 | v1.0 |
| 文档类型 | 测试策略 (Testing Strategy) |
| 关联文档 | [RDP v1.0.1](./LicenseManager-Platform-RDP-v1.0.1.md) · [TAD v1.0](./LicenseManager-Platform-TAD-v1.0.md) · [API Spec](./LicenseManager-Platform-API-Spec-v1.0.md) · [Deployment Guide](./LicenseManager-Platform-Deployment-Guide-v1.0.md) · [Core Dev Guide](./LicenseManager.Core-Development-Guide-v1.0.md) |
| 目标读者 | 后端工程师、前端工程师、QA、SRE、技术负责人 |
| 文档日期 | 2026-07-25 |

---

## 0. 修订历史

| 版本 | 日期 | 修订内容 |
| --- | --- | --- |
| v1.0 | 2026-07-25 | 初稿：基于 `tests/` 与 `web/admin/e2e/` 现有测试代码整理测试金字塔、分层规范、CI 集成与速查表 |
| v1.1 | 2026-09 | 按实测更新测试矩阵：Server 1 → 5 个测试类（32 用例）、Api 32 → 40+ 个集成测试类（约 973 → 1065 用例）、Platform 约 658 → 742、Infrastructure 约 137 → 172、Server 32 → 34，合计 2208 个用例 |
| v1.2 | 2026-09 | 按第四轮审查同步：§2.1 E2E 目录树更新为实际规模（admin 25 个 spec + 共享 `helpers.ts`/`fixtures.ts`；portal 3 个 spec），补注其余 web 应用（docs/embed/marketing/partner/sandbox/status/trust/trust-center/widget）暂无 E2E；§1.2 补实测覆盖率快照（行覆盖 86%，分支 62.4%，`coveragereport/` 为 2026-09-14 快照、不含本轮新增控制器） |
| v1.3 | 2026-09-24 | 复核同步：Api.Tests 实测 1066（原 1065）、合计 2209（原 2208）；§2.1 目录树补 `sdk/` 五语言测试与 `tests/e2e/` 旧 JS spec；新增 §2.4 SDK 测试矩阵与 §9.5 SDK 测试速查命令 |
| v1.4 | 2026-10-08 | 商业化裁剪：移除/标注资金流相关测试条目（支付适配器覆盖率行、PSP 用例强制项、billing-finance / orders-payments / pay-dialog 三个 E2E spec、TestDbContext 忽略表 Order/Invoice 行、P0 页面清单），移交外部销售系统 |
| v1.5 | 2026-10-14 | 默认种子管理员凭据同步为 `admin@licensemanager.cn` / `Admin@123456`（与演示环境一致） |

---

## 1. 概述

本文档定义 LicenseManager Platform 全栈（.NET 8 后端 + Vue 3 管理前端）的测试策略、分层规范、运行方式与 CI/CD 集成要求。所有新增功能必须按本策略补齐对应层级的测试，PR 不通过最低覆盖率门槛不予合入。

### 1.1 测试金字塔

```
            ┌──────────┐
            │   E2E    │   Playwright + Chromium（~10%，覆盖关键用户旅程）
            └──────────┘
        ┌──────────────────┐
        │  Integration     │   WebApplicationFactory + EF InMemory（~20%）
        └──────────────────┘
    ┌──────────────────────────┐
    │       Unit Tests         │   xUnit + FluentAssertions（~70%，主力）
    └──────────────────────────┘
```

| 层级 | 工具 | 占比目标 | 运行速度 | 反馈粒度 |
| --- | --- | --- | --- | --- |
| 单元测试 | xUnit 2.9 + FluentAssertions 6.12 | ~70% | 秒级 | 方法级 |
| 集成测试 | WebApplicationFactory + EF Core InMemory | ~20% | 秒级 | API 端点级 |
| E2E 测试 | Playwright 1.61 + Chromium | ~10% | 分钟级 | 用户旅程级 |

### 1.2 覆盖率目标

| 模块 | 单元测试覆盖率 | 集成测试覆盖 | E2E 覆盖 |
| --- | --- | --- | --- |
| `LicenseManager.Core` | ≥ 80% | — | — |
| `LicenseManager.Platform` 服务层 | ≥ 80% | 关键服务 | — |
| ~~`LicenseManager.Infrastructure` 支付适配器~~ | ~~≥ 75%~~ | — | — |（已随商业化裁剪移除，资金流测试移交外部销售系统） |
| `LicenseManager.Api` 控制器 | ≥ 70% | 全部端点 | P0/P1 页面 |
| `web/admin` 前端页面 | — | — | 64 个页面（含 Dashboard/Reports 等非列表页）+ 登录/CRUD/导航 |

> 覆盖率统计建议通过 `dotnet test --collect:"XPlat Code Coverage"` 生成 Cobertura 报告；前端 E2E 覆盖以 `web/admin/e2e/list-pages.spec.ts` 与 `api-coverage.spec.ts` 中列举的页面清单为准。

**实测快照（2026-09-14，`coveragereport/`，14 个 Cobertura 报告合并、6 个程序集）**：行覆盖 **86%**（15094/17551），分支覆盖 **62.4%**（3360/5380）。分程序集行覆盖：

| 程序集 | 行覆盖 |
| --- | --- |
| `LicenseManager.Api` | 85.7% |
| `LicenseManager.Core` | 91.5% |
| `LicenseManager.Platform` | 86.1% |
| `LicenseManager.Infrastructure` | 81.4% |
| `LicenseManager.Server` | 94.1% |

> 注意：该快照生成于 2026-09-14，**不含第四轮审查后新增的控制器/埋点**；分支覆盖 62.4% 偏低，为后续改进项。

---

## 2. 测试分层架构

### 2.1 仓库目录结构

```
LicenseManager/
├── src/                                    # 生产代码
│   ├── LicenseManager.Core.Abstractions/
│   ├── LicenseManager.Core/
│   ├── LicenseManager.Infrastructure/
│   ├── LicenseManager.Platform/
│   ├── LicenseManager.Server/
│   ├── LicenseManager.Tools/
│   └── LicenseManager.Api/
├── tests/                                  # .NET 测试项目（5 个）
│   ├── LicenseManager.Core.Tests/          # Core 域：License/Feature/SigningKey/Utils
│   ├── LicenseManager.Platform.Tests/      # Platform 服务：70+ Service 测试
│   ├── LicenseManager.Infrastructure.Tests/# 数据库提供程序/持久化/约定测试（原 PSP 支付适配器测试已随商业化裁剪移除）
│   ├── LicenseManager.Server.Tests/        # Server 租约服务测试
│   ├── LicenseManager.Api.Tests/           # 集成测试：WebApplicationFactory
│   └── e2e/                                # 2 个旧 JS spec（dashboard.spec.js / portal.spec.js，针对 website/ 静态页，未纳入 CI 主链路）
├── sdk/                                    # 五语言客户端 SDK（各带测试，见 §2.4）
│   ├── csharp/ + csharp-tests/ContractTests/  # C# SDK + 契约测试（控制台断言程序）
│   ├── go/                                 # *_test.go（keys_sync / leases / signature / webhook）
│   ├── nodejs/                             # src/test_contract / test_signature_leases / test_airgap_endpoints / test_webhook_cross_lang.ts
│   ├── python/                             # tests/（test_signature / test_leases / test_offline_sync）
│   └── rust/                               # tests/（signature_test / lease_test / offline_sync_test / webhook_test）
├── web/admin/
│   ├── playwright.config.ts
│   └── e2e/                                # 28 个 Playwright spec（原 billing-finance / orders-payments / pay-dialog 三个商业 spec 已随商业化裁剪移除）
│       ├── fixtures.ts                     # 共享 fixture（认证页面对象等）
│       ├── helpers.ts                      # 共享辅助函数
│       ├── api-coverage.spec.ts
│       ├── api-proxy.spec.ts
│       ├── auth.spec.ts
│       ├── comms-support.spec.ts
│       ├── compliance-system.spec.ts
│       ├── crud-operations.spec.ts
│       ├── customers-tenants-crud.spec.ts
│       ├── customers.spec.ts
│       ├── dashboard.spec.ts
│       ├── devices-lifecycle.spec.ts
│       ├── helpers-smoke.spec.ts
│       ├── licenses-lifecycle.spec.ts
│       ├── licenses.spec.ts
│       ├── list-pages.spec.ts
│       ├── login-flow.spec.ts
│       ├── multi-role.spec.ts
│       ├── navigation.spec.ts
│       ├── products-domain.spec.ts
│       ├── profile.spec.ts
│       ├── reports.spec.ts
│       ├── routes.spec.ts
│       └── wizard.spec.ts
└── web/portal/
    └── e2e/                                # 3 个 Playwright spec（同一 helpers/fixtures 模式）
        ├── fixtures.ts
        ├── portal-auth.spec.ts
        ├── portal-licenses.spec.ts
        └── portal-support.spec.ts
```

> 其余 web 应用（`web/docs`、`web/embed`、`web/marketing`、`web/partner`、`web/sandbox`、`web/status`、`web/trust`、`web/trust-center`、`web/widget`）当前**暂无 E2E 测试**，为已知缺口（见审查报告第四轮）。

### 2.2 .NET 测试项目矩阵

| 测试项目 | 目标框架 | 被测项目 | 主要依赖 | 测试数量级 |
| --- | --- | --- | --- | --- |
| `LicenseManager.Core.Tests` | net8.0 | `LicenseManager.Core` | xUnit、FluentAssertions | 13+ 个测试类，195 个测试用例 |
| `LicenseManager.Platform.Tests` | net8.0 | `LicenseManager.Platform` + `LicenseManager.Core` | xUnit、FluentAssertions、Logging.Abstractions | 75+ 个测试类，742 个测试用例 |
| `LicenseManager.Infrastructure.Tests` | net8.0 | `LicenseManager.Infrastructure` | xUnit、FluentAssertions | 16+ 个 PSP/Mock 适配器测试类 + 注册表/验签测试，172 个测试用例 |
| `LicenseManager.Server.Tests` | net8.0 | `LicenseManager.Server` | xUnit、FluentAssertions | 5 个测试类，34 个测试用例 |
| `LicenseManager.Api.Tests` | net8.0 | `LicenseManager.Api` | xUnit、`Microsoft.AspNetCore.Mvc.Testing` 8.0.7、`Microsoft.EntityFrameworkCore.InMemory` 8.0.10、FluentAssertions | 40+ 个集成测试类，1066 个测试用例 |

> 实测合计（2026-09，`dotnet test` 全量运行）：**2209** 个测试用例（Core 195 / Platform 742 / Infrastructure 172 / Server 34 / Api 1066），全部通过。

### 2.3 公共测试依赖版本

| 包 | 版本 | 用途 |
| --- | --- | --- |
| `Microsoft.NET.Test.Sdk` | 17.10.0 | 测试主机 |
| `xunit` | 2.9.0 | 测试框架 |
| `xunit.runner.visualstudio` | 2.8.2 | VS 测试发现器 |
| `FluentAssertions` | 6.12.0 | 流式断言 |
| `Microsoft.AspNetCore.Mvc.Testing` | 8.0.7 | `WebApplicationFactory<TEntryPoint>` |
| `Microsoft.EntityFrameworkCore.InMemory` | 8.0.10 | 替换真实数据库（MySql 默认 / PostgreSQL 可选）的 InMemory 数据库 |
| `Microsoft.Extensions.Logging.Abstractions` | 9.0.0 | Platform.Tests 注入空 Logger |
| `@playwright/test` | ^1.61.1 | 前端 E2E 框架 |

### 2.4 SDK 测试矩阵

五语言 SDK（`sdk/`）各自带本地测试，覆盖**本地 RSA 验签、浮动租约（floating lease）与气隙（airgap）配套方法**（公钥分发 `GET /v1/keys`、吊销列表 `GET /v1/customer/licenses/revocations`、离线消费同步 `POST /v1/customer/licenses/{licenseId}/airgap-sync` 等）：

| SDK | 测试位置 | 覆盖内容 | 运行命令（工作目录） |
| --- | --- | --- | --- |
| C#（`sdk/csharp`） | `sdk/csharp-tests/ContractTests`（控制台断言程序，非 xUnit） | 响应信封 + 嵌入式 `SignedLicense` 形状契约 | `dotnet run --project sdk/csharp-tests/ContractTests`（仓库根） |
| Go（`sdk/go`） | `keys_sync_test.go`、`leases_test.go`、`signature_test.go`、`webhook_test.go` | 请求契约、RSA 验签、租约、跨语言 webhook 签名 | `go test ./...`（`sdk/go`） |
| Node.js（`sdk/nodejs`） | `src/test_contract.ts`、`test_signature_leases.ts`、`test_airgap_endpoints.ts`、`test_webhook_cross_lang.ts` | 契约、签名+租约、airgap 端点、跨语言 webhook | `npm test`（`sdk/nodejs`，内部 `tsc` 编译后逐个执行） |
| Python（`sdk/python`） | `tests/test_signature.py`、`test_leases.py`、`test_offline_sync.py` | RSA 验签、租约、离线同步 | `pytest`（`sdk/python`） |
| Rust（`sdk/rust`） | `tests/signature_test.rs`、`lease_test.rs`、`offline_sync_test.rs`、`webhook_test.rs` | RSA 验签、租约、离线同步、webhook | `cargo test`（`sdk/rust`） |

> SDK 测试不依赖运行中的 API：传输层以可注入的假 HTTP transport / mock server 固定请求路径与响应形状。API 侧契约由 `tests/LicenseManager.Api.Tests` 的集成测试兜底。

---

## 3. 单元测试规范

### 3.1 测试框架

所有 .NET 单元测试统一使用 **xUnit 2.9**，断言库统一使用 **FluentAssertions 6.12**。禁止混用 NUnit/MSTest/Moq。

| 关注点 | 选型 | 说明 |
| --- | --- | --- |
| 测试框架 | xUnit | `[Fact]` / `[Theory]` / `[InlineData]` |
| 断言库 | FluentAssertions | `.Should().Be(...)` 链式风格 |
| Mock 方式 | 手写 Mock + `InMemoryRepository<T>` | 不引入 Moq/NSubstitute |
| 日志 | `Microsoft.Extensions.Logging.Abstractions` | `NullLogger<T>.Instance` |

### 3.2 命名规范

统一采用 `MethodName_Scenario_ExpectedResult` 三段式命名，使用下划线分隔；测试类以被测类型加 `Tests` 后缀命名。

| 命名要素 | 规则 | 示例 |
| --- | --- | --- |
| 测试类 | `{TypeName}Tests` | `CustomerServiceTests`、`LicenseGeneratorTests` |
| 测试方法 | `MethodName_Scenario_ExpectedResult` | `CreateAsync_Returns_CustomerDto_With_Code_And_Name` |
| 场景描述 | 简明扼要，描述输入或前置条件 | `Nonexistent`、`WhenSignatureTampered`、`WithValidPayload` |
| 期望结果 | 描述行为契约 | `ShouldReturn200`、`ShouldReturn404`、`ShouldThrow` |

参考示例（来自 `LicenseManager.Api.Tests/AdminApiTests.cs`）：

```csharp
[Fact]
public async Task GetProduct_Nonexistent_ShouldReturn404()
{
    var resp = await Client.GetAsync($"/v1/admin/products/{Guid.NewGuid()}");
    resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
```

### 3.3 AAA 模式（Arrange-Act-Assert）

所有测试方法必须按三段式组织，段间以空行分隔，可省略注释标签但顺序不可调换：

```csharp
[Fact]
public async Task CreateAsync_Persists_To_Repository()
{
    // Arrange
    var svc = CreateService();

    // Act
    var dto = await svc.CreateAsync(new CreateCustomerRequest("C1", "Customer 1", "a@b.com"));

    // Assert
    var fetched = await svc.GetAsync(dto.Id);
    fetched.Should().NotBeNull();
    fetched!.Code.Should().Be("C1");
}
```

| 段落 | 职责 | 禁止事项 |
| --- | --- | --- |
| Arrange | 构造 SUT（被测对象）与依赖 | 禁止执行被测逻辑 |
| Act | 调用被测方法，仅一行 | 禁止多行 Act |
| Assert | 通过 FluentAssertions 验证结果 | 禁止在 Assert 中调用 SUT |

### 3.4 Mock 策略

#### 3.4.1 构造函数注入优先

LicenseManager.Platform 服务层通过构造函数注入仓储与协作者，测试时直接传入真实或手写替身：

```csharp
public class CustomerServiceTests
{
    private readonly InMemoryRepository<Customer> _customerRepo;
    private readonly InMemoryRepository<AuditLog> _auditRepo;
    private readonly AuditService _audit;

    public CustomerServiceTests()
    {
        _customerRepo = new InMemoryRepository<Customer>(c => c.Id);
        _auditRepo = new InMemoryRepository<AuditLog>(l => l.Id);
        _audit = new AuditService(_auditRepo);
    }

    private CustomerService CreateService() => new(_customerRepo, _audit);
}
```

#### 3.4.2 手写 Mock 优先

- **优先**使用真实的 `InMemoryRepository<T>`（位于 `LicenseManager.Platform.Stores`）作为仓储替身，验证完整持久化路径。
- 仅在协作者难以构造（如外部 HTTP 客户端、系统时钟）时编写手写 Stub 类。
- **禁止**引入 Moq/NSubstitute 等动态 Mock 库，保持依赖最小化。

#### 3.4.3 测试套件复用

（原"多 PSP 适配器同构测试"（`PaymentAdapterTestsBase<TAdapter>`，10 个 PSP + Mock）已随商业化裁剪移除；以下泛型基类 + `[Theory]` 派生的复用模式仍适用于同构测试场景，示例保留作模式参考）：

```csharp
public abstract class PaymentAdapterTestsBase<TAdapter> where TAdapter : IPaymentProvider, new()
{
    protected abstract string ExpectedCode { get; }
    protected IPaymentProvider CreateProvider() => new TAdapter();

    [Fact]
    public void Code_ShouldReturnExpectedValue()
    {
        var provider = CreateProvider();
        provider.Code.Should().Be(ExpectedCode);
    }
}
```

### 3.5 测试覆盖目标

| 层 | 目标 | 强制项 |
| --- | --- | --- |
| `LicenseManager.Platform` 服务层 | 行覆盖率 ≥ 80% | 每个公共方法至少 1 个 Happy Path + 1 个错误路径 |
| `LicenseManager.Api` 控制器 | 行覆盖率 ≥ 70% | 集成测试覆盖所有 `[HttpVerb]` 端点 |
| `LicenseManager.Core` | 行覆盖率 ≥ 80% | 关键算法（签名、密钥轮换、特性消费）100% |
| ~~`LicenseManager.Infrastructure` 适配器（PSP）~~ | ~~行覆盖率 ≥ 75%~~ | ~~每个 PSP 必须有 Code、CreateIntent、Refund、VerifySignature 用例~~（已随商业化裁剪移除） |

---

## 4. 集成测试规范

### 4.1 WebApplicationFactory 使用

所有 API 集成测试继承 `ApiTestsBase`，共享 `ApiFactory`（`WebApplicationFactory<Program>` 的子类）。`ApiFactory` 通过 `ConfigureWebHost` 替换数据库注册，使测试主机无需真实数据库（生产默认 MySql，PostgreSQL 可选）。

`tests/LicenseManager.Api.Tests/ApiFactory.cs` 关键逻辑：

```csharp
public sealed class ApiFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Development");  // 避免 JwtSecretResolver 在缺密钥时抛错

        builder.ConfigureServices(services =>
        {
            // 1. 移除关系型 Provider 绑定的 DbContextOptions<LicenseManagerDbContext>
            // 2. 重新注册 EF Core InMemory 数据库（名为 license_manager_test_db）
            // 3. 用 TestDbContext 替换 LicenseManagerDbContext，忽略 JSON 列映射
            // 4. 将 ILicenseStore 路由到 TestDbContext 实例
        });
    }
}
```

| 关注点 | 实现 |
| --- | --- |
| 入口程序 | `WebApplicationFactory<Program>` |
| 环境 | `Development` |
| 数据库 | `UseInMemoryDatabase("license_manager_test_db")` |
| JSON 列兼容 | `TestDbContext.OnModelCreating` 忽略 `Features`/`Metadata`/`Notes`/`Items`/`LineItems` 等集合属性（生产环境 MySql 走 Pomelo JSON 列 + 值转换器，PostgreSQL 可选走原生 jsonb 映射） |
| `ILicenseStore` | 重新指向 `LicenseManagerDbContext`，确保与 `TestDbContext` 同一实例 |

### 4.2 InMemory Database 配置

`TestDbContext`（`tests/LicenseManager.Api.Tests/TestDbContext.cs`）继承自 `LicenseManagerDbContext`，在 `OnModelCreating` 中显式忽略所有映射为 JSON 列的集合/字典属性（生产默认 MySql 时经 Pomelo 映射为 MySQL JSON 列并附加值转换器，PostgreSQL 可选时映射为原生 `jsonb`），避免 EF Core InMemory provider 因无法映射而失败。

忽略的字段分类：

| 聚合根 | 被忽略属性 |
| --- | --- |
| `License` | `Features`、`Metadata`、`Notes`、`AllowedHardwareIds` |
| `User` / `Role` / `ApiKey` / `PersonalAccessToken` / `Tenant` | `RoleIds`、`Permissions`、`Scopes`、`Settings` |
| `Product` / `LicenseTemplate` | `Metadata`、`Features` |
| `WebhookEndpoint` | `SubscribedEvents` |
| `EntitlementSet` | `FeatureLimits`、`FeatureFlags` |
| `Eula` / `Integration` | `AllowedUseTypes`、`Settings` |

> 新增 JSON 列（MySQL JSON / PostgreSQL jsonb）时必须同步更新 `TestDbContext.OnModelCreating`，否则集成测试启动失败。

### 4.3 测试 Fixture 共享

`ApiTestsBase` 实现 `IClassFixture<ApiFactory>`，使同一测试类共享一个 `ApiFactory` 实例（即一个 InMemory 数据库）。跨测试类共享通过 `CollectionBehavior` 与静态字段实现。

`tests/LicenseManager.Api.Tests/ApiTestsBase.cs` 提供以下共享辅助方法：

| 方法 | 用途 |
| --- | --- |
| `ReadWrappedDataAsync(HttpResponseMessage)` | 断言 200 OK + 解包 `{success, data, requestId, timestamp}` 信封，返回 `data` |
| `ReadJsonAsync(HttpResponseMessage)` | 仅解析 JSON，不验证状态码 |
| `CreateProductAsync(HttpClient, code?, name?)` | 通过 `POST /v1/admin/products` 创建产品，返回 id |
| `CreateCustomerAsync(HttpClient, code?, name?)` | 通过 `POST /v1/admin/customers` 创建客户，返回 id |
| `SeedTemplateAsync(ApiFactory, productId)` | 直接向 `IInMemoryRepository<LicenseTemplate>` 注入模板（无 admin 端点） |
| `CreateLicenseAsync(ApiFactory, HttpClient)` | 一键构造 Product + Customer + Template + License 全链路 |

### 4.4 认证 Mock

集成测试不走 Mock 认证，而是调用真实 `POST /v1/auth/login` 接口获取 JWT，并设置 `Authorization: Bearer {token}` 默认请求头。

```csharp
private static readonly SemaphoreSlim _tokenLock = new(1, 1);
private static string? _cachedToken;

private static async Task<string> GetTokenAsync(ApiFactory factory)
{
    if (_cachedToken is not null) return _cachedToken;
    await _tokenLock.WaitAsync();
    try
    {
        if (_cachedToken is not null) return _cachedToken;
        using var unauthClient = factory.CreateClient();
        var resp = await unauthClient.PostAsJsonAsync(
            "/v1/auth/login",
            new { email = "admin@licensemanager.cn", password = "Admin@123456" });
        resp.EnsureSuccessStatusCode();
        var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
        _cachedToken = body!.RootElement.GetProperty("data").GetProperty("token").GetString();
        return _cachedToken!;
    }
    finally { _tokenLock.Release(); }
}
```

| 关注点 | 实现 |
| --- | --- |
| 登录端点 | `POST /v1/auth/login` |
| 默认账号 | `admin@licensemanager.cn` / `Admin@123456` |
| Token 缓存 | `static` 字段 + `SemaphoreSlim`，跨测试类共享，避免触发限流（600 req/min，代码兜底 100） |
| 请求头注入 | 构造函数中设置 `Client.DefaultRequestHeaders.Authorization` |

### 4.5 集成测试组织约定

| 约定 | 说明 |
| --- | --- |
| 测试类继承 | 所有 API 测试类继承 `ApiTestsBase` |
| 测试隔离 | 每个 `ApiFactory` 实例对应一个独立的 InMemory 数据库；跨测试类通过 token 缓存共享会话 |
| 信封断言 | 2xx 响应必须通过 `ReadWrappedDataAsync` 验证 `success=true` 与 `data` 字段 |
| 状态码断言 | 4xx/5xx 直接断言 `resp.StatusCode.Should().Be(...)` |
| 测试数据 | 通过 `Create*Async` 辅助方法构造，使用 `Guid.NewGuid().ToString("N")[..8]` 保证唯一性 |

---

## 5. E2E 测试规范

### 5.1 Playwright 配置

`web/admin/playwright.config.ts` 关键配置：

| 配置项 | 值 | 说明 |
| --- | --- | --- |
| `testDir` | `./e2e` | 测试文件目录 |
| `fullyParallel` | `true` | 文件级并行 |
| `forbidOnly` | `!!process.env.CI` | CI 中禁止 `test.only` |
| `retries` | `CI ? 2 : 0` | CI 失败重试 2 次，本地不重试 |
| `workers` | `CI ? 1 : undefined` | CI 单 worker，本地默认（自动） |
| `reporter` | `[['list'], ['html', { open: 'never' }]]` | 同时输出列表与 HTML 报告 |
| `baseURL` | `http://localhost:5173` | Vite dev server |
| `trace` | `on-first-retry` | 首次重试时收集 trace |
| `screenshot` | `only-on-failure` | 失败时截图 |
| `video` | `retain-on-failure` | 失败时保留视频 |
| `actionTimeout` | 10_000 ms | 单次操作超时 |
| `navigationTimeout` | 30_000 ms | 导航超时 |
| `projects` | `chromium`（Desktop Chrome） | 仅 Chromium，未配置 Firefox/WebKit |

> `webServer` 字段**故意省略**：Playwright 不会自动启动 dev server，需手动启动 .NET API（`http://localhost:8080`）与 Vite（`http://localhost:5173`）。

### 5.2 测试 Fixture

`web/admin/e2e/fixtures.ts` 导出三个核心工件：

| 工件 | 用途 |
| --- | --- |
| `test` | 默认 fixture，自动登录并预填 `sessionStorage` |
| `testWithoutAuth` | 不预填 token，用于登录页测试 |
| `expect` | 重导出 Playwright `expect` |

#### 5.2.1 登录状态 Fixture

```typescript
export const test = base.extend<{ authState: AuthState }, { authState: AuthState }>({
  authState: [async ({}, use) => {
    const state = await loginViaApi()  // 真实调用 POST /v1/auth/login
    await use(state)
  }, { scope: 'worker' }],            // worker 级共享，避免触发限流

  page: async ({ page, authState }, use) => {
    await page.addInitScript(
      ([email, jwt]) => {
        sessionStorage.setItem('admin_email', email)
        sessionStorage.setItem('admin_token', jwt)
      },
      [authState.email, authState.token] as const,
    )
    await use(page)
  },
})
```

| 关注点 | 实现 |
| --- | --- |
| 登录方式 | 真实后端 `POST http://localhost:8080/v1/auth/login` |
| 默认账号 | `admin@licensemanager.cn` / `Admin@123456`（导出为 `ADMIN_EMAIL` / `ADMIN_PASSWORD` 常量） |
| Token 作用域 | `worker` 级，每个 worker 只登录一次 |
| 预填机制 | `page.addInitScript` 在每个页面加载前注入 `sessionStorage` |
| 不带认证变体 | `testWithoutAuth = base`，用于 `/login` 页测试 |

#### 5.2.2 API Mock 策略

除 `pay-dialog.spec.ts` 外，E2E 测试**不使用** Playwright 的 `page.route()` 拦截 API，而是直接调用真实后端。这样能验证完整链路，但要求：

1. 后端 API 已启动且完成种子数据初始化（默认管理员账号存在）
2. 测试数据使用 `Date.now() + Math.random()` 生成唯一标识避免冲突
3. 测试顺序无关，但同一 worker 内共享同一 JWT

例外：~~`pay-dialog.spec.ts`（订单支付弹窗闭环冒烟）采用 mock 认证 + `page.route()` 拦截全部 `/v1` 请求，**无需后端运行**~~（该 spec 已随商业化裁剪移除；`page.route()` 拦截模式说明保留备查）。

### 5.3 页面覆盖要求

E2E 测试覆盖**所有 P0/P1 页面**，由 `web/admin/e2e/list-pages.spec.ts` 与 `api-coverage.spec.ts` 集中维护页面清单（64 个列表页 + Dashboard/Reports 等非列表页）。

#### 5.3.1 页面清单分类

| 分类 | 页面示例 | 数量 |
| --- | --- | --- |
| 核心资源 | Products、Customers、Licenses、Webhooks、API Keys、Tenants、Users、Roles、System Keys、Audit Log、Import/Export | 11 |
| Dashboard/Reports | Dashboard、Reports | 2 |
| 产品域 | Features、License Templates、Entitlement Sets、Custom Fields、Releases、Plans | 6 |
| 授权生命周期 | Devices、Named Users、License Users、Offline Activation、Floating Pool、Consumption、Token Pools、Batch Operations | 8 |
| 合规 | Legal Documents、EULA Use Types、DSR | 3 |
| 系统/身份 | PATs、Login Security、SSO Providers、Integrations、Imports、System Status、Telemetry | 7 |
| 通讯 | Webhook Deliveries、Notification Templates、Webhook Events、Incidents | 4 |
| 支持 | Tickets、Knowledge Base、Support Tiers | 3 |
| **合计** | | **64** |

#### 5.3.2 页面元数据约定

每个页面在 `list-pages.spec.ts` 中以 `PageSpec` 描述：

```typescript
type PageSpec = {
  name: string
  path: string
  hasCreate: boolean   // 工具栏是否渲染"新建"按钮
  hasTable: boolean    // 初始渲染是否为 el-table
}
```

每页面统一执行 4 类断言：
- `loads with page header`：`.page-header` 可见
- `renders an el-table or page content`：`.el-table` / `.el-empty` / `.el-card` / `.page-container` 可见
- `search box accepts input if present`：搜索框可填入（若存在且可编辑）
- `no fatal JS errors during load`：加载期间无致命 JS 错误

#### 5.3.3 P0/P1 页面定义

| 优先级 | 定义 | 测试覆盖要求 |
| --- | --- | --- |
| **P0** | 阻断核心业务流的页面：登录、Dashboard、Products、Customers、Licenses（原 Orders、Subscriptions、Payments 页面已随商业化裁剪移除） | 必须有专属 spec（`customers.spec.ts`、`licenses.spec.ts` 等） + 列表页 smoke + CRUD 全流程 |
| **P1** | 支撑业务流的页面：Webhooks、API Keys、Tenants、Users、Roles、Audit、所有 RDP 补全页面 | 列表页 smoke + API 覆盖（无 5xx） |
| **P2** | 辅助页面：System Status、Telemetry、Reports | 仅 smoke（页面可渲染） |

### 5.4 错误过滤规则

E2E 测试中通过 `page.on('pageerror', ...)` 收集 JS 错误，但需过滤掉后端返回的网络/状态码错误，避免误报。统一过滤规则见 `web/admin/e2e/list-pages.spec.ts`：

```typescript
const fatal = errors.filter((e) =>
  !/network|400|401|404|405|429|500|502|503|504|fetch|ECONN|socket|ERR_/i.test(e)
)
expect(fatal, `页面 ${p.name} 有致命 JS 错误：\n${fatal.join('\n')}`).toEqual([])
```

| 过滤类别 | 正则片段 | 原因 |
| --- | --- | --- |
| HTTP 4xx | `400\|401\|404\|405\|429` | 后端业务错误，非前端 JS 错误 |
| HTTP 5xx | `500\|502\|503\|504` | 后端服务器错误，由后端测试覆盖 |
| 网络错误 | `network\|fetch\|ECONN\|socket\|ERR_` | dev server 未就绪或代理转发失败 |

> `api-coverage.spec.ts` 额外断言：每个页面触发的 `/v1/` API 调用**无 5xx 响应**，但允许 4xx（业务错误）。

### 5.5 运行命令

`web/admin/package.json` 已配置以下脚本：

| 命令 | 作用 | 适用场景 |
| --- | --- | --- |
| `npm test` | `playwright test`（无头模式） | CI、本地快速验证 |
| `npm run test:headed` | `playwright test --headed` | 本地调试，观察浏览器行为 |
| `npm run test:ui` | `playwright test --ui` | 本地调试，使用 Playwright UI Mode（时间轴、DOM picket、watch 模式） |
| `npm run test:report` | `playwright show-report` | 打开上次运行的 HTML 报告 |

#### 5.5.1 运行前置条件

```bash
# 1. 启动后端 API（终端 1）
dotnet run --project src/LicenseManager.Api --urls http://localhost:8080

# 2. 启动前端 dev server（终端 2，工作目录 web/admin）
npm run dev    # 默认 http://localhost:5173

# 3. 运行 E2E（终端 3，工作目录 web/admin）
npm test
```

#### 5.5.2 针对性运行

```bash
# 运行单个 spec 文件
npx playwright test e2e/customers.spec.ts

# 运行匹配名称的测试
npx playwright test -g "login flow"

# 运行并打开报告
npx playwright test && npx playwright show-report
```

---

## 6. 测试数据管理

### 6.1 种子数据

| 层级 | 种子来源 | 说明 |
| --- | --- | --- |
| 集成测试 | `LicenseManager.Api/Services/SeedService.cs` | API 启动时由 `SeedService` 初始化默认管理员 `admin@licensemanager.cn / Admin@123456` |
| 集成测试资源 | `ApiTestsBase.Create*Async` 辅助方法 | 按需创建 Product / Customer / LicenseTemplate / License |
| E2E 测试 | 后端 `SeedService` + 真实 API 调用 | 复用集成测试账号，业务数据通过 `uniqueName()` 生成唯一值 |

### 6.2 测试隔离

| 层级 | 隔离策略 |
| --- | --- |
| .NET 单元测试 | 每个测试类 `new` 一组 `InMemoryRepository<T>`，互不影响 |
| .NET 集成测试 | 每个 `ApiFactory` 实例对应独立 InMemory 数据库；`IClassFixture<ApiFactory>` 在同类内共享 |
| E2E 测试 | 每个测试通过 `Date.now() + Math.random()` 生成唯一业务键，避免数据冲突 |
| JWT Token | 集成测试与 E2E 测试均通过 `SemaphoreSlim` / worker scope 缓存，跨测试共享 |

### 6.3 数据清理

| 层级 | 清理策略 |
| --- | --- |
| 单元测试 | `InMemoryRepository<T>` 随测试类实例回收，无需显式清理 |
| 集成测试 | InMemory 数据库随 `ApiFactory` 销毁回收；测试间不主动清理（依赖唯一 Guid 避免冲突） |
| E2E 测试 | 当前不主动清理产生的数据；后续如需可在 `test.afterEach` 中调用 `DELETE /v1/admin/{resource}/{id}` |

---

## 7. CI/CD 集成

### 7.1 流水线阶段

```
┌──────────┐   ┌──────────────┐   ┌──────────────────┐   ┌─────────────────┐
│  build   │ → │  unit + int  │ → │  e2e (Chromium)  │ → │  report upload  │
└──────────┘   └──────────────┘   └──────────────────┘   └─────────────────┘
```

### 7.2 构建阶段

```bash
# 还原 + 编译整个解决方案（含测试项目）
dotnet build LicenseManager.sln -c Release --no-restore
```

> 构建产物供后续 `--no-build` 复用，避免重复编译。

### 7.3 单元 + 集成测试阶段

```bash
# 运行所有 .NET 测试（单元 + 集成），不重新编译
dotnet test --no-build -c Release --logger "trx;LogFileName=dotnet-tests.trx" \
    --collect:"XPlat Code Coverage" \
    --results-directory ./test-results/dotnet
```

| 参数 | 作用 |
| --- | --- |
| `--no-build` | 复用上阶段构建产物 |
| `--logger "trx;..."` | 生成 VS Test Results（trx）报告 |
| `--collect:"XPlat Code Coverage"` | 收集覆盖率（Cobertura 格式） |
| `--results-directory` | 报告输出目录 |

### 7.4 E2E 测试阶段

```bash
# 1. 启动后端 API（后台）
$env:ASPNETCORE_ENVIRONMENT='Development'
dotnet run --project src/LicenseManager.Api/LicenseManager.Api.csproj \
    --no-build -c Release --urls http://localhost:8080 &

# 2. 等待 API 就绪
# 建议轮询 http://localhost:8080/v1/readyz 直到返回 200

# 3. 启动前端 dev server（后台）
cd web/admin
npm run dev &   # http://localhost:5173

# 4. 等待前端就绪后运行 Playwright
npx playwright test

# 5. 收集报告
# HTML 报告：web/admin/playwright-report/index.html
# JUnit 抙告（如需）：在 playwright.config.ts 添加 ['junit', { outputFile: 'test-results/e2e-junit.xml' }]
```

### 7.5 测试报告生成

| 报告类型 | 来源 | 格式 | 路径 |
| --- | --- | --- | `test-results/dotnet/*.trx` |
| .NET 覆盖率 | `dotnet test --collect:"XPlat Code Coverage"` | Cobertura | `test-results/dotnet/**/*.cobertura.xml` |
| E2E 列表报告 | Playwright `reporter: [['list']]` | 控制台 | stdout |
| E2E HTML 报告 | Playwright `reporter: [['html']]` | HTML | `web/admin/playwright-report/index.html` |
| E2E trace | `trace: 'on-first-retry'` | Playwright Trace Viewer | `web/admin/test-results/*.zip` |

### 7.6 CI 环境特殊配置

| 配置项 | CI 值 | 本地默认 |
| --- | --- | --- |
| `forbidOnly` | `true` | `false` |
| `retries` | `2` | `0` |
| `workers` | `1` | 自动（CPU 核数） |
| Playwright 浏览器 | 需 `npx playwright install --with-deps chromium` | 首次运行自动安装 |

---

## 8. 性能测试（可选）

### 8.1 基准测试工具

| 工具 | 适用场景 | 备注 |
| --- | --- | --- |
| `BenchmarkDotNet` | 微基准（如 `LicenseGenerator.GenerateAsync`、`SignatureUtils`） | 当前未引入，建议作为后续改进项 |
| `k6` | API 负载测试（如 `/v1/licenses/activate` 限流验证） | 通过 `k6 run script.js` 运行 |
| `wrk` | 简单 HTTP 压测 | 适合 quick check |

### 8.2 性能基线（建议值）

| 端点 | P50 | P95 | P99 | 备注 |
| --- | --- | --- | --- | --- |
| `POST /v1/auth/login` | < 50 ms | < 150 ms | < 300 ms | 含 SHA256+盐 哈希验证 |
| `GET /v1/admin/products` | < 30 ms | < 80 ms | < 150 ms | 分页 ≤ 20 条 |
| `POST /v1/licenses/activate` | < 80 ms | < 200 ms | < 400 ms | 含签名验证 |
| `POST /v1/admin/licenses`（签发） | < 100 ms | < 250 ms | < 500 ms | 含密钥加载 |

> 当前项目尚未引入自动化性能基线，上述数值作为规划目标，待 `BenchmarkDotNet` 集成后补充。

---

## 9. 附录：常用测试命令速查表

### 9.1 .NET 测试

| 场景 | 命令 |
| --- | --- |
| 构建解决方案 | `dotnet build LicenseManager.sln` |
| 运行所有测试 | `dotnet test` |
| 运行单个测试项目 | `dotnet test tests/LicenseManager.Platform.Tests/LicenseManager.Platform.Tests.csproj` |
| 运行单个测试类 | `dotnet test --filter "FullyQualifiedName~CustomerServiceTests"` |
| 运行单个测试方法 | `dotnet test --filter "FullyQualifiedName~CreateAsync_Returns_CustomerDto"` |
| 不重新编译 | `dotnet test --no-build` |
| 生成 trx 报告 | `dotnet test --logger "trx;LogFileName=tests.trx"` |
| 收集覆盖率 | `dotnet test --collect:"XPlat Code Coverage"` |
| 仅运行集成测试 | `dotnet test tests/LicenseManager.Api.Tests/` |
| VS 中运行 | Test Explorer → Run All |

### 9.2 E2E 测试

| 场景 | 命令（工作目录 `web/admin`） |
| --- | --- |
| 安装 Playwright 浏览器 | `npx playwright install chromium` |
| 运行全部 E2E（无头） | `npm test` |
| 运行全部 E2E（有头） | `npm run test:headed` |
| Playwright UI Mode | `npm run test:ui` |
| 打开 HTML 报告 | `npm run test:report` |
| 运行单个 spec | `npx playwright test e2e/customers.spec.ts` |
| 按名称过滤 | `npx playwright test -g "login flow"` |
| 仅 chromium project | `npx playwright test --project=chromium` |
| 失败重试 3 次 | `npx playwright test --retries=3` |
| 单 worker 串行 | `npx playwright test --workers=1` |
| 调试模式 | `npx playwright test --debug` |
| 生成 trace.zip | 失败自动生成（`trace: 'on-first-retry'`） |
| 查看 trace | `npx playwright show-trace test-results/xxx.zip` |
| 查看 codegen | `npx playwright codegen http://localhost:5173` |

### 9.3 启动本地环境

| 场景 | 命令 |
| --- | --- |
| 启动后端 API | `$env:ASPNETCORE_ENVIRONMENT='Development'; dotnet run --project src/LicenseManager.Api/LicenseManager.Api.csproj --urls http://localhost:8080` |
| 启动前端 dev server | `cd web/admin; npm run dev` |
| 健康检查 | `curl http://localhost:8080/v1/readyz` |
| 默认管理员账号 | `admin@licensemanager.cn` / `Admin@123456` |

### 9.4 关键文件路径速查

| 文件 | 路径 |
| --- | --- |
| 集成测试基类 | `tests/LicenseManager.Api.Tests/ApiTestsBase.cs` |
| WebApplicationFactory | `tests/LicenseManager.Api.Tests/ApiFactory.cs` |
| InMemory DbContext | `tests/LicenseManager.Api.Tests/TestDbContext.cs` |
| Playwright 配置 | `web/admin/playwright.config.ts` |
| E2E Fixture | `web/admin/e2e/fixtures.ts` |
| 页面清单（smoke） | `web/admin/e2e/list-pages.spec.ts` |
| API 覆盖清单 | `web/admin/e2e/api-coverage.spec.ts` |
| CRUD 测试 | `web/admin/e2e/crud-operations.spec.ts` |
| 登录流程测试 | `web/admin/e2e/login-flow.spec.ts` |

### 9.5 SDK 测试

| 场景 | 命令（工作目录见括号） |
| --- | --- |
| C# SDK 契约测试 | `dotnet run --project sdk/csharp-tests/ContractTests`（仓库根） |
| Go SDK 测试 | `go test ./...`（`sdk/go`） |
| Node.js SDK 测试 | `npm test`（`sdk/nodejs`） |
| Python SDK 测试 | `pytest`（`sdk/python`） |
| Rust SDK 测试 | `cargo test`（`sdk/rust`） |

## 10. 本版测试基线（2026-10）

- 全量 **2357** 测试通过：Core 195 + Platform 772 + Server 53 + Infrastructure 234 + Api 1103。
- 短信验证码登录：`SmsCodeServiceTests` 10 例（归一化互通、一次性消费、冷却 / 频控、错误尝试作废、失败无残留）、`SmsAuthApiTests` 6 例（防枚举受理、真实验证码登录、重放 401、冷却 429、错误码 401）；admin / portal 登录页短信 E2E 各 4 例（route mock：Tab 切换、手机号校验、冷却倒计时、完整登录）。
- SCIM 2.0：`ScimApiTests` 16 例（鉴权 fail-closed、ServiceProviderConfig、CRUD 全生命周期、filter 查询与不支持过滤、`count=0`、重复 409、PATCH 三形态、软禁用与管理端交叉验证、软删除）。
- 特性开关：`FeaturesConfigApiTests` 4 例（匿名访问与默认值、管理端列表详情、覆盖后 features 端点反映新值、目录外键 404）。
- E2E 约定沿用：admin 需 API 8080 + vite 5173；portal 需 API 8080 + vite 5174（fixtures seed 走真实 API）。短信登录 E2E 用 `page.route` mock 短信端点（DevSmsSender 仅日志输出验证码，E2E 不可读）。

---

*文档结束*
