为 Microsoft.Testing.Platform (MTP) 构建扩展组件。

本文介绍 MTP 超出测试框架本身的扩展点。 有关测试框架的创建,请参阅 生成测试框架。

有关完整的扩展点摘要和进程内/进程外概念,请参阅 “创建自定义扩展”。

扩展点

测试平台提供额外的可扩展性点,让你可以自定义平台和测试框架的行为。 这些扩展性点是可选的,可用于增强测试体验。

Tip

本文中显示的每个扩展都包含一个手动注册代码段(例如, builder.TestHost.AddDataConsumer(...))。 如果你以 NuGet 包的形式发布扩展,可以通过公开一个 TestingPlatformBuilderHook 和一个小的 MSBuild props 文件,让使用者跳过手动调用。 然后,自动生成的入口点会自动调用你的钩子。 有关详情,请参阅使用TestingPlatformBuilderHook自动注册您的扩展程序。

ICommandLineOptionsProvider 扩展程序

注释

在扩展此 API 时,自定义扩展将同时存在于测试主机进程内外。

正如体系结构部分中所述,初始步骤包括创建 ITestApplicationBuilder 以注册测试框架和扩展。

var builder = await TestApplication.CreateBuilderAsync(args);

CreateBuilderAsync 方法接受名为 string[] 的字符串 (args) 数组。 这些参数可用于向测试平台的所有组件(包括内置组件、测试框架和扩展)传递命令行选项,以便自定义它们的行为。

通常,传递的参数是标准 Main(string[] args) 方法中接收到的参数。 然而,如果主机环境不同,则可以提供任何参数列表。

参数“必须带有前缀”并包含双破折号 --。 例如,--filter。

如果测试框架或扩展点等组件希望提供自定义命令行选项,可以通过实现 ICommandLineOptionsProvider 接口来实现。 然后,可以通过 ITestApplicationBuilder 属性的注册工厂将此实现注册到 CommandLine 中,如下所示:

builder.CommandLine.AddProvider(
    static () => new CustomCommandLineOptions());

在所提供的示例中,CustomCommandLineOptions 是 ICommandLineOptionsProvider 接口的实现,该接口包括以下成员和数据类型:

public interface ICommandLineOptionsProvider : IExtension
{
    IReadOnlyCollection<CommandLineOption> GetCommandLineOptions();

    Task<ValidationResult> ValidateOptionArgumentsAsync(
        CommandLineOption commandOption,
        string[] arguments);

    Task<ValidationResult> ValidateCommandLineOptionsAsync(
        ICommandLineOptions commandLineOptions);
}

public sealed class CommandLineOption
{
    public string Name { get; }
    public string Description { get; }
    public ArgumentArity Arity { get; }
    public bool IsHidden { get; }

    // ...
}

public interface ICommandLineOptions
{
    bool IsOptionSet(string optionName);

    bool TryGetOptionArgumentList(
        string optionName,
        out string[]? arguments);
}

正如所观察到的,ICommandLineOptionsProvider 扩展了 IExtension 接口。 因此,与其他扩展一样,可以使用 IExtension.IsEnabledAsync API 来选择将其启用或禁用。

ICommandLineOptionsProvider 的执行顺序是:

一个表示“ICommandLineOptionsProvider”接口执行顺序的示意图。

让我们来检查 API 及其平均值:

ICommandLineOptionsProvider.GetCommandLineOptions():此方法用于检索组件提供的所有选项。 每个 CommandLineOption 都需要指定以下属性:

string name:这是不含破折号的选项名称。 例如,“筛选器”将被用户用作 --filter。

string description:这是选项的说明。 当用户将 --help 作为参数传递给应用程序生成器时,它将显示出来。

ArgumentArity arity:选项的实参数量是指如果指定了该选项或命令,可以传递的值的个数。 当前的可用实参数量包括:

  • Zero:表示参数的元数为零。
  • ZeroOrOne:表示参数元数为零或一。
  • ZeroOrMore:表示参数的元数为零或更多。
  • OneOrMore:表示一个或多个参数元数。
  • ExactlyOne:表示正好一个参数实参数量。

有关示例,请参阅 System.CommandLine arity 表。

bool isHidden:此属性表示该选项可以使用,但在调用 --help 时不会显示在说明中。

ICommandLineOptionsProvider.ValidateOptionArgumentsAsync:此方法用于“验证”用户提供的参数。

例如,如果有一个名为 --dop 的参数,表示我们自定义测试框架的并行度,那么用户可能会输入 --dop 0。 在此情况下,值 0 将无效,因为预计它的并行度为 1 或更高。 通过使用 ValidateOptionArgumentsAsync,可以执行预先验证,并在必要时返回错误信息。

上述示例的可行实现方法包括:

public Task<ValidationResult> ValidateOptionArgumentsAsync(
    CommandLineOption commandOption,
    string[] arguments)
{
    if (commandOption.Name == "dop")
    {
        if (!int.TryParse(arguments[0], out int dopValue) || dopValue <= 0)
        {
            return ValidationResult.InvalidTask("--dop must be a positive integer");
        }
    }

    return ValidationResult.ValidTask;
}

ICommandLineOptionsProvider.ValidateCommandLineOptionsAsync:此方法作为最后一个方法被调用,可以进行全局一致性检查。

例如,假设我们的测试框架能够生成测试结果报告并保存到文件中。 使用 --generatereport 选项可以访问该功能,使用 --reportfilename myfile.rep 可以指定文件名。 在此情况下,如果用户只提供 --generatereport 选项而不指定文件名,验证就会失败,因为没有文件名就无法生成报告。 上述示例的可行实现方法包括:

