自动化规范

概述了一种新的自动化测试类型,称为"规范",它已经添加到我们现有的自动化测试框架中。

Choose your operating system:

Windows

macOS

Linux

我们在现有的自动化测试框架中添加了一种新的自动化测试类型。这种新类型称为 规范 。"规范"是一个术语,用于按照 行为驱动设计(BDD) 方法构建的测试。这是Web开发测试中使用的一个非常常见的方法,我们将其应用到我们的C++框架。

编写规范的原因有几个,其中包括:

  • 它们是自文档化的

  • 它们流畅且通常不自我重复的

    DRY(不自我重复)

  • 编写线程测试代码或潜在测试代码要容易得多

  • 它们隔离期望(测试)

  • 它们几乎可以用于所有类型的测试(功能测试、集成测试和单元测试)

如何设置规范

有两种方法来定义规范的标头,这两种方法都非常类似于我们定义测试类型的现有方法。

最简单的方法是使用 DEFINE_SPEC 宏,它采用的参数与所有其余测试定义宏所用的参数完全相同。

DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomSpec", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
void MyCustomSpec::Define()
{
    //@todo 在此处写下我的期望
}

唯一的替代方案是使用 BEGIN_DEFINE_SPEC END_DEFINE_SPEC 宏。这些宏允许您将自己的成员定义为测试的一部分。在下一节中您将看到,有一个值包含与该指针相关的内容。

BEGIN_DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomSpec", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
    TSharedPtr<FMyAwesomeClass> AwesomeClass;
END_DEFINE_SPEC(MyCustomSpec)
void MyCustomSpec::Define()
{
    //@todo 在此处写下我的期望
}

唯一的其他提示是,您需要为规范类的 Define() 成员编写实现,而不是像为其他测试类型编写一样为 RunTests() 成员编写实现。

规范应该在文件中进行定义,文件扩展名为 .spec.cpp ,且名称不能含有"Test"一词。例如, FItemCatalogService 类可能包含文件 ItemCatalogService.h ItemCatalogService.cpp ItemCatalogService.spec.cpp

这是建议性指导,而不是技术限制。

如何定义您的期望

BDD的一个重要部分是,您是要测试公共API的期望,而不是测试特定实现。这样,您的测试就不会那么脆弱,从而更容易维护,如果突然出现相同API的多个不同实现,测试更有可能正常运行。

在规范中,使用两个不同的主要函数 Describe() It() 来定义期望。

Describe

Describe() 用于确定复杂期望的范围,以便它们更具可读性且更不会自我重复。使用 Describe() 使您的代码更加不会自我重复,这是基于它与其他支持函数(如 BeforeEach() AfterEach() )之间的交互,具体如下:

void Describe(const FString& Description, TFunction<void()> DoWork)

Describe() 获取一个字符串,该字符串描述其中预期的范围,并获取一个lambda,该lambda定义这些预期。

可将 Describe() 放到另一个 Describe() 中来级联 Describe()

请记住, Describe() 不是一个测试,不会在实际测试运行期间执行。当第一次在规范中定义期望(或测试)时,它们只执行一次。

It

It() 是定义规范的实际期望的一小段代码。您可以从根 Define() 方法或从任意一个 Describe() lambda中调用 It() 。理想情况下,应该使用 It() 来断言期望,但也可以使用它来为测试场景进行最终的几个设置。

通常,最好的做法是用"should"一词作为 It() 调用描述字符串的开头,表示"它应该"。

定义基本期望

这里有一个例子,它把所有这些结合起来定义一个非常简单的期望:

BEGIN_DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomClass", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
    TSharedPtr<FMyCustomClass> CustomClass;
END_DEFINE_SPEC(MyCustomSpec)
void MyCustomSpec::Define()
{
    Describe("Execute()", [this]()
    {
        It("should return true when successful", [this]()
        {
            TestTrue("Execute", CustomClass->Execute());
        });

        It("should return false when unsuccessful", [this]()
        {
            TestFalse("Execute", CustomClass->Execute());
        });
    });
}

正如您所见,这会导致测试自文档化,如果程序员花时间正确地描述期望,而没有将不同的期望组合在一起,这种情况更容易出现。将所有 Describe() It() 调用组合在一起应构成可读性很强的语句,例如:

如果成功,Execute()应返回true
如果失败,Execute()应返回false

下面是一个更为复杂的示例,展示了自动化测试UI中成熟规范当前的状态:

AutomationSpec_MatureExample.png

本例中, Driver Element Click 均为 Describe() 调用,并由 It() 调用定义各种"should..."消息。

其中,每个 It() 调用都成为要执行的单独测试,因此,如果某个调用失败,而其他调用成功,则可以孤立执行。这样,因为调试测试变得更加轻松,所以维护测试也会更加简单。另外,由于测试会自文档化并孤立地执行,所以,当一个测试失败时,操作人员阅读测试报告会对发生的问题有更明确的了解——而不仅仅是知道一个名为 核心 的大桶出现了故障。这意味着,问题会更快地传达给相关人员,调查问题所需的时间也会更短。

最后,单击上述任一测试都将直接转到定义它的 It() 语句。

