注入IConfiguration的 Metalama切面单元测试(XUnit)
我正在开发一个库,其中包含供其他项目使用的标准化日志记录和辅助切面。其中一个是 TimeMethodAttribute,它继承自 OverrideMethodAspect,用于记录方法执行时间。
对于输出,它要么使用默认格式(即 c),要么可以在属性构造函数中配置,或者可以从应用设置中读取。
以下是该切面的精简版代码:
[AttributeUsage(AttributeTargets.Method)]
public class TimeMethodAttribute : OverrideMethodAspect
{
private readonly string _timespanFormat;
private readonly bool _formatSetInCtor;
[IntroduceDependency]
private readonly ILogger _logger; // Assigned at runtime
[IntroduceDependency]
private readonly IConfiguration _configuration;
public override dynamic? OverrideMethod()
{
var resolvedFormat = string.Empty;
if (string.IsNullOrWhiteSpace(_timespanFormat))
{
var formatFromConfig = AspectHelper.GetTimeSpanFormatFromConfig(_configuration);
resolvedFormat = AspectHelper.IsValidTimeSpanFormat(formatFromConfig, out _)
? formatFromConfig
: AspectHelper.DefaultTimeSpanFormatString;
}
var sw = new Stopwatch();
sw.Start();
var result = meta.Proceed();
sw.Stop();
_logger.Debug("Method {$MethodName} Execution Time {$ExecutionTime}", meta.Target.Method.Name, sw.Elapsed.ToString(resolvedFormat));
return result;
}
}
目前,我正在研究如何对逻辑中的配置部分进行单元测试。为此,我创建了一个最小的类:
public class TestingClass
{
private readonly ILogger _logger;
public TestingClass(ILogger logger)
{
ArgumentNullException.ThrowIfNull(logger);
_logger = logger;
}
[TimeMethod]
public void TimeMethod() => Task.Delay(100).Wait();
}
我遇到的问题是,如何用不同的值来设置 IConfiguration 以测试逻辑。
到目前为止,我尝试过的做法:
- 不能直接传递
IConfiguration,因为常规构造函数只有一个参数,而用于IConfiguration的构造在构建后才存在。 - 我尝试直接创建一个服务提供程序,然后将其分配给
ServiceProviderProvider.ServiceProvider,例如:
```
var configurationBuilder = new ConfigurationBuilder();
configurationBuilder.AddInMemoryCollection(new Dictionary
IConfiguration config = configurationBuilder.Build();
var sc = new ServiceCollection();
sc.AddSingleton
var sp = sc.BuildServiceProvider();
ServiceProviderProvider.ServiceProvider = () => sp; ```
但切面中的 IConfiguration 仍然为null
我尝试创建一个新类,继承自 UnitTestClass,并实现一个自定义上下文(基于他们GitHub仓库中的示例):
public class TimingTestContextClass : UnitTestClass
{
private readonly LoggingFixture _fixture;
protected TimingTestContextClass(LoggingFixture fixture, Xunit.Abstractions.ITestOutputHelper? logger = null) : base(
logger)
{
_fixture = fixture;
}
[Fact]
public void TestTimingConfig()
{
var configurationBuilder = new ConfigurationBuilder();
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Logging:Timing:DefaultFormat"] = @"dd\.hh\:mm\:ss",
});
var configuration = configurationBuilder.Build();
using (var context = CreateTimingContext(configuration))
{
_fixture.ServiceMethodLevel.TimeMethodWithDefaultFormatting();
_fixture.CapturedLogEvents.Should().HaveCount(1);
var o = 0;
}
}
private protected TimingTestContext CreateTimingContext(IConfiguration configuration,
[CallerFilePath] string? callerFile = null, [CallerMemberName] string? callerMemberName = null)
{
var syncProvider = new TestSynchronizationProvider(this.TestOutput);
var additionalServices = new AdditionalServiceCollection();
additionalServices.AddUntypedGlobalService(typeof(IConfiguration), configuration);
var testContext = this.CreateTestContext(additionalServices, callerFile, callerMemberName);
return new TimingTestContext(testContext, syncProvider);
}
}
public sealed class TimingTestContext : IDisposable
{
private readonly TestContext _testContext;
public TestSynchronizationProvider SyncProvider { get; }
public IServiceProvider ServiceProvider { get; }
//public GlobalServiceProvider Global { get; }
public CancellationToken CancellationToken => this._testContext.CancellationToken;
internal TimingTestContext(TestContext testContext, TestSynchronizationProvider syncProvider)
{
this._testContext = testContext;
this.SyncProvider = syncProvider;
//this.Global = testContext.ServiceProvider.Global;
this.ServiceProvider = testContext.ServiceProvider.Global.Underlying; //this.Global.Underlying;
}
public void Dispose()
{
// Always release all sync points to avoid deadlocks if the test fails.
this.SyncProvider.ReleaseAll();
this._testContext.Dispose();
}
}
但这也带来了一些问题,包括:
GlobalServiceProvider属性无法按原样使用,因为它是CompileTime,但该类被视为RunTime。添加该属性只会导致关于执行作用域不匹配的不同错误信息。- 去掉
GlobalServiceProvider属性后,所有代码都能编译,单元测试也能识别,但它不会运行。无论是常规运行还是调试模式,一切都在运行,唯独这一个测试不跑;我得到如下输出:
Building Test Projects Executing test method: Company.Logging.Core.Tests.Tests.TimingTestContextClass.TestTimingConfig ========== Starting test run ========== ========== Test run finished: 0 Tests (0 Passed, 0 Failed, 0 Skipped) run in 1.3 sec ==========
最后,我尝试改写Metalama提供的用于测试编译时代码的示例(可在这里找到:https://doc.metalama.net/conceptual/aspects/testing/compile-time-testing),但我也无法让它工作。编译会因大量缺失的引用而失败,例如 TimeMethodAttribute、Serilog等。
我一直在浏览他们的文档,并向GitHub Copilot求助,但始终找不到相关答案。
请问有没有人能给出一个示例,说明如何对这个切面及所提供的测试类进行单元测试,通过 IConfiguration 为切面提供不同的应用设置?不胜感激。
解决方案
你选错了测试类型。测试切面有两种方式,再加上你不小心也在做的第三种:
- 代码生成 / 快照测试 — 通过将切面生成的代码与基线文件进行比对,来验证切面生成了什么代码。
- 运行时测试 — 在运行时验证变换后的代码做了些什么。这些是普通的xUnit测试:切面会织入到你的测试程序集,然后你只需
new该类并调用方法。
你实际做的是 测试编译时辅助代码,并使用 Metalama.Testing.UnitTesting(UnitTestClass、CreateTestContext、AdditionalServiceCollection、ServiceProviderProvider…)来实现。这是一种对非切面编译时逻辑进行单元测试的边缘技术——它与测试切面在运行时的行为无关。这也是你遇到一切困难的原因:GlobalServiceProvider 是 [CompileTime](作用域不匹配),并且该基类使用基于文件的自定义运行器,因此你的 [Fact] 虽然被发现却没有测试通过。
对于你的 IConfiguration 逻辑,你需要的是“#2”,完全不需要那些机制。
为什么 IConfiguration 为null
在默认的依赖注入框架下,[IntroduceDependency] 使用的是构造函数注入——依赖成为构造函数的参数,因此从不调用静态服务定位器。ServiceProviderProvider.ServiceProvider 只有在你选择加入 Metalama.Extensions.DependencyInjection.ServiceLocator 框架时才会起作用。默认情况下Metalama会生成:
public SampleService(IConfiguration? configuration = null, ILogger? logger = null)
{
this._configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
因此测试只是:构建一个包含你的 IConfiguration 和 ILogger 的服务提供程序,让它来构造这个类。ActivatorUtilities.CreateInstance<T> 就是这么做的。
可工作的测试
在你的测试项目中对目标应用切面(它必须引用 Metalama.Framework,以便在此处进行织入):
public class SampleService
{
[TimeMethod] public void DoWork() => Thread.Sleep(20);
}
一个简易的Serilog写入点用以捕捉事件(或使用 Serilog.Sinks.InMemory 包):
public sealed class InMemorySink : ILogEventSink
{
private readonly ConcurrentQueue<LogEvent> _events = new();
public IReadOnlyList<LogEvent> Events => _events.ToArray();
public void Emit(LogEvent logEvent) => _events.Enqueue(logEvent);
}
测试——只需通过构建不同的 IConfiguration 来改变设置即可:
private static (SampleService Service, InMemorySink Sink) CreateService(
IDictionary<string, string?> settings)
{
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(settings)
.Build();
var sink = new InMemorySink();
ILogger logger = new LoggerConfiguration()
.MinimumLevel.Verbose()
.WriteTo.Sink(sink)
.CreateLogger();
var provider = new ServiceCollection()
.AddSingleton(configuration)
.AddSingleton(logger)
.BuildServiceProvider();
// Resolves the ILogger/IConfiguration constructor params Metalama introduced.
var service = ActivatorUtilities.CreateInstance<SampleService>(provider);
return (service, sink);
}
[Fact]
public void UsesFormatFromConfiguration()
{
var (service, sink) = CreateService(new Dictionary<string, string?>
{
["Logging:Timing:DefaultFormat"] = @"dd\.hh\:mm\:ss",
});
service.DoWork();
var e = Assert.Single(sink.Events);
var executionTime = e.Properties["ExecutionTime"].ToString().Trim('"');
Assert.Matches(@"^\d{2}\.\d{2}:\d{2}:\d{2}$", executionTime);
}
[Fact]
public void FallsBackToDefaultWhenConfigMissing()
{
var (service, sink) = CreateService(new Dictionary<string, string?>());
service.DoWork();
var e = Assert.Single(sink.Events);
var executionTime = e.Properties["ExecutionTime"].ToString().Trim('"');
Assert.Matches(@"^\d{2}:\d{2}:\d{2}(\.\d+)?$", executionTime); // "c" format
}
我在.NET 8上对照公开的NuGet包(Metalama.Framework / Metalama.Extensions.DependencyInjection 2026.1、Serilog、Microsoft.Extensions.Configuration、xUnit)进行了端到端验证,两个测试都通过。
两点提示:
- 注入伪实现/内存中实现的服务;在测试之间改变设置只需构建一个不同的
IConfiguration。这就是全部技巧。 - 属性本身提供的格式(
[TimeMethod("G")])是在编译时解析并写死为字面量,因此只有基于配置的路径在运行时才有意义去测试。
将这些运行时测试用于行为测试,当你需要调试“某个具体切面实例是如何被织入”的过程时,再添加Metalama的快照测试——两者是互补的。