public Task<ValidationResult> ValidateCommandLineOptionsAsync(ICommandLineOptions commandLineOptions)
{
    bool generateReportEnabled = commandLineOptions.IsOptionSet(GenerateReportOption);
    bool reportFileName = commandLineOptions.TryGetOptionArgumentList(ReportFilenameOption, out string[]? _);

    return (generateReportEnabled || reportFileName) && !(generateReportEnabled && reportFileName)
        ? ValidationResult.InvalidTask("Both `--generatereport` and `--reportfilename` need to be provided simultaneously.")
        : ValidationResult.ValidTask;
}

请注意,ValidateCommandLineOptionsAsync 方法提供 ICommandLineOptions 服务,用于获取平台本身解析的参数信息。

ITestSessionLifetimeHandler 扩展程序

ITestSessionLifetimeHandler 是一个“进程内”扩展,可在测试会话“之前”和“之后”执行代码。

若要注册自定义 ITestSessionLifetimeHandler,请使用以下 API:

var builder = await TestApplication.CreateBuilderAsync(args);

// ...

builder.TestHost.AddTestSessionLifetimeHandle(
    static serviceProvider => new CustomTestSessionLifetimeHandler());

工厂利用 IServiceProvider 来访问测试平台提供的服务套件。

重要

注册的顺序非常重要,因为 API 是按照注册的顺序调用的。

ITestSessionLifetimeHandler 接口包括以下方法:

public interface ITestSessionLifetimeHandler : ITestHostExtension
{
    Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext);

    Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext);
}

public interface ITestSessionContext
{
    SessionUid SessionUid { get; }

    CancellationToken CancellationToken { get; }
}

public readonly struct SessionUid(string value)
{
    public string Value { get; } = value;
}

public interface ITestHostExtension : IExtension
{
}

重要

在 MTP 2.0.0 中,这两种方法都已更改为接受一个单独的 ITestSessionContext 参数,该参数可提供对 SessionUid 和 CancellationToken 的访问。 在 MTP 1.x 中,每个方法采用单独的 SessionUid 参数和 CancellationToken 参数。 有关更多信息,请参阅从 Microsoft.Testing.Platform (MTP) v1 迁移到 v2。

ITestSessionLifetimeHandler 是 ITestHostExtension 的一种类型,是所有“测试主机”扩展的基础。 与所有其他扩展点一样,它也继承自 IExtension。 因此,与其他扩展一样,可以使用 IExtension.IsEnabledAsync API 来选择将其启用或禁用。

请考虑此 API 的以下详细信息:

OnTestSessionStartingAsync:此方法会在测试会话开始前调用,并接收 ITestSessionContext,其 SessionUid 为当前测试会话提供不透明标识符。

OnTestSessionFinishingAsync:测试会话完成后会调用此方法,确保测试框架已完成所有测试的执行,并向平台报告了所有相关数据。 在这种方法中,扩展通常使用 IMessageBus 将自定义资产或数据传输到共享平台总线。 此方法还可以向任何自定义“进程外”扩展发出测试会话已结束的信号。

最后,ITestSessionContext 公开扩展应遵循的 CancellationToken。

如果扩展需要密集初始化,并且需要使用 async/await 模式,则可以参考 Async extension initialization and cleanup。 如果需要在扩展点之间“共享状态”,则可以参考 CompositeExtensionFactory<T> 部分。

ITestApplicationLifecycleCallbacks 扩展程序

重要

ITestApplicationLifecycleCallbacks 在 MTP 2.0.0 中删除。 改用 ITestHostApplicationLifetime。 有关更多信息,请参阅从 Microsoft.Testing.Platform (MTP) v1 迁移到 v2。

该 ITestHostApplicationLifetime 接口允许 进程内 扩展在 测试主机的开头和末尾运行代码。

若要注册自定义 ITestHostApplicationLifetime,请使用以下 API:

var builder = await TestApplication.CreateBuilderAsync(args);

// ...

builder.TestHost.AddTestHostApplicationLifetime(
    static serviceProvider
    => new CustomTestHostApplicationLifetime());

工厂使用 IServiceProvider 访问测试平台提供的服务。

重要

注册的顺序非常重要,因为 API 是按照注册的顺序调用的。

ITestHostApplicationLifetime 接口包括以下方法:

public interface ITestHostApplicationLifetime : ITestHostExtension
{
    Task BeforeRunAsync(CancellationToken cancellationToken);

    Task AfterRunAsync(
        int exitCode,
        CancellationToken cancellationToken);
}

public interface ITestHostExtension : IExtension
{
}

ITestHostApplicationLifetime 接口扩展了 ITestHostExtension,后者作为所有 测试主机 扩展的基础接口。 与所有其他扩展点一样,它也继承自 IExtension。 因此,与其他扩展一样,可以使用 IExtension.IsEnabledAsync API 来选择将其启用或禁用。

BeforeRunAsync:此方法是“测试主机”的初始接触点,也是“进程内”扩展执行功能的第一次机会。 如果某项功能设计用于在两种环境中运行,它通常用于与任何相应的“进程外”扩展建立连接。

例如,内置的挂起转储功能由进程内和进程外扩展组成,这种方法用于与扩展的进程外组件交换信息。

AfterRunAsync:此方法是退出 int ITestApplication.RunAsync() 之前的最后一次调用,它提供了 exit code。 它应该仅用于清理任务,并通知任何相应的外部进程扩展,告知测试宿主即将终止。

