Unit Testing AActor and AActorComponent
Abstract
After I completed my first draft of an ASceneComponent
, I wanted to test it properly like I’m used to with frameworks like Google Test and Catch2.
To do so, I had to instantiate and initialize a World, ready to be used by my Actors.
To give you an idea on what I was able to do, here’s one of the tests I wrote:
It("Should allow to fire again if enough time has passed", [this]
{
FComponentOptions Opt;
Opt.FireRateRpm = 600;
Opt.HasInfiniteAmmo = true;
Opt.AmmoType.IsHitScan = true;
// Instantiate the component to test
auto* Component = CreateAndAttachComponent(Opt);
const double WaitingTimeBetweenEachShot = Component->GetSecondsBetweenShots() + 0.1;
Component->FireOnce();
// Here we tick our world created ad-hoc for our tests
World.Tick(WaitingTimeBetweenEachShot);
Component->FireOnce();
// We can choose a custom DeltaTime
World.Tick(WaitingTimeBetweenEachShot);
Component->FireOnce();
// Use the default DeltaTime if we don't care
World.Tick();
TestTrueExpr(DelegateHandler->OnShotFiredCounter == 3);
});
Steps
My current design consists in a module that:
- Uses a
UEngineSubsystem
to create/initialize/destroy theUWorld
instances. - A
FTestWorldHelper
that acts like aunique_ptr
for our Worlds. - A custom
UGameInstance
that registers itself with the new World.
To simplify this post, the method’s implementation will be provided inline, but you need to split it into header/source, expecially for the FTestWorldHelper
and the Subsystem
as there’s a recursive include otherwise.
Create a UGameInstance
and AGameModeBase
Why do we need these? Look at these lines of UWorld::BeginPlay
:
AGameModeBase* const GameMode = GetAuthGameMode(); // this just returns AuthorityGameMode
...
if (GameMode)
{
GameMode->StartPlay();
...
}
...
AGameModeBase::StartPlay
will eventually call AActor::BeginPlay
for each Actor loaded in the World, and because UWorld::HasBegunPlay
will return true, newly spawned Actors will get their BeginPlay
called too.
But when does a UWorld
acquire get a GameMode
? Here:
bool UWorld::SetGameMode(const FURL& InURL)
{
...
AuthorityGameMode = GetGameInstance()->CreateGameModeForURL(InURL, this);
...
}
UGameInstance::CreateGameModeForURL
creates and initializes the correct AGameModeBase
for that level.
So, to complete this step, let’s create an empty AGameModeBase
, that I called ATestWorldGameMode
.
Then, our Game Instance, UTestWorldGameInstance
:
UCLASS()
class TESTWORLD_API UTestWorldGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
void InitForTest(UWorld& World)
{
FWorldContext* TestWorldContext = GEngine->GetWorldContextFromWorld(&World);
check(TestWorldContext);
WorldContext = TestWorldContext;
WorldContext->OwningGameInstance = this;
World.SetGameInstance(this);
World.SetGameMode(FURL()); // Now the UWorld::AuthorityGameMode will be valid
Init();
}
// We don't care which game mode the base class has created, we always return ours.
virtual TSubclassOf<AGameModeBase> OverrideGameModeClass(
TSubclassOf<AGameModeBase> GameModeClass,
const FString& MapName,
const FString& Options,
const FString& Portal) const override
{
return ATestWorldGameMode::StaticClass();
}
};
Create a helper class to manage UWorld
ticking
Why do I need a wrapper for
UWorld
? Can’t I just callUWorld::Tick
?
Nope. Turns out that there’s plenty of stuff under the hood that is required to actually tick properly.
By searching all references of UWorld::Tick
you’ll end up here:
void UGameEngine::Tick( float DeltaSeconds, bool bIdleMode )
{
...
// Tick the world.
Context.World()->Tick( LEVELTICK_All, DeltaSeconds );
...
}
There’s a lot of stuff that is “ticked” before and after that, so to actually do a proper tick we need to:
// -- Extracted from UGameEngine::Tick
// Update subsystems.
// This assumes that UObject::StaticTick only calls ProcessAsyncLoading.
StaticTick(DeltaSeconds, !!GAsyncLoadingUseFullTimeLimit, GAsyncLoadingTimeLimit / 1000.f);
// Tick the world.
Context.World()->Tick( LEVELTICK_All, DeltaSeconds );
// Tick all tickable objects
FTickableGameObject::TickObjects(nullptr, LEVELTICK_All, false, DeltaTime);
// -- Extracted from FEngineLoop::Tick
// Increase the frame counters, otherwise Actors and Components will not tick again!
GFrameCounter++;
// need to process gamethread tasks at least once a frame no matter what
FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread);
// tick core ticker, threads & deferred commands
FThreadManager::Get().Tick();
FTSTicker::GetCoreTicker().Tick(DeltaTime);
GEngine->TickDeferredCommands();
This doesn’t include everything, Slate
isn’t ticked, viewport isn’t updated, seamless travel and async loading aren’t updated either etc.
I don’t need them, but if you do, look into FEngineLoop::Tick
, UGameEngine::Tick
and UWorld::Tick
.
Now, let’s create a C++ class FTestWorldHelper
:
#include "Engine/CoreSettings.h"
#include "HAL/ThreadManager.h"
class UTestWorldSubsystem;
class TESTWORLD_API FTestWorldHelper
{
// Subsystem that manages the created Worlds
UTestWorldSubsystem* Subsystem;
UWorld* World;
// If NOT shared, then we need to cleanup it when we are destroyed
bool IsSharedWorld;
decltype(GFrameCounter) OldGFrameCounter;
public:
explicit FTestWorldHelper() :
Subsystem(nullptr),
World(nullptr),
IsSharedWorld(false),
OldGFrameCounter(GFrameCounter)
{
}
explicit FTestWorldHelper(UTestWorldSubsystem* Subsystem,
UWorld* World,
bool IsSharedWorld)
: Subsystem(Subsystem),
World(World),
IsSharedWorld(IsSharedWorld),
OldGFrameCounter(GFrameCounter)
{}
// This class is like std::unique_ptr for UWorld instances
FTestWorldHelper(const FTestWorldHelper&) = delete;
FTestWorldHelper& operator=(const FTestWorldHelper&) = delete;
FTestWorldHelper(FTestWorldHelper&& Other) noexcept;
FTestWorldHelper& operator=(FTestWorldHelper&& Other) noexcept;
~FTestWorldHelper()
{
// World created just for us, so destroy it as we don't need it anymore
if (World && !IsSharedWorld)
{
GFrameCounter = OldGFrameCounter;
Subsystem->DestroyPrivateWorld(World->GetFName());
}
}
FORCEINLINE UWorld* operator->() const
{
check(World);
return World;
}
// Smallest DeltaTime with an exact representation.
// Personally I needed such a small default.
void Tick(float DeltaTime = 0.001953125) const
{
check(IsInGameThread());
check(World);
StaticTick(DeltaTime, !!GAsyncLoadingUseFullTimeLimit, GAsyncLoadingTimeLimit / 1000.f);
World->Tick(LEVELTICK_All, DeltaTime);
FTickableGameObject::TickObjects(nullptr, LEVELTICK_All, false, DeltaTime);
GFrameCounter++;
FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread);
FThreadManager::Get().Tick();
FTSTicker::GetCoreTicker().Tick(DeltaTime);
GEngine->TickDeferredCommands();
}
};
Create the UEngineSubsystem
that manages the UWorld
Why an Engine Subsystem? Well personally I wanted a Singleton that were available anywhere in my tests, and because I didn’t want to initialize a new UWorld
each time.
Let’s start by creating a UTestWorldSubsystem
:
USTRUCT()
struct FTestWorldData
{
GENERATED_BODY()
UPROPERTY()
TObjectPtr<UGameInstance> GameInstance;
UPROPERTY()
TObjectPtr<UWorld> World;
};
UCLASS()
class TESTWORLD_API UTestWorldSubsystem : public UEngineSubsystem
{
GENERATED_BODY()
TMap<FName, FTestWorldData> PrivateWorlds;
FTestWorldData SharedWorld;
static FTestWorldData MakeTestWorld(FName Name);
public:
virtual void Initialize(FSubsystemCollectionBase& Collection) override
{
Super::Initialize(Collection);
}
virtual void Deinitialize() override
{
// Dispose of the created Worlds
if (!PrivateWorlds.IsEmpty())
{
for (const auto& [Name, Env] : PrivateWorlds)
{
// This shouldn't happen, might want to log a warning
Env.World->DestroyWorld(true);
Env.GameInstance->RemoveFromRoot();
}
PrivateWorlds.Empty();
}
if (SharedWorld.World)
{
SharedWorld.World->DestroyWorld(true);
SharedWorld.GameInstance->RemoveFromRoot();
}
Super::Deinitialize();
}
FTestWorldHelper UTestWorldSubsystem::GetPrivateWorld(FName Name)
{
check(IsInGameThread());
checkf(PrivateWorlds.Find(Name) == nullptr, TEXT("This test world has already been created"));
const auto& [GameInstance, World] = PrivateWorlds.Add(Name, MakeTestWorld(Name));
return FTestWorldHelper{this, World, false};
}
FTestWorldHelper UTestWorldSubsystem::GetSharedWorld()
{
// Lazy initialize the shared World,
// because doing so in UEngineSubsystem::Initialize
// is too early!
if (!SharedWorld.World)
{
SharedWorld = MakeTestWorld("TestWorld_SharedWorld");
}
FTestWorldHelper Helper{this, SharedWorld.World, true};
return Helper;
}
void DestroyPrivateWorld(FName Name)
{
const auto& [GameInstance, World] = PrivateWorlds.FindAndRemoveChecked(Name);
World->DestroyWorld(true);
GameInstance->RemoveFromRoot();
}
};
Let’s analyze how we create a test World:
FTestWorldData UTestWorldSubsystem::MakeTestWorld(FName Name)
{
check(IsInGameThread());
// Create a Game World
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Game);
UWorld* World = UWorld::CreateWorld(EWorldType::Game, true, Name);
UTestWorldGameInstance* GameInstance = NewObject<UTestWorldGameInstance>();
GameInstance->AddToRoot(); // Avoids GC
WorldContext.SetCurrentWorld(World);
World->UpdateWorldComponents(true, true);
World->AddToRoot();
World->SetFlags(RF_Public | RF_Standalone);
// Engine shouldn't Tick this UWorld.
World->SetShouldTick(false);
// Remember UTestWorldGameInstance::InitForTest?
GameInstance->InitForTest(*World);
#if WITH_EDITOR
GEngine->BroadcastLevelActorListChanged();
#endif
World->InitializeActorsForPlay(FURL());
auto* Settings = World->GetWorldSettings();
// Unreal clamps the DeltaTime
Settings->MinUndilatedFrameTime = 0.0001;
Settings->MaxUndilatedFrameTime = 10;
// Finally, we start playing
World->BeginPlay();
return {GameInstance, World};
}
That’s all folks!
Our Worlds are ready to be used in our tests!
Real World Usage
Here’s an example on how I use this system:
BEGIN_DEFINE_SPEC(FBallisticWeaponComponent_Spec, "WeaponSystemPlugin.Runtime.BallisticWeaponComponent",
EAutomationTestFlags::ApplicationContextMask
| EAutomationTestFlags::HighPriority | EAutomationTestFlags::ProductFilter)
TObjectPtr<UTestWorldSubsystem> Subsystem;
FTestWorldHelper World;
TObjectPtr<AActor> Actor;
TObjectPtr<UBallisticWeaponComponent> PrevComponent;
struct FComponentOptions
{
...
};
UBallisticWeaponComponent* CreateAndAttachComponent(const FComponentOptions& Options = FComponentOptions())
{
...
return PrevComponent;
}
END_DEFINE_SPEC(FBallisticWeaponComponent_Spec)
void FBallisticWeaponComponent_Spec::Define()
{
Describe("The Ballistic Weapon Component", [this]
{
BeforeEach([this]
{
// Get the subsystem once
if (!Subsystem)
{
Subsystem = GEngine->GetEngineSubsystem<UTestWorldSubsystem>();
}
// Get our shared world, but could be private as well
World = Subsystem->GetSharedWorld();
// We can spawn actors!
Actor = World->SpawnActor<ATestWorldActor>();
});
Describe("When trying to fire once", [this]
{
It("Cant fire because there is no ammo", [this]
{
// This creates a component and attaches it to the Actor.
// UBallisticWeaponComponent::BeginPlay will be called!
auto* Component = CreateAndAttachComponent();
Component->FireOnce();
// Tick our Test World:
// Actor::Tick
// UBallisticWeaponComponent::TickComponent
World.Tick();
TestTrueExpr(DelegateHandler->OnShotFiredCounter == 0);
});
});
AfterEach([this]{
Actor->Destroy();
Actor = nullptr;
PrevComponent = nullptr;
});
});
}
Considerations
- It’s enough for my use case, and I’ve only tested it with a
USceneComponent
. - We aren’t ticking everything, like level streaming/transitioning etc. because I don’t need it.
- For complex tests you might want to use Gauntlet, as it’s a real running game, and provides more features.
- The current design might leak state due to the SharedWorld being, well, shared. Proper Setup/Cleanup in a test is required.
- The current design creates a dedicated GameInstance per World, further research is needed to see if we can use a single
UGameInstance
for multipleUWorld
instances, but I didn’t need such “optimization”.
Credits
I wouldn’t have accomplished all of this without the help of:
- Unreal Slackers’s Discord,
#cpp
channel! UE5Coro TestWorld
credits to the repo owner.- Epic Games for writing readable code :D