Version:

第15章 为组件编写单元测试

第15章 为组件编写单元测试

首先应使系统运行,即使它除了调用适当的虚拟子程序集外,没有任何用处。然后,它被一点一点地充实,子程序反过来被开发为作或对下一级别中空存根的调用。 —Frederick P. Brooks Jr. “没有灵丹妙药 - 软件工程中的本质和意外”,1986 年

介绍

注意:
本章的源代码可以在 GitHub 上找到: https://github.com/AMZN-Olex/O3DEBookCode2111/tree/ch15_unittests

在游戏开发中,一个经常被忽视的开发过程是单元测试。O3DE 引擎附带强大的 Google Test 和 Google Mock 框架。

本章将向您展示如何为 OscillatorComponent 编写一个简单的单元测试,我们在第 9 章 使用 AZ::TickBus 中实现。

单元测试生成目标

每当在 O3DE 中创建新 Gem 时,您都会获得一个默认的单元测试构建目标,例如 MyGem.Tests。

在 Visual Studio 解决方案中,您可以将其与其他生成目标一起找到。 但是,当我们生成 MyProject 时,它没有收到单元测试构建目标。我们需要 MyProject.Tests 构建目标来测试 OscillatorComponent,因为该组件位于 MyProject 中,而不是 Gem 中。虽然完全没有必要在项目中放置组件,但它将作为从头开始添加单元测试所需的一个很好的示例

图 15.1.单元测试在 Gem 中的位置

由于我们要向项目添加新的构建目标,因此我们必须修改 C:\git\book\MyProject\Code\CMakeLists.txt。这是要添加到CMakeLists.txt末尾的更改。

例 15.1. 添加 MyProject.Tests构建目标

# See if globally, tests are supported
if(PAL_TRAIT_BUILD_TESTS_SUPPORTED)
  ly_add_target(
    NAME MyProject.Tests ${PAL_TRAIT_TEST_TARGET_TYPE}
    NAMESPACE Gem
    FILES_CMAKE
      myproject_test_files.cmake
    INCLUDE_DIRECTORIES
      PRIVATE
        Tests
        Source
    BUILD_DEPENDENCIES
      PRIVATE
        AZ::AzTest
        AZ::AzFramework
        Gem::MyProject.Static
  )
  # Add MyProject.Tests to googletest
  ly_add_googletest(
    NAME Gem::MyProject.Tests
  )
endif()

这里的重要元素是:

  • 将 MyProject.Tests 目标标记为单元测试构建目标。
ly_add_googletest(
  NAME Gem::MyProject.Tests
)

这样,您就可以从 Visual Studio 将此目标作为 Google Unittest 启动。

  • 针对 MyProject.Static 的 MyProject.Tests 目标链接,后者具有带有 OscillatorComponent 的对象代码。
BUILD_DEPENDENCIES
  PRIVATE
    Gem::MyProject.Static
  • myproject_test_files.cmake 包含要为单元测试构建的文件。 例 15.2.myproject_test_files.cmake
set(FILES
  Tests/MyProjectTest.cpp
  Tests/OscillatorTests.cpp
)

图 15.2.单元测试在 Gem 中的位置

单元测试

MyProjectTest.cpp将用作单元测试设置

例 15.3. MyProjectTest.cpp

#include <AzTest/AzTest.h>
AZ_UNIT_TEST_HOOK(DEFAULT_UNIT_TEST_ENV);

运行单元测试

在我们开始修改单元测试之前,让我们先看看如何运行这些测试。在 Visual Studio 中,您可以通过右键单击项目并选择 Set as StartUp Project 来将 MyProject.Tests 设置为启动项目。运行项目将执行单元测试。

例 15.4.单元测试输出