最后,这两个 API 都会使用扩展也应遵循的 CancellationToken。

IDataConsumer 扩展程序

IDataConsumer 是一个进程内扩展,能够订阅并接收由测试框架及其扩展发布到IMessageBus的IData信息。

此扩展点至关重要,因为它能让开发人员收集和处理测试过程中生成的所有信息。

要注册自定义 IDataConsumer,请使用以下 API:

var builder = await TestApplication.CreateBuilderAsync(args);

// ...

builder.TestHost.AddDataConsumer(
    static serviceProvider => new CustomDataConsumer());

工厂利用 IServiceProvider 来访问测试平台提供的服务套件。

重要

注册的顺序非常重要,因为 API 是按照注册的顺序调用的。

IDataConsumer 接口包括以下方法:

public interface IDataConsumer : IExtension
{
    Type[] DataTypesConsumed { get; }

    Task ConsumeAsync(
        IDataProducer dataProducer,
        IData value,
        CancellationToken cancellationToken);
}

public interface IData
{
    string DisplayName { get; }
    string? Description { get; }
}

重要

在 MTP 2.0.0 中, IDataConsumer 移动到 Microsoft.Testing.Platform.Extensions 命名空间,现在直接扩展 IExtension 。 在 MTP 1.x 中,它扩展了 ITestHostExtension。 你仍然将其注册到 builder.TestHost.AddDataConsumer(...)。 有关更多信息,请参阅从 Microsoft.Testing.Platform (MTP) v1 迁移到 v2。

IDataConsumer 继承自 IExtension。 因此,与其他扩展一样,可以使用 IExtension.IsEnabledAsync API 来选择将其启用或禁用。

DataTypesConsumed:此属性会返回扩展计划使用的 Type 列表。 它对应于 IDataProducer.DataTypesProduced。 值得注意的是,IDataConsumer 可以订阅来自不同 IDataProducer 实例的多个类型,而不会出现任何问题。

ConsumeAsync:每当将当前消费者所订阅类型的数据发布到 IMessageBus 时,都会触发此方法。 它会接收 IDataProducer,以便提供数据有效负载生成者以及 IData 有效负载本身的详细信息。 如你所见,IData 是一个包含一般信息数据的通用占位符接口。 能够发布不同类型 IData 意味着使用者需要“开启”类型本身,将其转换为正确的类型并访问特定信息。

重要

对于来自IDataProducer且其IExtension.Uid与使用者的IExtension.Uid 匹配的数据,消息总线不会调用IDataConsumer。 如果扩展同时发布和订阅消息总线数据,请为其生成者和使用者注册分配不同的 UID。 若要处理扩展程序自身产生的数据,请直接调用处理逻辑,而不要通过消息总线传递这些数据。

如果使用者希望详细说明由TestNodeUpdateMessage生成的 ,可以使用以下示例实现:

internal class CustomDataConsumer : IDataConsumer, IOutputDeviceDataProducer
{
    public Type[] DataTypesConsumed => new[] { typeof(TestNodeUpdateMessage) };
    ...
    public Task ConsumeAsync(
        IDataProducer dataProducer,
        IData value,
        CancellationToken cancellationToken)
    {
        var testNodeUpdateMessage = (TestNodeUpdateMessage)value;

        switch (testNodeUpdateMessage.TestNode.Properties.Single<TestNodeStateProperty>())
        {
            case InProgressTestNodeStateProperty _:
                {
                    ...
                    break;
                }
            case PassedTestNodeStateProperty _:
                {
                    ...
                    break;
                }
            case FailedTestNodeStateProperty failedTestNodeStateProperty:
                {
                    ...
                    break;
                }
            case SkippedTestNodeStateProperty _:
                {
                    ...
                    break;
                }
            ...
        }

        return Task.CompletedTask;
    }
...
}

最后,API 需要一个扩展应遵循的 CancellationToken。

重要

直接在 ConsumeAsync 方法中处理有效负载。 常规的 IDataConsumer 会异步处理数据:IMessageBus 会将每个已发布的负载加入队列,并在后台循环中处理它们,因此 IMessageBus.PublishAsync 不会阻塞生产者,而且无法保证在生产者继续工作的同时 ConsumeAsync 会在何时运行。 平台序列化传递,以便每个使用者一次只处理一个有效负载,这样就无需在单个使用者中进行复杂的同步。

注释

对于需要保证在生成者继续执行之前已进行消耗的应用场景(例如,在测试开始运行之前),MTP 2.3.0 引入了实验性的 IBlockingDataConsumer 标记接口(需要抑制 TPEXP 诊断)。 消息总线会以内联方式调用还实现 IBlockingDataConsumer 的使用者:调用会被串行化,PublishAsync 会阻止到 ConsumeAsync 完成,并且 ConsumeAsync 引发的任何异常都会传播回发布数据的生成者。 消息总线跳过将生成者的数据传送回同一生成者(相同的 UID),因此在自己的 UID 下发布很安全。 但是,阻止使用者不得在 ConsumeAsync 内部发布使用不同的生成者 UID 路由回其自身的数据,因为这种重入会导致死锁。

警告

IDataConsumer在复合扩展点内与 ITestSessionLifetimeHandler 结合使用时,必须忽略在执行 ITestSessionLifetimeHandler.OnTestSessionFinishingAsync 后收到的任何数据。 OnTestSessionFinishingAsync 是处理累积数据并向 IMessageBus 传输新信息的最后机会,因此,超出这一点的任何数据将无法供扩展“使用”。

