自动化规范

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

Windows
MacOS
Linux

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

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

  • 它们是自文档化的

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

    DRY(不自我重复)

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

  • 它们隔离期望(测试)

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

如何设置规范

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

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

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

唯一的替代方案是使用 BEGIN_DEFINE_SPECEND_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.hItemCatalogService.cppItemCatalogService.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

本例中,DriverElementClick 均为 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 编写的规范。

Select Skin
Light
Dark

Welcome to the new Unreal Engine 4 Documentation site!

We're working on lots of new features including a feedback system so you can tell us how we are doing. It's not quite ready for use in the wild yet, so head over to the Documentation Feedback forum to tell us about this page or call out any issues you are encountering in the meantime.

We'll be sure to let you know when the new system is up and running.

Post Feedback