注入IConfiguration的 Metalama切面单元测试(XUnit)

编程语言 2026-07-08

我正在开发一个库,其中包含供其他项目使用的标准化日志记录和辅助切面。其中一个是 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 以测试逻辑。

到目前为止,我尝试过的做法:

  1. 不能直接传递 IConfiguration,因为常规构造函数只有一个参数,而用于 IConfiguration 的构造在构建后才存在。
  2. 我尝试直接创建一个服务提供程序,然后将其分配给 ServiceProviderProvider.ServiceProvider,例如:

``` var configurationBuilder = new ConfigurationBuilder(); configurationBuilder.AddInMemoryCollection(new Dictionary { ["Logging:Timing:DefaultFormat"] = @"dd.hh\:mm\:ss", });

IConfiguration config = configurationBuilder.Build(); var sc = new ServiceCollection(); sc.AddSingleton(_ => config);

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();
    }
}

但这也带来了一些问题,包括:

  1. GlobalServiceProvider 属性无法按原样使用,因为它是 CompileTime,但该类被视为 RunTime。添加该属性只会导致关于执行作用域不匹配的不同错误信息。
  2. 去掉 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 为切面提供不同的应用设置?不胜感激。

解决方案

你选错了测试类型。测试切面有两种方式,再加上你不小心也在做的第三种:

  1. 代码生成 / 快照测试 — 通过将切面生成的代码与基线文件进行比对,来验证切面生成了什么代码。
  2. 运行时测试 — 在运行时验证变换后的代码做了些什么。这些是普通的xUnit测试:切面会织入到你的测试程序集,然后你只需 new 该类并调用方法。

你实际做的是 测试编译时辅助代码,并使用 Metalama.Testing.UnitTestingUnitTestClassCreateTestContextAdditionalServiceCollectionServiceProviderProvider…)来实现。这是一种对非切面编译时逻辑进行单元测试的边缘技术——它与测试切面在运行时的行为无关。这也是你遇到一切困难的原因: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));
}

因此测试只是:构建一个包含你的 IConfigurationILogger 的服务提供程序,让它来构造这个类。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、SerilogMicrosoft.Extensions.Configuration、xUnit)进行了端到端验证,两个测试都通过。

两点提示:

  • 注入伪实现/内存中实现的服务;在测试之间改变设置只需构建一个不同的 IConfiguration。这就是全部技巧。
  • 属性本身提供的格式([TimeMethod("G")])是在编译时解析并写死为字面量,因此只有基于配置的路径在运行时才有意义去测试。

将这些运行时测试用于行为测试,当你需要调试“某个具体切面实例是如何被织入”的过程时,再添加Metalama的快照测试——两者是互补的。

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章