如果扩展需要密集初始化,并且需要使用 async/await 模式,则可以参考 Async extension initialization and cleanup。 如果需要在扩展点之间“共享状态”,则可以参考 CompositeExtensionFactory<T> 部分。

消息总线文件工件

自定义 IData 有效负载不会自动生成用户界面或命令行输出。 平台仅呈现已注册匹配使用者的数据,因此,如果发布自己的 IData 类型,但没有任何使用者,就不会打印或转发任何内容。 若要让扩展生成的文件对用户和工具可见,请发布一种内置的文件工件消息,而内置的 terminal 和 dotnet test 使用方已能够识别该消息。

对于运行级或会话级文件,请发布一个 FileArtifact 或一个 SessionFileArtifact。 这两者都是在 MTP 1.0.0 中引入的,并位于命名空间中 Microsoft.Testing.Platform.Extensions.Messages :

  • FileArtifact 未限定范围。 将其用于与特定测试会话无关的文件。
  • SessionFileArtifact 的范围通过其 SessionUid 限定为运行/会话。 将其用于针对整个运行生成的工件,例如覆盖范围结果、报表、转储或录制的视频。

内置终端和 dotnet test 使用方都会识别这两种类型,并打印或转发这些文件路径,从而让这些文件可在控制台输出中以及通过 dotnet test 管道被发现。 由于用户控制最终的呈现方式,因此请将每个文件保留在磁盘上,并在其可能被使用或转发期间保持可用;不要在同一个 PublishAsync 调用中将其删除。

项目对象本身不具有生成者标识。 消息总线通过 IDataConsumer.ConsumeAsync 的 dataProducer 参数向每个使用者提供原始 IDataProducer,因此使用者从该参数而不是从工件中得知文件生成者。 消息总线也不获取引用文件的所有权、移动或删除:生成者拥有文件的生存期,使用者只能接收、读取或转发其路径。

注释

第三方 IDataConsumer 注册仅在 builder.TestHost(进程内测试主机)中公开。 没有供进程外使用方使用的公共 builder.TestHostControllers.AddDataConsumer API。 显示工件的第一方报表扩展使用内部平台集成,而这是自定义扩展不能依赖的集成。 若要让您自己的扩展显示文件,请发布此处所述的内置构件消息,并让内置使用方呈现这些文件。

发布会话工件的生成者必须实现 IDataProducer,并在 DataTypesProduced 中列出其发布的确切运行时消息类型。 以下示例在会话完成时发布覆盖率报告:

internal sealed class CoverageReportProducer(IMessageBus messageBus)
    : IDataProducer, ITestSessionLifetimeHandler
{
    public string Uid => nameof(CoverageReportProducer);
    public string Version => "1.0.0";
    public string DisplayName => "Coverage report producer";
    public string Description => "Publishes the coverage report as a session artifact.";

    // List the exact runtime message types this producer publishes.
    public Type[] DataTypesProduced => new[] { typeof(SessionFileArtifact) };

    public Task<bool> IsEnabledAsync() => Task.FromResult(true);

    public Task OnTestSessionStartingAsync(ITestSessionContext context)
        => Task.CompletedTask;

    public Task OnTestSessionFinishingAsync(ITestSessionContext context)
    {
        var report = new FileInfo("coverage.cobertura.xml");
        return messageBus.PublishAsync(
            this,
            new SessionFileArtifact(
                context.SessionUid,
                report,
                "Code coverage",
                "Cobertura coverage report for the run."));
    }
}

若要将文件附加到 特定测试 ,以便终端 dotnet test和 IDE 将它与该测试关联并显示,请不要发布独立文件项目。 相反,向测试框架通过 TestNodeUpdateMessage 报告的 TestNode 中添加一个或多个 FileArtifactProperty 条目。 FileArtifactProperty 已在 MTP 1.7.0 中引入:

var testNode = new TestNode
{
    Uid = testUid,
    DisplayName = testDisplayName,
    Properties = new PropertyBag(
        PassedTestNodeStateProperty.CachedInstance,
        new FileArtifactProperty(
            new FileInfo("screenshot.png"),
            "Failure screenshot",
            "Screenshot captured while the test ran.")),
};

await messageBus.PublishAsync(
    dataProducer,
    new TestNodeUpdateMessage(sessionUid, testNode));

重要

TestNodeFileArtifact 已过时,在 MTP 2.0.0 中删除。 若要附加测试级别文件,请在 TestNode 上使用 FileArtifactProperty。 有关更多信息,请参阅从 Microsoft.Testing.Platform (MTP) v1 迁移到 v2。

注释

MTP 2.4.0 向 kind 和 Kind 添加了实验性的 SessionFileArtifact 构造函数重载和 TPEXP 属性(需要抑制 FileArtifact 诊断)。 Kind是工件格式的由生产者声明的反向 DNS 标识符(例如 microsoft.testing.trx、microsoft.testing.junit、microsoft.testing.ctrf 或 microsoft.testing.html),供后处理用于对相同格式的工件进行分组。 null如果生成者未声明已知类型,请将其保留或省略。

#pragma warning disable TPEXP // Experimental API.
new SessionFileArtifact(
    context.SessionUid,
    trxFile,
    "TRX report",
    "Test results in TRX format.",
    kind: "microsoft.testing.trx");
#pragma warning restore TPEXP

IArtifactPostProcessor 扩展程序

