测试与一致性套件
本页讲怎样用测试基座在内存里装配应用、测试特性、中间件与操作,怎样让 Provider 与插件跑一致性套件,以及框架仓库的架构测试与门禁,适用于应用开发者和写 Provider、插件、框架扩展的人。完整 API 见 Testing 测试基座 与 Conformance 一致性套件。
测试项目
框架仓库的测试用 xUnit v3,由 Microsoft.Testing.Platform(MTP)驱动。应用的测试项目按同样的方式配置:仓库根的 global.json 选择 MTP 运行器,测试项目引用 xunit.v3 与 XiHan.Framework.Testing。
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
</Project>dotnet add package Microsoft.NET.Test.Sdk
dotnet add package xunit.v3
dotnet add package xunit.runner.visualstudio
dotnet add package Microsoft.Testing.Extensions.CodeCoverage
dotnet add package XiHan.Framework.Testing --prerelease.NET 10 的 MTP 模式下,dotnet test 用 --solution 或 --project 指定目标(在仓库根执行):
# 整个解决方案
dotnet test --solution framework/XiHan.Framework.slnx --configuration Release --no-build
# 单个测试项目
dotnet test --project framework/test/XiHan.Framework.Caching.InMemory.Tests/XiHan.Framework.Caching.InMemory.Tests.csproj --configuration Release
# 与 CI 相同:采集 Cobertura 覆盖率
dotnet test --solution framework/XiHan.Framework.slnx --configuration Release --no-build -p:TestingPlatformCommandLineArguments="--coverage --coverage-output-format cobertura"MTP 模式不要退回 VSTest
global.json 选了 MTP 之后,dotnet test 走 VSTest 目标会被 .NET 10 SDK 拒绝。不要删除 global.json 的 test.runner,也不要去掉 --solution / --project 来绕过运行错误。
测试宿主
TestHost.StartAsync(configure, configuration, services) 在内存里装配并启动一个应用:自建服务集合(ValidateScopes = true,在根容器解析 Scoped 服务直接报错)与内存配置,调用 AppBuilder 上的 UseFeature 后启动全部特性;没有 HTTP,也不经过任何协议适配器。启动失败时先释放容器再抛出,DisposeAsync 按与启动相反的顺序停止特性,重复释放是空操作。
宿主上的调用都在新的服务作用域里、以完整管道执行,与真实宿主里一请求一作用域的语义一致:
| 方法 | 用途 |
|---|---|
InvokeAsync(handler, deadline, baggage) | 以一个 PipelineStep 作为 Handle 阶段,返回调用结果;用来测中间件 |
InvokeOperationAsync<TRequest, TResponse>(operationId, request, baggage) | 类型化调用操作,经完整管道 |
InvokeOperationRawAsync(OperationRequest, baggage) | 以 XGI 消息(JSON 载荷)调用操作,返回 OperationResponse |
StubFeature 是可注入 OnConfigure / OnStart / OnStop 行为、按顺序记录 configure / start / stop 事件的特性桩;TestManifests.Create(id, provides, requires, role, kind, bindings) 用 id@version 文本构造清单;TestInvocationContext.Create(...) 给不需要宿主的单元测试一个独立的调用上下文。
SQLite 文件库
用例用临时的 SQLite 文件库时,宿主停止后先清这个库的连接池再删文件:using var connection = new SqliteConnection(连接串); SqliteConnection.ClearPool(connection);——连接池按连接串分组,用例自己在连接串后追加了参数(如 Default Timeout)的,那个连接串也清一次。不要在用例里调 SqliteConnection.ClearAllPools():它清的是整个进程的连接池,xUnit 并行跑的别的用例此刻正在打开的连接会被 Microsoft.Data.Sqlite 当作泄漏的连接回收并关掉句柄(它在池锁之外先置活动标记、再记外层连接,清池恰好落在两步之间),那个用例随即以 ObjectDisposedException(SQLitePCL.sqlite3)失败,SqlSugar 建表时报成「需要系统表权限」;负载越高越容易碰到。给连接串加 Pooling=False 也能避开,但每次开连接都要重新打开文件,并行负载下实测慢一倍多。用 SqlSugar 的宿主停止后,清池之前先 GC.Collect(); GC.WaitForPendingFinalizers();:SqlSugar 在 SQLite 上读数据后不释放命令(已报上游 DotNetNext/SqlSugar#1461),事务里用过的命令被回收后,它的预编译语句要等终结器才 finalize,在此之前连接虽已关闭,库仍占着文件,删除偶发「文件被另一进程使用」。框架自己的数据库测试项目都按这两条做。
测试里 Http 活动、CloudEvents Webhook 或 S3 命名实例要连本机的服务时,配置 XiHan:Security:Outbound:AllowLoopback=true,见出站地址策略。用 ConfigurePrimaryHttpMessageHandler 换主处理器的替身不经过策略,不用配。
测一个中间件
中间件在 InvokeAsync 里决定是否调用下一步;短路时把失败结果写进上下文,不进入下游。下面的中间件要求调用方带关联 ID:
using XiHan.Framework.Kernel.Pipeline;
using XiHan.Framework.Primitives;
namespace Contoso.Correlation;
public static class CorrelationMessages
{
public const string Missing = "contoso.correlation.missing";
}
public sealed class RequireCorrelationMiddleware : IPipelineMiddleware
{
public const string ErrorCode = "CONTOSO_VALIDATION_001";
public ValueTask InvokeAsync(InvocationContext context, PipelineStep nextStep)
{
if (!context.TryGetBaggage(BaggageKeys.Correlation, out _))
{
// 短路:写入失败结果,不调用下游
context.SetOutcome(Error.Validation(ErrorCode, CorrelationMessages.Missing));
return ValueTask.CompletedTask;
}
return nextStep(context);
}
}用特性桩把它挂进管道,再分别以缺少和带有 Baggage 的调用验证:
using Contoso.Correlation;
using XiHan.Framework.Kernel.Pipeline;
using XiHan.Framework.Primitives;
using XiHan.Framework.Testing;
using Xunit;
public sealed class RequireCorrelationMiddlewareTests
{
private static StubFeature Feature() => new(TestManifests.Create("xihan.contoso.correlation", requires: ["kernel.hosting@1"]))
{
OnConfigure = static context => context.AddMiddleware<RequireCorrelationMiddleware>(PipelineStage.Validate),
};
[Fact]
public async Task MissingCorrelation_ShortCircuits()
{
await using var host = await TestHost.StartAsync(static b => b.UseFeature(Feature()));
var reached = false;
var result = await host.InvokeAsync(context =>
{
reached = true;
context.SetOutcome(Result.Success());
return ValueTask.CompletedTask;
});
Assert.Equal(RequireCorrelationMiddleware.ErrorCode, result.Error?.Code);
Assert.False(reached);
}
[Fact]
public async Task Correlation_PassesThrough()
{
await using var host = await TestHost.StartAsync(static b => b.UseFeature(Feature()));
var result = await host.InvokeAsync(
static context =>
{
context.SetOutcome(Result.Success());
return ValueTask.CompletedTask;
},
baggage: new Dictionary<string, string> { [BaggageKeys.Correlation] = "c-1" });
Assert.True(result.IsSuccess);
}
}deadline 参数可以验证截止时间:传入已过去的时刻,结果是 KERNEL_TIMEOUT_001。
测一个操作
手写登记一个操作(不用生成器时,序列化元数据来自源生成的 JsonSerializerContext),再以类型化方式调用:
using System.Text.Json.Serialization;
using XiHan.Framework.Kernel.Operations;
using XiHan.Framework.Primitives;
using XiHan.Framework.Testing;
using Xunit;
public sealed record EchoRequest(string Text);
public sealed record EchoResponse(string Text, string? Tenant);
[JsonSerializable(typeof(EchoRequest))]
[JsonSerializable(typeof(EchoResponse))]
internal sealed partial class EchoJsonContext : JsonSerializerContext;
public sealed class EchoOperation : IOperation<EchoRequest, EchoResponse>
{
public ValueTask<Result<EchoResponse>> HandleAsync(EchoRequest request, InvocationContext context)
{
var tenant = context.TryGetBaggage(BaggageKeys.Tenant, out var value) ? value : null;
Result<EchoResponse> result = new EchoResponse(request.Text, tenant);
return ValueTask.FromResult(result);
}
}
public sealed class EchoOperationTests
{
[Fact]
public async Task Echo_ReturnsTextAndTenant()
{
var feature = new StubFeature(TestManifests.Create("xihan.contoso.echo", requires: ["kernel.hosting@1"]))
{
OnConfigure = static context => context.AddOperation<EchoOperation, EchoRequest, EchoResponse>(
new OperationContract("echoes.create"), EchoJsonContext.Default.EchoRequest, EchoJsonContext.Default.EchoResponse),
};
await using var host = await TestHost.StartAsync(b => b.UseFeature(feature));
var result = await host.InvokeOperationAsync<EchoRequest, EchoResponse>(
"echoes.create", new EchoRequest("hi"), new Dictionary<string, string> { [BaggageKeys.Tenant] = "acme" });
var response = result.ValueOrThrow();
Assert.Equal("hi", response.Text);
Assert.Equal("acme", response.Tenant);
}
}用 [Operation] 与 Kernel.Generators 的项目,在特性的 Configure 里调用 context.AddGeneratedOperations(),测试照样经 InvokeOperationAsync 调用(操作契约)。要测完整的子系统组合,直接在 TestHost.StartAsync 里 UseStandardProfile() 再加被测特性,配置以键值对传入,如 new KeyValuePair<string, string?>("XiHan:Kernel:StartTimeoutSeconds", "5")。
一致性套件
每个子系统抽象都配一个 <领域>.Conformance 包:一组 xUnit 抽象测试类,Provider 的测试项目继承基类、实现工厂方法,套件里的全部用例随之执行。它是「换 Provider 只改包引用与配置」的保证:框架的每个默认实现与 Provider 都在仓库里跑同一套用例,自己写的 Provider 也应当继承它(扩展与二次开发)。
- 新建测试项目,引用被测 Provider、对应的
<领域>.Conformance包与xunit.v3(套件包本身只引用 xUnit 的核心与断言包,不带运行器)。 - 继承基类,实现工厂方法;按需覆写时限、配置等虚成员。
- 运行
dotnet test --project <测试项目>。
Caching.InMemory 的测试项目就这样跑完整套缓存用例:
using XiHan.Framework.Caching.Abstractions.Stores;
using XiHan.Framework.Caching.Conformance;
using XiHan.Framework.Caching.InMemory;
public sealed class InMemoryCacheStoreConformanceTests : CacheStoreConformance
{
protected override ICacheStore CreateStore() => new InMemoryCacheStore(new InMemoryCachingOptions(), TimeProvider.System);
}以特性为入口的套件(持久化、消息)实现的是 CreateFeature(),套件自己用 TestHost 装配并走完整管道:
using XiHan.Framework.Kernel.Hosting;
using XiHan.Framework.Messaging.Conformance;
using XiHan.Framework.Messaging.InMemory;
public sealed class InMemoryMessageBrokerConformanceTests : MessageBrokerConformance
{
protected override IFeature CreateFeature() => new InMemoryMessagingFeature();
}各套件的基类、工厂方法与用例覆盖面见 Conformance 一致性套件。依赖真实服务的套件(Redis、RabbitMQ、Kafka、PostgreSQL、S3、Nacos 等)只在设置了对应环境变量(XIHAN_TEST_REDIS、XIHAN_TEST_RABBITMQ、XIHAN_TEST_KAFKA、XIHAN_TEST_POSTGRES、XIHAN_TEST_SQLSERVER、XIHAN_TEST_MYSQL、XIHAN_TEST_ORACLE、XIHAN_TEST_S3、XIHAN_TEST_NACOS、XIHAN_TEST_QDRANT 等)时运行,没有就跳过;CI 的主作业以服务容器提供 Redis、PostgreSQL、SQL Server、MySQL、RabbitMQ 与 Qdrant;Oracle 镜像大、冷启动慢,单独一个与主作业并行的 oracle 作业起 gvenzl/oracle-free:23-slim-faststart,只构建并测试 Persistence.SqlSugar.Tests(ADR-0087)。
Plugins.Conformance 与 xihan-conformance
插件协议有两份黑盒套件,任何语言写的插件都用它们验证:.NET 的 XiHan.Framework.Plugins.Conformance,和规范仓库 framework-spec 里语言中立的 Go 运行器 xihan-conformance。两者都以宿主身份启动插件、按 spec/plugin/ 的规范核对,用例相同。
.NET 测试项目以插件的构建产物目录继承 PluginProtocolConformance(进程外)或 WasmPluginConformance(Wasm):
using XiHan.Framework.Plugins.Conformance;
public sealed class EchoPluginConformanceTests : PluginProtocolConformance
{
protected override string PluginDirectory => Path.Combine(AppContext.BaseDirectory, "plugins", "echo");
// 故意崩溃的操作不调用
protected override IReadOnlySet<string> ExcludedOperations => new HashSet<string>(StringComparer.Ordinal) { "echoes.crash" };
protected override IReadOnlyDictionary<string, string> SampleRequests => new Dictionary<string, string>(StringComparer.Ordinal) { ["echoes.create"] = """{"text":"conformance"}""" };
}跑 Wasm 套件的测试项目在 Windows 上要设 <CETCompat>false</CETCompat>:套件会故意触发陷阱,影子栈强制时整个测试进程会 fail-fast(插件)。
非 .NET 的插件用 Go 运行器,不需要 .NET 环境:
go install github.com/xihanfun/framework-spec/conformance/cmd/xihan-conformance@latest
xihan-conformance plugin --dir ./bin --exclude echoes.crash --sample 'echoes.create={"text":"hi"}'
xihan-conformance plugin --dir ./bin --json
xihan-conformance vectors| 选项 | 含义 |
|---|---|
--dir | 插件目录(含 xihan.manifest.json 与 entry 指向的可执行文件),必填 |
--timeout | 每一步的截止时间,缺省 15s |
--config | Configure 下发的配置节 JSON,缺省 {} |
--sample op=json | 某个操作的请求样例,可重复;没有样例的操作以 {} 调用 |
--exclude op | 不调用的操作,可重复 |
--case name | 只跑指定用例,可重复 |
--json | 结果输出为 JSON |
--quiet | 不转发插件的 stderr |
用例:handshake(握手行、版本协商、原语层版本、清单一致、声明在清单范围内)、refusal(没有共同版本时以 FailedPrecondition 拒绝)、lifecycle(Configure / Start / Health / WatchHealth / Stop,进程在宽限内退出)、unknown-operation(Unimplemented)、declared-operations(每个声明的操作都返回 status)、event(订阅投递得到确认,没有订阅时跳过)、stdin(关闭 stdin 后进程自行退出)。每个用例单独启动一次插件进程。退出码:0 全部通过(含跳过),1 有用例失败,2 参数或目录错误。xihan-conformance vectors 自检 conformance/vectors/ 下的数据向量。
两份套件都不提供宿主回调服务,Host.Invoke / Host.Publish 的令牌与权限、崩溃重启由宿主实现的测试覆盖(Plugins.OutOfProcess.Tests、Plugins.Wasm.Tests)。
架构测试与门禁
这一节面向向框架仓库提交扩展的人:framework/ 下的每个正式包都要通过下列门禁,CI 全部是必过项,不放宽、不绕过、不加 NoWarn。
| 门禁 | 位置 | 检查什么 |
|---|---|---|
| 编译 | framework/Directory.Build.props | TreatWarningsAsErrors、Nullable、AnalysisLevel=latest-recommended、EnforceCodeStyleInBuild(命名 IDE1006 与未使用 using IDE0005 在构建期即错误)、NuGet 高危 / 严重漏洞(NU1903 / NU1904)中断构建 |
| 公开面与包 | framework/src/Directory.Build.props | PublicAPI.Shipped.txt / PublicAPI.Unshipped.txt 必须与公开面一致、包验证、AOT 分析(IsAotCompatible 缺省为真) |
| 分析器 | Kernel.Analyzers | XH0001–XH0005:[ApiLevel]、XiHan 前缀、禁用词、Internal API、Ring 0/1 零反射(Kernel.Analyzers) |
| 架构测试 | framework/test/XiHan.Framework.Architecture.Tests | 见下表 |
| 冒烟 | CI | dotnet pack 触发包验证;samples/SimpleApp 以 Native AOT 发布 |
架构测试扫描源码目录与项目文件,按包 ID 推出环层再判定:
| 测试 | 规则 |
|---|---|
ProjectReferences_PointToLowerRingsOrSameRingAbstractions | 只能引用更低环层的包;同环只能引用对方的 .Abstractions;工具链与组合包例外 |
Primitives_HasNoPackageOrProjectReferences | 原语层没有任何包或项目引用 |
KernelAndAbstractions_ReferenceOnlyMicrosoftExtensionsAbstractions | Ring 0/1 与抽象包只引用 Microsoft.Extensions.* 抽象包或白名单里的包 |
PublicSignatures_DoNotLeakThirdPartyTypes | 公开签名只出现 BCL、Microsoft.Extensions 与框架自身的类型 |
EverySourceProject_IsReferencedByArchitectureTests | 新增的正式包要加进架构测试项目的引用 |
PackageIds_FollowGrammar、Vocabulary_IsWellFormed | 包 ID 由术语表与 spec/conventions/package-ids.md 的文法推出 |
Namespaces_MatchFolders、Namespaces_EqualPackageIdWithAtMostOneSegment | 命名空间等于包 ID,子命名空间最多一层且与目录同名 |
PublicTypes_DoNotCarryXiHanPrefix | 公开类型名不带 XiHan 前缀 |
SourceFiles_DeclareOneTopLevelType_NamedLikeTheFile | 一个文件一个顶层类型,文件名与类型名一致(泛型加 OfT) |
SourceFiles_StartWithCopyrightHeader | 版权头 |
XmlSummaries_UseTheThreeLineForm、XmlSummaries_AreOneConciseSentence | <summary> 三行写法、一句话,细节放 <remarks> |
PrimitivesAndKernel_DoNotUseReflection | Ring 0/1 源码不使用反射 |
EverySourceProject_HasReadmeAndPublicApiFiles | 每个正式包都有 README.md 与两个 PublicAPI 文件 |
EveryLocalizedPackage_HasOneFilePerLocale_WithConsistentKeys 等四项 | 10 种区域的语言文件齐全、占位符一致、按约定嵌入、消息键常量与 en-US.json 一一对应、错误调用点只用常量(RFC-0010) |
DeclaredHealthItems_AreImplementedInTheSamePackage、PackagesRegisteringContributors_DeclareHealthItems | 清单 contributes.health 的条目由同一个包实现,登记了 IHealthContributor 的包必须声明条目(插件宿主例外);samples/ 下的示例项目同样受检 |
SourceProjects_DoNotCallSingleItemMethodsInsideLoops | 不在循环里调用仓储、缓存存储、消息发布的单条方法,改用批量方法 |
DatabaseRecords_DeclareEveryStringColumnLength | 数据库记录的字符串列都声明 [StringLength(n)] 或 [LongText] |
相关
- Testing 测试基座、Conformance 一致性套件
- Kernel.Analyzers、Kernel.Generators
- 扩展与二次开发、插件、执行管道、操作契约
- 规范:RFC-0017《进程外插件协议》、RFC-0018《Wasm 插件 ABI》;
spec/conformance/README.md