规范期望如何转化为测试

以下是详细说明;不过,了解规范测试类型的底层行为,可以更容易了解以下一些复杂的特性。

规范测试类型仅在需要时才会执行一次 Define() 根函数。执行此函数时,会收集每个非 Describe lambda。 Define() 完成后,会返回查看它收集的所有lambda或代码块,并为每个 It() 生成一组潜在命令。

因此,所有 BeforeEach() It() AfterEach() lambda代码块都放到一个执行链中,用于执行单个测试。当要求运行一个特定测试时,规范测试类型将对此特定测试的所有命令进行排队以便执行。在这种情况下,只有上一个块发出信号表示它已经执行完毕,下一个块才会继续执行。

其他特性

规范测试类型提供了几种其他特性,以便于编写复杂测试。特别是,它通常消除了直接使用自动化测试框架的潜在命令系统的需要,该系统虽强大却很麻烦。

下面是规范测试类型支持的特性列表,这些特性可以帮助处理更复杂的场景:

BeforeEach和AfterEach

BeforeEach() AfterEach() 是核心函数,用于编写最琐碎的规范之外的任何其他内容。使用 BeforeEach() ,您可以在运行随后的 It() 代码之前运行此代码。 AfterEach() 同理,但会在运行 It() 代码之后运行此代码。

请记住,每个"测试"只由一个It()调用组成。

例如:

BEGIN_DEFINE_SPEC(AutomationSpec, "System.Automation.Spec", EAutomationTestFlags::SmokeFilter | EAutomationTestFlags::ApplicationContextMask)
    FString RunOrder; 
END_DEFINE_SPEC(AutomationSpec)
void AutomationSpec::Define()
{
    Describe("A spec using BeforeEach and AfterEach", [this]()
    {
        BeforeEach([this]()
        {
            RunOrder = TEXT("A");
        });

        It("will run code before each spec in the Describe and after each spec in the Describe", [this]()
        {
            TestEqual("RunOrder", RunOrder, TEXT("A"));
        });

        AfterEach([this]()
        {
            RunOrder += TEXT("Z");
            TestEqual("RunOrder", RunOrder, TEXT("AZ"));
        });
    });
}

在我们的示例中,先定义 BeforeEach() ,然后定义 It() ,最后定义 AfterEach() ,因此代码块是从上到下执行的。虽然这不是硬性要求,但我们建议您保持这一调用逻辑顺序。您可以打乱上述三个调用的顺序,最后始终会生成相同的测试。

在上面的示例中还会在 AfterEach() 中检查期望,这是极为异常的现象,也是测试规范测试类型本身的负面效应。同样,除了用于清理之外,我们不建议使用 AfterEach()

您还可以执行多个 BeforeEach() AfterEach() 调用,它们将按照定义它们的顺序被调用。正如第一个 BeforeEach() 调用会在第二个 BeforeEach() 调用之前执行, AfterEach() 的行为也非常类似——第一个调用在后续调用之前执行。

BeforeEach([this]()
{
    RunOrder = TEXT("A");
});

BeforeEach([this]()
{
    RunOrder += TEXT("B");
});

It("will run code before each spec in the Describe and after each spec in the Describe", [this]()
{
    TestEqual("RunOrder", RunOrder, TEXT("AB"));
});

AfterEach([this]()
{
    RunOrder += TEXT("Y");
    TestEqual("RunOrder", RunOrder, TEXT("ABY"));
});

AfterEach([this]()
{
    RunOrder += TEXT("Z");
    TestEqual("RunOrder", RunOrder, TEXT("ABYZ"));
});

此外, BeforeEach() AfterEach() 受调用它们的 Describe() 作用域的影响。两者都只对处于相同调用作用域内的 It() 调用执行。

下面是一个复杂的示例,虽然调用顺序不当,但都得出正确的结果。

BEGIN_DEFINE_SPEC(AutomationSpec, "System.Automation.Spec", EAutomationTestFlags::SmokeFilter | EAutomationTestFlags::ApplicationContextMask)
    FString RunOrder; 
END_DEFINE_SPEC(AutomationSpec)
void AutomationSpec::Define()
{
    Describe("A spec using BeforeEach and AfterEach", [this]()
    {
        BeforeEach([this]()
        {
            RunOrder = TEXT("A");
        });

        AfterEach([this]()
        {
            RunOrder += TEXT("Z");

            // 会导致
            // TestEqual("RunOrder", RunOrder, TEXT("ABCYZ"));

// 或这个,基于正在执行的It()
            // TestEqual("RunOrder", RunOrder, TEXT("ABCDXYZ"));
        });

        BeforeEach([this]()
        {
            RunOrder += TEXT("B");
        });

        Describe("while nested inside another Describe", [this]()
        {
            AfterEach([this]()
            {
                RunOrder += TEXT("Y");
            });

It("will run all BeforeEach blocks and all AfterEach blocks", [this]()
            {
                TestEqual("RunOrder", RunOrder, TEXT("ABC"));
            });

            BeforeEach([this]()
            {
                RunOrder += TEXT("C");
            });

            Describe("while nested inside yet another Describe", [this]()
            {
                It("will run all BeforeEach blocks and all AfterEach blocks", [this]()
                {
                    TestEqual("RunOrder", RunOrder, TEXT("ABCD"));
                });

                AfterEach([this]()
                {
                    RunOrder += TEXT("X");
                });

                BeforeEach([this]()
                {
                    RunOrder += TEXT("D");
                });
            });
        });
    });
}