从 MTP 2.4.0 开始,实验性IArtifactPostProcessor扩展点会在 dotnet test调用运行多个测试模块后,或在重试运行多次尝试后处理工件。 ArtifactPostProcessingMode 值用于标识处理器处理的是 TestModules 还是 RetryAttempts。

内置的 TRX、JUnit、CTRF 和 HTML 报表扩展使用此扩展点来合并目录中的相关报表项目 merged 。 HTML 合并会创建合并的摘要,并保留原始的按进程报告。 若要了解报表合并如何影响测试输出,请参阅 报表合并。

ITestExecutionFilterProvider 扩展程序

从 MTP 2.4.0 开始,实验性的 ITestExecutionFilterProvider 扩展点允许扩展程序在测试框架收到请求之前,为发现或运行请求添加约束条件。 向 AddTestExecutionFilterProvider 注册提供程序工厂。

MTP 将请求筛选器和每个提供程序筛选器与逻辑 AND 组合在一起。 它对 TestNodeUidListFilter 值取交集,并对其他组合使用 CompositeTestExecutionFilter。 测试框架必须了解可以访问它的每个筛选器类型。

提供程序接收一个 TestExecutionFilterContext,它用于标识该请求是用于发现还是执行,以及该请求是来自控制台还是 JSON-RPC 服务器。 当提供程序未添加约束时,返回 null 或 NopFilter。 JSON-RPC 服务器请求不支持提供程序约束,因此当请求源为Server时返回null。

重要

这些 API 使用 TPEXP 诊断 ID,可能会在将来的版本中更改。

ITestHostExecutionOrchestratorMiddleware 扩展程序

从 MTP 2.5 预览版开始,实验性 ITestHostExecutionOrchestratorMiddleware 扩展点恰好封装一次对已配置测试主机协调程序的调用。 使用 AddTestHostExecutionOrchestratorMiddleware 注册中间件。 注册顺序是从最外层开始的。

中间件可以监视调用过程或将其短路,但无法实现重试、压力测试循环、分片或多阶段执行。 现有 --retry-failed-tests 业务流程协调程序不使用此中间件 API。

遵循以下约束:

  • 至多调用 next 一次,并在中间件方法返回或引发异常之前开始调用它。
  • 调用 next 时,应返回其确切的退出代码,并原样传播其异常和取消情况。
  • 当你未调用 next 而短路时,返回非成功退出代码。
  • 将组合顺序视为重要。 中间件效果并不被假定为可交换。

重要

这些 API 使用 TPEXP 诊断 ID,可能会在将来的版本中更改或删除。

ITestHostEnvironmentVariableProvider 扩展程序

ITestHostEnvironmentVariableProvider 是一个“进程外”扩展,可用于为测试主机建立自定义环境变量。 使用此扩展点可确保测试平台启动一个带有适当环境变量的新主机,详见体系结构部分。

要注册自定义 ITestHostEnvironmentVariableProvider,请使用以下 API:

var builder = await TestApplication.CreateBuilderAsync(args);

// ...

builder.TestHostControllers.AddEnvironmentVariableProvider(
    static serviceProvider => new CustomEnvironmentVariableForTestHost());

工厂利用 IServiceProvider 来访问测试平台提供的服务套件。

重要

注册的顺序非常重要,因为 API 是按照注册的顺序调用的。

ITestHostEnvironmentVariableProvider 接口包括以下方法和类型:

public interface ITestHostEnvironmentVariableProvider : ITestHostControllersExtension, IExtension
{
    Task UpdateAsync(IEnvironmentVariables environmentVariables);

    Task<ValidationResult> ValidateTestHostEnvironmentVariablesAsync(
        IReadOnlyEnvironmentVariables environmentVariables);
}

public interface IEnvironmentVariables : IReadOnlyEnvironmentVariables
{
    void SetVariable(EnvironmentVariable environmentVariable);
    void RemoveVariable(string variable);
}

public interface IReadOnlyEnvironmentVariables
{
    bool TryGetVariable(
        string variable,
        [NotNullWhen(true)] out OwnedEnvironmentVariable? environmentVariable);
}

public sealed class OwnedEnvironmentVariable : EnvironmentVariable
{
    public IExtension Owner { get; }

    public OwnedEnvironmentVariable(
        IExtension owner,
        string variable,
        string? value,
        bool isSecret,
        bool isLocked);
}

public class EnvironmentVariable
{
    public string Variable { get; }
    public string? Value { get; }
    public bool IsSecret { get; }
    public bool IsLocked { get; }
}

ITestHostEnvironmentVariableProvider 是 ITestHostControllersExtension 的一种类型,是所有“测试主机控制器”扩展的基础。 与所有其他扩展点一样,它也继承自 IExtension。 因此,与其他扩展一样,可以使用 IExtension.IsEnabledAsync API 来选择将其启用或禁用。

请考虑此 API 的详细信息:

UpdateAsync:此更新 API 提供了 IEnvironmentVariables 对象的实例,可以从中调用 SetVariable 或 RemoveVariable 方法。 在使用 SetVariable 时,必须传递一个 EnvironmentVariable 类型的对象,它需要遵循以下规范:

  • Variable:环境变量的名称。
  • Value:环境变量的值。
  • IsSecret:这表示环境变量是否包含敏感信息,这些信息不应通过 TryGetVariable 记录或访问。
  • IsLocked:这将决定其他 ITestHostEnvironmentVariableProvider 扩展是否可以修改此值。

