Version:

第16章 使用Mock组件进行单元测试

第16章 使用Mock组件进行单元测试

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

使用 Mock 进行测试

第 15 章 为组件编写单元测试 编写了一个涉及两个实际组件的单元测试。另一种常见模式是编写一个测试,该测试涉及一个 real 组件和一个模拟另一个 mock 组件。在这种情况下,实际组件将是 OscillatorComponent,而 mock 将是 MockTransformComponent。

此类测试的目的是检查 OscillatorComponent 是否调用了特定的 EBus 调用。这与检查某个变量是否具有预期值不同。相反,这种类型的单元测试检查是否调用了特定接口。因此,我们编写一个单元测试,检查 OscillatorComponent 是否在类似 Transform 的组件上调用 SetWorldTranslation。

“mock” 组件是伪测试组件。创建它是为了测试 mock 和一些真实组件之间的交互。它是一个强大的测试工具。O3DE 附带了 Google Mock,它提供了一个强大的 C++ 模拟框架。

模拟 TransformComponent

从概念上讲,O3DE 中的组件由它实现的事件总线处理程序标识。你可以选择让 mock 对象继承 TransformComponent 实现的所有此类处理程序。但是,在 OscillatorComponent 与 TransformComponent 交互的情况下,我们知道只涉及 TransformBus,因此只处理 TransformBus::Handler 就足够了。