AsyncExecution

利用规范测试类型,您还可轻松定义单个代码块的执行方式。只需将适当的 EAsyncExecution 类型传递到重载版本的 BeforeEach() It() 和/或 AfterEach() 中即可。

例如:

BeforeEach(EAsyncExecution::TaskGraph, [this]() 
{
// 设置一些内容
));

It("should do something awesome", EAsyncExecution::ThreadPool, [this]()
{
    // 执行一些内容
});

AfterEach(EAsyncExecution::Thread, [this]() 
{
    // 销毁一些内容
));

上述各个代码块都将以不同的方式执行,但采用有保障的执行顺序。 BeforeEach() 块将作为任务在 TaskGraph 中运行, It() 将在线程池中的一个开放线程上运行,而 AfterEach() 将启动自己的专用线程,仅为运行一个代码块。

当必须模拟线程敏感型场景时,例如使用 自动化驱动程序 ,这些选项非常方便。

AsyncExecution 特性可与 潜在完成(Latent Completion) 特性组合起来。

潜在完成

有时候,您需要编写测试来执行一个需要多个帧的操作,例如在执行查询时。在这些场景中,您可以使用重载的 LatentBeforeEach() LatentIt() LatentAfterEach() 成员。其中,每个成员都与非潜在变体完全相同,只是它们的lambda使用一个名为 Done 的简单委托。

使用潜在变体时,规范测试类型将不会继续执行测试序列中的下一个代码块,直到主动运行的潜在代码块调用Done委托。

LatentIt("should return available items", [this](const FDoneDelegate& Done)
{
    BackendService->QueryItems(this, &FMyCustomSpec::HandleQueryItemComplete, Done);
});

void FMyCustomSpec::HandleQueryItemsComplete(const TArray<FItem>& Items, FDoneDelegate Done)
{
    TestEqual("Items.Num() == 5", Items.Num(), 5);
Done.Execute();
}

正如您在示例中看到的,您可以将 Done 委托作为有效荷载传递给其他回调函数,使其可被潜在代码访问。因此,当执行上面的测试时,它将不会继续为 It() 执行任何 AfterEach() 代码块,直至执行 Done 委托为止(即使 It() 代码块已经完成执行)。

潜在完成 特性可与 AsyncExecution 特性组合起来。

参数化测试

有时候,您需要以数据驱动的方式创建测试。而有时,这意味着从文件中读取输入并根据这些输入生成测试。其他情况下,理想的方式可能就是减少代码重复。无论哪种方式,规范测试类型都允许以一种非常自然的方式进行参数化测试。

Describe("Basic Math", [this]()
{
    for (int32 Index = 0; Index < 5; Index++)
    {
        It(FString::Printf(TEXT("should resolve %d + %d = %d"), Index, 2, Index + 2), [this, Index]()
        {
            TestEqual(FString::Printf(TEXT("%d + %d = %d"), Index, 2, Index + 2), Index + 2, Index + 2);
        });
    }
});

正如您在上面的示例中所看到的,创建参数化测试所需要做的就是动态调用其他规范函数,这些函数将参数化数据作为lambda有效荷载的一部分传递,同时生成唯一的描述。

在某些情况下,使用参数化测试可能会导致测试膨胀。作为单个测试的一部分,简单地从输入执行所有场景可能比较合理。您应该考虑输入的数量和生成的最终测试。以参数化的方式创建数据驱动测试的主要好处是,每个测试都可以单独运行,从而更容易重现。

Redefine

使用参数化测试时,有时可以在运行时方便地更改驱动输入的外部文件,并自动刷新测试。 Redefine() 是规范测试类型的成员,当调用该函数时,将重新执行 Define() 流程。这将导致重新采集和整理测试的所有代码块。

执行上述操作最方便的方法是创建一小段代码,以侦听输入文件的更改,并根据需要在测试上调用 Redefine()

禁用测试

规范测试类型的每个 Describe() BeforeEach() It() AfterEach() 成员都有一个变体,该变体以"x"为开头。例如, xDescribe() xBeforeEach() xIt() xAfterEach() 。这些变体是禁用代码块或 Describe() 的一种更为简单的方法。如果使用了一个 xDescribe() ,则 xDescribe() 中的所有代码也都将被禁用。

这比注释掉需要迭代的期望要容易。

成熟示例

您可以在 Engine/Source/Developer/AutomationDriver/Private/Specs/AutomationDriver.spec.cpp 中找到成熟的规范测试类型示例。这个规范目前包含120多个期望,并且在某个时候利用了大多数高级特性。

我们的启动程序团队还拥有规范框架的多种成熟用途,其中最成熟的用途之一是围绕 BuildPatchServices 编写的规范。

欢迎帮助改进虚幻引擎文档!请告诉我们该如何更好地为您服务。
填写问卷调查
取消