ValidateTestHostEnvironmentVariablesAsync:在调用了已注册 UpdateAsync 实例的所有 ITestHostEnvironmentVariableProvider 方法后,才会调用此方法。 可以通过它来“验证”环境变量的设置是否正确。 它采用实现 IReadOnlyEnvironmentVariables 的对象,该对象提供 TryGetVariable 方法,可通过 OwnedEnvironmentVariable 对象类型来获取特定的环境变量信息。 验证完成后,将返回一个 ValidationResult,其中包含任何失败原因。

注释

默认情况下,测试平台会实现并注册 SystemEnvironmentVariableProvider。 此提供程序会加载所有“当前”环境变量。 作为第一个注册的提供程序,它将首先执行,并允许所有其他 ITestHostEnvironmentVariableProvider 用户扩展访问默认环境变量。

如果扩展需要密集初始化,并且需要使用 async/await 模式,则可以参考 Async extension initialization and cleanup。 如果需要在扩展点之间“共享状态”,则可以参考 CompositeExtensionFactory<T> 部分。

ITestHostProcessLifetimeHandler 扩展程序

ITestHostProcessLifetimeHandler 是一个“进程外”扩展,可用于从外部角度观察测试主机进程。 这可确保扩展不受测试代码可能导致的潜在崩溃或挂起的影响。 使用此扩展点将提示测试平台启动新主机,详见体系结构部分。

要注册自定义 ITestHostProcessLifetimeHandler,请使用以下 API:

var builder = await TestApplication.CreateBuilderAsync(args);

// ...

builder.TestHostControllers.AddProcessLifetimeHandler(
    static serviceProvider => new CustomMonitorTestHost());

工厂利用 IServiceProvider 来访问测试平台提供的服务套件。

重要

注册的顺序非常重要,因为 API 是按照注册的顺序调用的。

ITestHostProcessLifetimeHandler 接口包括以下方法:

public interface ITestHostProcessLifetimeHandler : ITestHostControllersExtension
{
    Task BeforeTestHostProcessStartAsync(CancellationToken cancellationToken);

    Task OnTestHostProcessStartedAsync(
        ITestHostProcessInformation testHostProcessInformation,
        CancellationToken cancellation);

    Task OnTestHostProcessExitedAsync(
        ITestHostProcessInformation testHostProcessInformation,
        CancellationToken cancellation);
}

public interface ITestHostProcessInformation
{
    int PID { get; }
    int ExitCode { get; }
    bool HasExitedGracefully { get; }
}

ITestHostProcessLifetimeHandler 是 ITestHostControllersExtension 的一种类型,是所有“测试主机控制器”扩展的基础。 与所有其他扩展点一样,它也继承自 IExtension。 因此,与其他扩展一样,可以使用 IExtension.IsEnabledAsync API 来选择将其启用或禁用。

请考虑此 API 的以下详细信息:

BeforeTestHostProcessStartAsync:此方法会在测试平台启动测试主机之前调用。

OnTestHostProcessStartedAsync:此方法会在测试主机启动后立即调用。 该方法提供了一个实现 ITestHostProcessInformation 接口的对象,该对象提供了测试主机进程结果的关键详细信息。

重要

调用此方法不会停止测试主机的执行。 如果需要暂停,应注册进程内扩展,如 ITestHostApplicationLifetime,并与“进程外”扩展同步。

OnTestHostProcessExitedAsync:此方法会在测试套件执行完成后调用。 此方法提供了一个符合 ITestHostProcessInformation 接口的对象,该对象传达了有关测试主机进程结果的关键详细信息。

ITestHostProcessInformation 接口提供以下详细信息:

  • PID:测试主机的进程 ID。
  • ExitCode:进程的退出代码。 此值只能在 OnTestHostProcessExitedAsync 方法中使用。 尝试在 OnTestHostProcessStartedAsync 方法中访问它将导致异常。
  • HasExitedGracefully:一个布尔值,表示测试主机是否已崩溃。 如果为 true,则表示测试主机没有正常退出。

使用 TestingPlatformBuilderHook 自动注册您的扩展程序

前面的每个扩展部分都显示 手动 注册调用(例如, builder.TestHost.AddDataConsumer(...))。 要求使用者编辑其 Main 方法是一种糟糕的入职体验。 Microsoft.Testing.Platform.MSBuild 包通过生成从自动生成的入口点运行的 SelfRegisteredExtensions.AddSelfRegisteredExtensions(builder, args) 方法解决此问题。 要将你的扩展插入到生成的该方法中,请在 NuGet 包中包含两个项目:

  • 包含用于注册你的扩展的 TestingPlatformBuilderHook 方法的公共静态 AddExtensions 类。
  • 一个 MSBuild props 文件,其中声明了一个指向该类的 <TestingPlatformBuilderHook> 项。

当使用者安装你的包时,MSBuild 集成会识别该项并生成对你的挂钩的调用,这样你的扩展就会在无需使用者更改代码的情况下完成注册。

注释

只有当使用者的项目中包含 Microsoft.Testing.Platform.MSBuild(由 MSTest、NUnit 和 xUnit 运行器以传递方式包含)且未通过设置 <GenerateTestingPlatformEntryPoint>false</GenerateTestingPlatformEntryPoint> 选择退出时,自动注册才会生效。 禁用自动生成入口点的使用者仍需要从其 Main 方法调用手动注册 API。

创建挂钩类

在你的扩展程序集中添加一个 public static class TestingPlatformBuilderHook,其中包含 AddExtensions(ITestApplicationBuilder, string[]) 方法,该方法执行使用者原本需要手动调用的注册操作:

using Microsoft.Testing.Platform.Builder;

namespace Contoso.MyExtension;

public static class TestingPlatformBuilderHook
{
    public static void AddExtensions(ITestApplicationBuilder testApplicationBuilder, string[] arguments)
        => testApplicationBuilder.AddMyExtension();
}

类名不必TestingPlatformBuilderHook(MSBuild 项按全类型名称指向它),但使用该名称可使代码与内置扩展(如 Microsoft.Testing.Extensions.Retry 和 Microsoft.Testing.Extensions.HotReload)保持一致。

该方法必须:

  • 应为 public static。
  • 具有类型为 Microsoft.Testing.Platform.Builder.ITestApplicationBuilder 的第一个参数。
  • 具有第二个类型 string[] 参数(传递给测试主机的命令行参数)。 如果扩展不需要它,则可以忽略它。
  • 返回 void。

声明 MSBuild 项

在 NuGet 包的 buildMultiTargeting/<PackageId>.props 下提供一个 props 文件。 声明一个 <TestingPlatformBuilderHook> 项,将 MSBuild 任务指向你的挂钩类:

<Project>
  <ItemGroup>
    <TestingPlatformBuilderHook Include="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
      <DisplayName>Contoso.MyExtension</DisplayName>
      <TypeFullName>Contoso.MyExtension.TestingPlatformBuilderHook</TypeFullName>
    </TestingPlatformBuilderHook>
  </ItemGroup>
</Project>

元数据如下所示:

  • Include:用于唯一标识您的挂钩的 GUID。 请参阅 GUID Include 是随机标识符。
  • DisplayName:生成入口点时 MSBuild 诊断消息中显示的友好名称。 使用您的包或扩展名称。
  • TypeFullName:你之前创建的 TestingPlatformBuilderHook 类的完全限定名称。 MSBuild 任务使用此项在生成的入口点中输出 global::Contoso.MyExtension.TestingPlatformBuilderHook.AddExtensions(builder, args);。

Include GUID 是随机标识符

Include 特性中的 GUID 不等同于你扩展的 IExtension.Uid。 它是 MSBuild 任务用于对 NuGet 引用中的挂钩去重(并在少数已知情况下对它们进行排序)的注册标识符。

创作新扩展时,请在 props 文件中生成全新的 GUID 并对其进行硬编码。 生成一个的方法有几种:

  • PowerShell: [guid]::NewGuid()
  • Visual Studio:Tools>Create GUID
  • Linux 和 macOS 上的 uuidgen

重要

切勿从其他扩展的 props 文件中复制 GUID(无论该扩展是由 Microsoft 还是第三方提供)。 共享相同 Include 值的两个扩展被视为重复项:只调用一个挂钩,因此扩展以无提示方式无法注册。

注释

一旦你发布了某个 GUID,就应将其视为永久性的。 在以后的版本中更改它本身是无害的,但在将来的包版本中重用不同挂钩的 旧 值可能会使升级期间在依赖项关系图中拥有这两个版本的使用者感到困惑。

验证挂钩是否已连接

在使用 Microsoft.Testing.Platform.MSBuild 的测试项目中安装包后,在 SelfRegisteredExtensions.g.cs 下生成项目并检查生成的 obj/<Configuration>/<TargetFramework>/ 文件。 你应该看到对挂钩的调用,例如:

public static void AddSelfRegisteredExtensions(this global::Microsoft.Testing.Platform.Builder.ITestApplicationBuilder builder, string[] args)
{
    global::Contoso.MyExtension.TestingPlatformBuilderHook.AddExtensions(builder, args);
}

如果缺少该调用,请再次检查 props 文件是否已打包在 buildMultiTargeting/ 内的 build/ 下(而不是 .nupkg 下),是否存在 DisplayName 和 TypeFullName 元数据,以及使用方是否未设置 <GenerateTestingPlatformEntryPoint>false</GenerateTestingPlatformEntryPoint>。

动态加载扩展

从 MTP 2.4.0 开始,传递 --enable-dynamic-extensions 以加载由测试应用程序旁边的 *.testingplatformextensions.json 清单文件声明的扩展。 MTP 不会扫描任意程序集,除非每次运行都显式启用,否则动态加载将保持禁用状态。

每个清单项都标识一个程序集和一个要调用的TestingPlatformBuilderHook 类型。 MTP 报告终端输出中每个加载的扩展,如果清单、程序集、类型或挂钩无效,则运行失败。 将条目 enabled 的属性设置为 false 禁用它,而无需删除清单。

警告

动态加载的扩展在测试过程中以完全信任方式运行。 仅使用来自受信任目录和来源的清单及程序集。

有关完整的清单协定,请参阅 扩展清单 JSON 架构 和 动态扩展加载设计。

扩展执行顺序