[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from OscillatorTest
[ RUN      ] OscillatorTest.EntityMovingUp
[       OK ] OscillatorTest.EntityMovingUp (7 ms)
[----------] 1 test from OscillatorTest (8 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (9 ms total)
[  PASSED  ] 1 test.
提示:

如果您在 MyProject.Tests 的属性页中占有一席之地,则可以找到从命令行运行单元测试所需的步骤:

C:/O3DE/21.11.2/bin/Windows/profile/Default/AzTestRunner.exe
C:/git/book/build/bin/profile/MyProject.Tests.dll AzRunUnitTests

单元测试的结构

注意:
本章的代码可以在 GitHub 上找到,网址为: https://github.com/AMZN-Olex/O3DEBookCode2111/tree/ch15_unittests

O3DE 中游戏的大部分功能都是在组件中完成的。所以它只允许为组件编写单元测试。常见的方法如下:

  1. 创建一个 AZ::Entity。
  2. 添加您正在测试的组件。
  3. 添加您正在测试的交互中涉及的另一个组件。
  4. 调用触发或推进交互的 API(如 EBuses)。
  5. 测试结果。

例如,以下是我们将为 OscillatorComponent 编写的单元测试的结构(来自第 9 章,使用 AZ::TickBus):

  1. 如前所述创建 AZ::Entity。这不需要特别的。您可以在堆栈或堆上创建一个。
  2. 将 OscillatorComponent 添加到实体中。
  3. 添加 AzFramework::TransformComponent。
  4. 调用 TickBus,因为 OscillatorComponent 在 OnTick 方法中移动了实体。
  5. 测试 TransformComponent 是否在一次更新后向上移动。 我将介绍单元测试源代码文件的每个部分,并在最后显示整个代码。

内存分配器设置

O3DE 单元测试的难点是正确分配内存。您可以通过使用 AzCore/UnitTest/TestTypes.h 中的预制单元测试基类::UnitTest::AllocatorsFixture 来避免这种麻烦。它具有 SetUp 和 TearDown 虚拟方法,它从 GoogleTest 基类::testing::Test 中覆盖这些方法。

   #include <AzCore/UnitTest/TestTypes.h>
   ...
   class OscillatorTest
   :
   public ::UnitTest::AllocatorsFixture
   {
   ...
   ...
   void SetUp() override
   {
   ::UnitTest::AllocatorsFixture::SetUp();

这样,O3DE 内存分配器将得到正确设置,您无需处理错误,例如:

   'SystemAllocator' NOT ready for use! Call Create first!

如果您曾经尝试编写自己的 O3DE 单元测试,则可能已经看到过此类运行时崩溃。AllocatorsFixture 解决了这个问题。

注册组件

单元测试在精简环境中运行。没有加载 Gem,即使项目的组件也不会自动注册。

提示:
如果您尝试在单元测试中创建组件,但未先向 O3DE 注册该组件,则会触发运行时断言,例如: o3de\Code\Framework\AzCore\AzCore/UnitTest/UnitTest.h(155): error: Entity ‘6688814788’ [6688814788] cannot be activated. No descriptor registered for Component class ‘OscillatorComponent’.

这可以通过创建 AZ::SerializeContext 的实例并反映您希望使用的组件来解决。

    AZStd::unique_ptr<AZ::SerializeContext> m_sc;
    AZStd::unique_ptr<AZ::ComponentDescriptor> m_td;
    AZStd::unique_ptr<AZ::ComponentDescriptor> m_od;
 ...
 // register components involved in testing
        m_sc = AZStd::make_unique<AZ::SerializeContext>();
        m_td.reset(TransformComponent::CreateDescriptor());
        m_td->Reflect(m_sc.get());
        m_od.reset(OscillatorComponent::CreateDescriptor());
        m_od->Reflect(m_sc.get());

向实体添加组件

创建实体后,非常简单:

Entity e;

您可以添加我们之前注册的组件:

...
...
// helper method
void PopulateEntity(Entity& e)
{
// OscillatorComponent is the component we are testing
e.CreateComponent<OscillatorComponent>();
// And how it interacts with
e.CreateComponent<AzFramework::TransformComponent>();

激活实体

回想一下,在调用 Activate 方法之前,组件是非活动状态的。您可以通过初始化和激活实体来执行此作。

// Bring the entity online
e.Init();
e.Activate();

测试体

如前所述,此测试将触发一个 tick 事件,并在 TransformComponent 上测试结果。

TEST_F(OscillatorTest, EntityMovingUp)
{
Entity e;
PopulateEntity(e);
// Move entity to (0,0,0)
TransformBus::Event(e.GetId(),
&TransformBus::Events::SetWorldTranslation,
Vector3::CreateZero());
// tick once
TickBus::Broadcast(&TickBus::Events::OnTick, 0.1f,
            ScriptTimePoint());
 // Get entity's position
    Vector3 change;
    TransformBus::EventResult(change, e.GetId(),
        &TransformBus::Events::GetWorldTranslation);
 // check that it moved up, by any amount
    ASSERT_TRUE(change.GetZ() > 0);
 }
注意:
OscillatorTest 是此测试体继承自的单元测试装置。这就是我们可以直接使用 PopulateEntity 的原因。EntityMovingUp 是测试的名称。

单元测试钩子

O3DE 有一个包装 GoogleTest 的宏。您只需从其中一个测试文件中调用它一次。

 #include <AzTest/AzTest.h>
 AZ_UNIT_TEST_HOOK(DEFAULT_UNIT_TEST_ENV);

输出

如果一切顺利,您应该在运行单元测试时在命令行中看到以下输出。

 [----------] 1 test from OscillatorTest
 [ RUN      ] OscillatorTest.EntityMovingUp
 [       OK ] OscillatorTest.EntityMovingUp (1 ms)
 [----------] 1 test from OscillatorTest (2 ms total)

源代码

注意:
本章的源代码可以在 GitHub 上找到: https://github.com/AMZN-Olex/O3DEBookCode2111/tree/ch15_unittests

例 15.5. MyProjectTest.cpp

 #include <AzTest/AzTest.h>
 AZ_UNIT_TEST_HOOK(DEFAULT_UNIT_TEST_ENV);

例 15.6. OscillatorTest.cpp

 #include <OscillatorComponent.h>
 #include <AzCore/Component/Entity.h>
 #include <AzCore/Component/TransformBus.h>
 #include <AzCore/Serialization/SerializeContext.h>
 #include <AzCore/std/smart_ptr/unique_ptr.h>
 #include <AzCore/UnitTest/TestTypes.h>
 #include <AzFramework/Components/TransformComponent.h>
 #include <AzTest/AzTest.h>
 namespace MyProject
 {
 class OscillatorTest
        : 
public ::UnitTest::AllocatorsFixture
    {
        AZStd::unique_ptr<AZ::SerializeContext> m_sc;
        AZStd::unique_ptr<AZ::ComponentDescriptor> m_td;
        AZStd::unique_ptr<AZ::ComponentDescriptor> m_od;
 protected:
 void SetUp() override
        {
            ::UnitTest::AllocatorsFixture::SetUp();
 // register components involved in testing
            m_sc = AZStd::make_unique<AZ::SerializeContext>();
            m_td.reset(AzFramework::TransformComponent
                ::CreateDescriptor());
            m_td->Reflect(m_sc.get());
            m_od.reset(OscillatorComponent::CreateDescriptor());
            m_od->Reflect(m_sc.get());
        }
 void TearDown() override
        {
            m_td.reset();
            m_od.reset();
            m_sc.reset();
            ::UnitTest::AllocatorsFixture::TearDown();
        }
 // helper method
 void PopulateEntity(AZ::Entity& e)
        {
 // OscillatorComponent is the component we are testing
            e.CreateComponent<OscillatorComponent>();
 // And how it interacts with
            e.CreateComponent<AzFramework::TransformComponent>();
 // Bring the entity online
            e.Init();
            e.Activate();
        }
    };
    TEST_F(OscillatorTest, EntityMovingUp)
    {
 using namespace AZ;
        Entity e;
        PopulateEntity(e);
 // Move entity to (0,0,0)
        TransformBus::Event(e.GetId(),
            &TransformBus::Events::SetWorldTranslation,
            Vector3::CreateZero());
 // tick once
        TickBus::Broadcast(&TickBus::Events::OnTick, 0.1f,
            ScriptTimePoint());
 // Get entity's position
        Vector3 change = AZ::Vector3::CreateZero();
        TransformBus::EventResult(change, e.GetId(),
            &TransformBus::Events::GetWorldTranslation);
 // check that it moved up, by any amount
        ASSERT_TRUE(change.GetZ() > 0);
    }
 }