#include <AzCore/Component/TransformBus.h>
...
/**
* \brief Pretend to be a TransformComponent
  */
  class MockTransformComponent
  :  public AZ::Component
  ,  public AZ::TransformBus::Handler
  {

当然,它仍然必须是一个 O3DE 组件,所以它必须继承自 AZ::Component 并实现最基本的组件接口。

...
...
// be sure this guid is unique, avoid copy-paste errors!
AZ_COMPONENT(MockTransformComponent, "{7E8087BD-46DA-4708-ADB0-08D7812CA49F}");
// Just a mock object, no reflection necessary
static void Reflect(ReflectContext*) {}
// Mocking out pure virtual methods
void Activate() override
{
  AZ::TransformBus::Handler::BusConnect(GetEntityId());
}
void Deactivate() override
{
  AZ::TransformBus::Handler::BusDisconnect();
}

由于 MockTransformComponent 需要处理 TransformBus 接口,因此它必须连接到总线。

模拟虚拟方法

现在我们来看看 Google Mock 的第一个功能:模拟虚拟方法。

class MockTransformComponent
  ...
  // OscillatorComponent will be calling these methods
  MOCK_METHOD1(SetWorldTranslation, void (const AZ::Vector3&));
  MOCK_METHOD0(GetWorldTranslation, AZ::Vector3 ());
  ...

这些将覆盖 TransformBus 的虚拟方法:

class TransformInterface {
  ...
  virtual void SetWorldTranslation(const AZ::Vector3&);
  virtual AZ::Vector3 GetWorldTranslation()

MOCK_METHOD0 , MOCK_METHOD1 等为这些方法提供了许多附加功能的存根实现。在本章中,将使用 SetWorldTranslation 和 GetWorldTranslation。TransformBus 中的其他一些纯虚拟方法则不会。这些仍然需要被模拟出来,这样我们才能实例化这个对象:

// Unused methods but they are pure virtual in TransformBus
MOCK_METHOD0(IsStaticTransform, bool ());
MOCK_METHOD0(IsPositionInterpolated, bool ());
MOCK_METHOD0(IsRotationInterpolated, bool ());
MOCK_METHOD0(GetLocalTM, const Transform& ());
MOCK_METHOD0(GetWorldTM, const Transform& ());
MOCK_METHOD1(BindTransformChangedEventHandler, void(TransformChangedEvent::Handler&));
MOCK_METHOD1(BindParentChangedEventHandler, void(ParentChangedEvent::Handler&));
MOCK_METHOD1(BindChildChangedEventHandler, void(ChildChangedEvent::Handler&));
MOCK_METHOD2(NotifyChildChangedEvent, void(ChildChangeType, EntityId));

所以整个 mock 定义如下:

例 16.1.MockTransformComponent 定义

 /**
 * \brief Pretend to be a TransformComponent
 */
 class MockTransformComponent
    : 
public AZ::Component
    , 
public AZ::TransformBus::Handler
 {
 public:
 // be sure this guid is unique, avoid copy-paste errors!
    AZ_COMPONENT(MockTransformComponent,
 "{7E8087BD-46DA-4708-ADB0-08D7812CA49F}");
 // Just a mock object, no reflection necessary
 static void Reflect(ReflectContext*) {}
 // Mocking out pure virtual methods
 void Activate() override
    {
        AZ::TransformBus::Handler::BusConnect(GetEntityId());
    }
 void Deactivate() override
    {
        AZ::TransformBus::Handler::BusDisconnect();
    }
 // OscillatorComponent will be calling these methods
    MOCK_METHOD1(SetWorldTranslation, void (const AZ::Vector3&));
    MOCK_METHOD0(GetWorldTranslation, AZ::Vector3 ());
 // Unused methods but they are pure virtual in TransformBus
    MOCK_METHOD0(IsStaticTransform, bool ());
    MOCK_METHOD0(IsPositionInterpolated, bool ());
    MOCK_METHOD0(IsRotationInterpolated, bool ());
    MOCK_METHOD0(GetLocalTM, const Transform& ());
    MOCK_METHOD0(GetWorldTM, const Transform& ());
    MOCK_METHOD1(BindTransformChangedEventHandler,
 void(TransformChangedEvent::Handler&));
    MOCK_METHOD1(BindParentChangedEventHandler,
 void(ParentChangedEvent::Handler&));
    MOCK_METHOD1(BindChildChangedEventHandler,
     void(ChildChangedEvent::Handler&));
    MOCK_METHOD2(NotifyChildChangedEvent,
 void(ChildChangeType, EntityId));
 };

使用 Mock 组件设置 Entity

与上一章非常相似,我们将创建一个可重用的 test fixture 来处理内存分配器和组件的注册。

class OscillatorMockTest
: public ::UnitTest::AllocatorsFixture
{
  AZStd::unique_ptr<AZ::SerializeContext> m_sc;
  AZStd::unique_ptr<AZ::ComponentDescriptor> m_md;
  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_md.reset(MockTransformComponent::CreateDescriptor());
    m_md->Reflect(m_sc.get());
    m_od.reset(OscillatorComponent::CreateDescriptor());
    m_od->Reflect(m_sc.get());
  }
...

区别在于我们如何将组件附加到实体。具体来说,我们需要保留一个指向 MockTransformComponent 的指针,这样我们就可以在测试体中对它执行一些 Google Mock 魔法作。

  ...
};
// helper method
void PopulateEntity(Entity& e)
{
  // OscillatorComponent is the component we are testing
  e.CreateComponent<OscillatorComponent>();
  // We can mock out Transform and test the interaction
  mock = new MockTransformComponent();
  e.AddComponent(mock);
  // Bring the entity online
  e.Init();
  e.Activate();
}
MockTransformComponent* mock = nullptr;

现在,我们有一个 helper 方法来创建和激活实体,同时保留指向 mock 的指针。

注意:
e.AddComponent(mock) 将组件实例的内存所有权传递给实体,因此不要尝试删除测试体中的 mock 指针。实体析构函数将处理该问题。

设置对接口调用的期望

现在我们准备好编写测试体了。它从准备实体开始。

TEST_F(OscillatorMockTest, Calls_SetWorldTranslation)
{
  Entity e;
  PopulateEntity(e);

我们将在本章中使用的一个功能是设置对调用特定方法的期望。例如,我们将测试 SetWorldTranslation 是否被调用了一次。

// expect SetWorldTranslation() to be called
EXPECT_CALL(*mock, SetWorldTranslation(_)).Times(1);
注意:
SetWorldTranslation() 中的下划线是一个特殊的 Google Mock 对象 ::test ing:: 匹配任何参数。因此,此语句为:“预期使用任何参数调用 SetWorldTranslation 一次。

回想一下,OscillatorComponent 调用 GetWorldTranslation 来获取当前位置。我们在 MockTransformComponent 中将其模拟为:

MOCK_METHOD0(GetWorldTranslation, AZ::Vector3 ());

所以默认情况下它不会返回任何值。如果您的测试在没有任何额外准备的情况下运行并调用此方法,它将在运行时出错并显示错误:

[ RUN      ] OscillatorMockTest.Calls_SetWorldTranslation
unknown file: error: C++ exception with description
"Uninteresting mock function call - returning default value.
Function call: GetWorldTranslation()
The mock function has no default action set, and its return
type has no default value set." thrown in the test body.

Google Mock 提供了一种自定义 GetWorldTranslation 行为的方法,而无需修改 MockTransformComponent。以下是我们如何将其设置为返回 (0,0,0) 的向量:

// setup a return value for GetWorldTranslation()
ON_CALL(*mock, GetWorldTranslation()).WillByDefault(
Return(AZ::Vector3::CreateZero()));

在简单的英语中,这意味着“每当调用 GetWorldTranslation 时返回 Vector3::CreateZero”。

有了这个,测试体就简短而甜蜜了:

TEST_F(OscillatorMockTest, Calls_SetWorldTranslation)
{
    Entity e;
    PopulateEntity(e);
 // setup a return value for GetWorldTranslation()
    ON_CALL(*mock, GetWorldTranslation()).WillByDefault(
        Return(AZ::Vector3::CreateZero()));
 // expect SetWorldTranslation() to be called
    EXPECT_CALL(*mock, SetWorldTranslation(_)).Times(1);
    TickBus::Broadcast(&TickBus::Events::OnTick, 0.1f,
            ScriptTimePoint());
 }

它执行以下作:

  1. 为测试准备实体。
  2. 设置 GetWorldTranslation 的默认返回值。
  3. 设置对 TransformBus 上的 OscillatorComponent 调用的期望值。
  4. 滴答一次。
  5. 在范围结束时,Google Mock 将为我们完成工作,以确保达到预期。
提示:

如果您要运行此类测试,您会看到 Google Mock 的警告:

   GMOCK WARNING:
   Uninteresting mock function call - taking default action specified
   MyProject\Gem\Code\Tests\MyProjectTest.cpp(105):
   Function call: GetWorldTranslation()
   Returns: 16-byte object <00-00 ... 00-00>
   NOTE: You can safely ignore the above warning unless this call
   should not happen.  Do not suppress it by blindly adding
   an EXPECT_CALL() if you don't mean to enforce the call.  

Google Mock 在此警告中告诉我们,GetWorldTranslation 的调用方式与我们预期的 OscillatorComponent 相同,但测试主体没有对此指定任何期望。正如警告所暗示的那样,一个好的经验法则是只测试真正重要的东西。在这种情况下,SetWorldTranslation 是重要的调用,并且已设置了对它的期望。

一旦你确定测试正常,你可以通过将创建时 mock 对象的类型从以下更改为:

   // We can mock out Transform and test the interaction
   mock = new MockTransformComponent();
   e.AddComponent(mock);

到:

   // We can mock out Transform and test the interaction
   mock = new NiceMock<MockTransformComponent>();
   e.AddComponent(mock);

NiceMock 是一个 Google Mock 类,它静默地忽略对 mock 对象的意外调用。

小结

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

如果一切顺利,您应该会看到本章单元测试的以下输出:

[----------] 1 test from OscillatorMockTest
[ RUN      ] OscillatorMockTest.Calls_SetWorldTranslation
[       OK ] OscillatorMockTest.Calls_SetWorldTranslation (2 ms)
[----------] 1 test from OscillatorMockTest (3 ms total)

为了完整起见,这是完整的源代码。

例 16.2.OscillatorMockTest.cpp

 #include <OscillatorComponent.h>
 #include <AzCore/Component/ComponentApplication.h>
 #include <AzCore/Component/Entity.h>
 #include <AzCore/Component/TransformBus.h>
 #include <AzCore/std/smart_ptr/unique_ptr.h>
 #include <AzCore/UnitTest/TestTypes.h>
 #include <AzTest/AzTest.h>
 using namespace AZ;
 using namespace AZStd;
 using namespace MyProject;
 using namespace ::testing;
 /**
 * \brief Pretend to be a TransformComponent
 */
 class MockTransformComponent
    : 
public AZ::Component
    , 
public AZ::TransformBus::Handler
 {
 public:
 // be sure this guid is unique, avoid copy-paste errors!
    AZ_COMPONENT(MockTransformComponent,
 "{7E8087BD-46DA-4708-ADB0-08D7812CA49F}");
 // Just a mock object, no reflection necessary
 static void Reflect(ReflectContext*) {}
 // Mimic Transform component service
 static void GetProvidedServices(
 AZ::ComponentDescriptor::DependencyArrayType& req)
    {
    }
        req.push_back(AZ_CRC("TransformService"));
 // Mocking out pure virtual methods
 void Activate() override
    {
        AZ::TransformBus::Handler::BusConnect(GetEntityId());
    }
 void Deactivate() override
    {
        AZ::TransformBus::Handler::BusDisconnect();
    }
 // OscillatorComponent will be calling these methods
    MOCK_METHOD1(SetWorldTranslation, void (const AZ::Vector3&));
    MOCK_METHOD0(GetWorldTranslation, AZ::Vector3 ());
 // Unused methods but they are pure virtual in TransformBus
    MOCK_METHOD0(IsStaticTransform, bool ());
    MOCK_METHOD0(IsPositionInterpolated, bool ());
    MOCK_METHOD0(IsRotationInterpolated, bool ());
    MOCK_METHOD0(GetLocalTM, const Transform& ());
    MOCK_METHOD0(GetWorldTM, const Transform& ());
    MOCK_METHOD1(BindTransformChangedEventHandler,
 void(TransformChangedEvent::Handler&));
    MOCK_METHOD1(BindParentChangedEventHandler,
 void(ParentChangedEvent::Handler&));
    MOCK_METHOD1(BindChildChangedEventHandler,
 void(ChildChangedEvent::Handler&));
    MOCK_METHOD2(NotifyChildChangedEvent,
 void(ChildChangeType, EntityId));
 };
 class OscillatorMockTest
    : 
public ::UnitTest::AllocatorsFixture
 {
    AZStd::unique_ptr<AZ::SerializeContext> m_sc;
    AZStd::unique_ptr<AZ::ComponentDescriptor> m_md;
    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_md.reset(MockTransformComponent::CreateDescriptor());
        m_md->Reflect(m_sc.get());
        m_od.reset(OscillatorComponent::CreateDescriptor());
        m_od->Reflect(m_sc.get());
    }
    void TearDown() override
    {
        m_md.reset();
        m_od.reset();
        m_sc.reset();
        ::UnitTest::AllocatorsFixture::TearDown();
    }
 // helper method
 void PopulateEntity(Entity& e)
    {
 // OscillatorComponent is the component we are testing
        e.CreateComponent<OscillatorComponent>();
 // We can mock out Transform and test the interaction
        mock = new NiceMock<MockTransformComponent>();
        e.AddComponent(mock);
 // Bring the entity online
        e.Init();
        e.Activate();
    }
    MockTransformComponent* mock = nullptr;
 };
 TEST_F(OscillatorMockTest, Calls_SetWorldTranslation)
 {
    Entity e;
    PopulateEntity(e);
 // setup a return value for GetWorldTranslation()
    ON_CALL(*mock, GetWorldTranslation()).WillByDefault(
        Return(AZ::Vector3::CreateZero()));
 // expect SetWorldTranslation() to be called
    EXPECT_CALL(*mock, SetWorldTranslation(_)).Times(1);
    TickBus::Broadcast(&TickBus::Events::OnTick, 0.1f,
            ScriptTimePoint());
 }