测试平台由测试框架和任意数量的扩展组成,这些扩展可在进程内或进程外运行。 本文档概述了所有潜在扩展点的“调用顺序”,以便明确预计何时会调用某项功能:

  1. ITestHostEnvironmentVariableProvider.UpdateAsync:进程外
  2. ITestHostEnvironmentVariableProvider.ValidateTestHostEnvironmentVariablesAsync:进程外
  3. ITestHostProcessLifetimeHandler.BeforeTestHostProcessStartAsync:进程外
  4. 测试主机进程启动
  5. ITestHostProcessLifetimeHandler.OnTestHostProcessStartedAsync:在进程外,此事件可能会与进程内扩展的操作交织,具体取决于竞争条件。
  6. ITestHostApplicationLifetime.BeforeRunAsync:进程内
  7. ITestSessionLifetimeHandler.OnTestSessionStartingAsync。正在进行中
  8. ITestFramework.CreateTestSessionAsync:进程内
  9. ITestFramework.ExecuteRequestAsync:进程内,此方法可被调用一次或多次。 此时,测试框架将向 IMessageBus 传输信息,以供 IDataConsumer 使用。
  10. ITestFramework.CloseTestSessionAsync:进程内
  11. ITestSessionLifetimeHandler.OnTestSessionFinishingAsync:处理中
  12. ITestHostApplicationLifetime.AfterRunAsync:进程内
  13. 进程内清理涉及调用所有扩展点上的处置和 IAsyncCleanableExtension。
  14. ITestHostProcessLifetimeHandler.OnTestHostProcessExitedAsync:进程外
  15. 进程外清理涉及调用所有扩展点上的处置和 IAsyncCleanableExtension。

扩展辅助工具

测试平台提供了一系列帮助程序类和接口,以简化扩展的实现。 这些帮助程序旨在简化开发流程,并确保扩展符合平台标准。

扩展的异步初始化和清理

通过工厂创建测试框架和扩展遵循使用同步构造函数的标准.NET对象创建机制。 如果扩展需要密集初始化(如访问文件系统或网络),则不能在构造函数中使用 async/await 模式,因为构造函数会返回 void,而不是 Task。

因此,测试平台提供了一种方法,通过一个简单的接口来使用 async/await 模式初始化扩展。 为了保持对称性,它还为清理提供了异步接口,插件可以无缝地实现这个接口。

public interface IAsyncInitializableExtension
{
    Task InitializeAsync();
}

public interface IAsyncCleanableExtension
{
    Task CleanupAsync();
}

IAsyncInitializableExtension.InitializeAsync:确保在创建工厂后调用此方法。

IAsyncCleanableExtension.CleanupAsync:此方法确保在测试会话结束时、默认 或 DisposeAsync 之前被调用至少一次。

重要

与标准的 Dispose 方法类似,CleanupAsync 也可以多次调用。 如果一个对象的 CleanupAsync 方法被调用多次,则该对象必须忽略第一次调用后的所有调用。 如果多次调用对象的 CleanupAsync 方法,则对象不得导致异常。

注释

默认情况下,如果 DisposeAsync 可用,测试平台将调用他;如果 Dispose 已实现,则测试平台将调用它。 值得注意的是,测试平台不会同时调用两种处置方法,但如果实现了异步方法,则会优先使用异步方法。

CompositeExtensionFactory<T>

如扩展部分所述,测试平台让你能够实现接口,将自定义扩展纳入流程内外。

每个接口都解决了一个特定功能,根据.NET设计,可以在特定对象中实现此接口。 可以使用 AddXXX 中的特定注册 API TestHost 或 TestHostController 中的 ITestApplicationBuilder 对象注册扩展本身,详见相应部分。

但是,如果需要在两个扩展之间“共享状态”,则可以实现和注册实现不同接口的不同对象,这会使得共享成为一项具有挑战性的任务。 如果没有任何协助,就需要一种方法将一个扩展传递给另一个扩展以共享信息,而这就会让设计变得复杂。

为此,测试平台提供了使用同一类型实现多个扩展点的复杂方法,使数据共享成为一项简单的任务。 只需利用 CompositeExtensionFactory<T>,然后就可以使用与单一接口实现相同的 API 进行注册。

例如,考虑一种同时实现 ITestSessionLifetimeHandler 和 IDataConsumer 的类型。 这种情况很常见,因为通常希望从测试框架中收集信息,然后在测试会话结束后,使用 IMessageBus 中的 ITestSessionLifetimeHandler.OnTestSessionFinishingAsync 来调度工件。

你应该做的是按常规实现这些接口:

internal class CustomExtension : ITestSessionLifetimeHandler, IDataConsumer, ...
{
   ...
}

为类型创建 CompositeExtensionFactory<CustomExtension> 后,就可以用 IDataConsumer 和 ITestSessionLifetimeHandler API 来注册它,这两个 API 都提供了 CompositeExtensionFactory<T> 的重载:

var builder = await TestApplication.CreateBuilderAsync(args);

// ...

var factory = new CompositeExtensionFactory<CustomExtension>(serviceProvider => new CustomExtension());

builder.TestHost.AddTestSessionLifetimeHandle(factory);
builder.TestHost.AddDataConsumer(factory);

工厂构造函数使用 IServiceProvider 来访问测试平台提供的服务。

测试平台将负责管理复合扩展的生命周期。

值得注意的是,由于测试平台同时支持“进程内”和“进程外”扩展,因此不能任意组合任何扩展点。 扩展的创建和使用取决于主机类型,这意味着只能将“进程内”(TestHost) 和“进程外”(TestHostController) 扩展组合在一起。

可使用以下组合:

  • 对于 ITestApplicationBuilder.TestHost,可以将 IDataConsumer 和 ITestSessionLifetimeHandler 结合起来。
  • 对于 ITestApplicationBuilder.TestHostControllers,可以将 ITestHostEnvironmentVariableProvider 和 ITestHostProcessLifetimeHandler 结合起来。

注释

IDataConsumer是进程内扩展,因此,自定义消费者只能通过builder.TestHost进行注册(包括通过CompositeExtensionFactory<T>)。 没有用于在 builder.TestHostControllers 上注册 IDataConsumer 的公开 API。