Introduction
The hardest part of writing tests in Unreal Engine 5 is often not the assertion syntax. It is deciding which testing system to use.
UE provides several overlapping options:
IMPLEMENT_SIMPLE_AUTOMATION_TEST- Complex Automation Tests
- Automation Spec
- CQTest
- Functional Tests
- Python Automation Tests
- Automation Driver
- Gauntlet
- Low-Level Tests
Games also require very different execution environments. Some rules can be tested as ordinary C++ functions. Others require UObject, UWorld, Actors, PIE, level streaming, UI input, rendering, network sessions, or a packaged build.
Trying to force all of those cases into one framework usually creates a slow and fragile test suite.
This article uses Unreal Engine 5.8 documentation as its baseline and explains how to divide responsibilities across C++ tests, CQTest, Automation Spec, asynchronous waits, Functional Tests, command-line execution, and CI.
Code sample scope: The snippets reference UE 5.8 APIs, but they are explanatory fragments rather than a complete sample project. Project-specific types, includes,
.Build.csdependencies, module API macros, and some setup code must be adapted to your project. The examples were not fully compiled in a UE 5.8 project while preparing this article.
The practical rule is simple:
Use the lightest execution environment that can prove the behavior you care about.
Which UE Test System Should You Use?
| Target | Good first choice | Typical environment |
|---|---|---|
| Math, rules, and data conversion | CQTest, Automation Spec, or Simple Automation Test | Editor / command line |
| Behavior expressed as readable scenarios | Automation Spec | Editor / command line |
| The same logic with many inputs | Complex Automation Test | Editor / command line |
| Actors, Components, Blueprints, and level wiring | Functional Test | Editor / PIE |
| Asset and content-production rules | Python Automation Test | Editor |
| Mouse, keyboard, scrolling, and UI flows | Automation Driver | Editor / Client |
| Packaged builds, devices, clients, and servers | Gauntlet | Build machine / device |
| Fast tests in a dedicated executable | Low-Level Tests | Dedicated test target |
Do not introduce everything at once. Keep most rule verification in fast C++ tests, add a small number of Functional Tests for critical gameplay wiring, and introduce Gauntlet only when the packaged runtime or multi-process environment is part of the requirement.
Separate Game Rules from Actors and Widgets
The most effective preparation for testing is to move deterministic rules out of Actors and Widgets.
// DamageCalculator.h
#pragma once
#include "CoreMinimal.h"
class FDamageCalculator
{
public:
static int32 CalculateFinalDamage(
const int32 BaseDamage,
const int32 Defense)
{
if (BaseDamage <= 0)
{
return 0;
}
return FMath::Max(1, BaseDamage - FMath::Max(0, Defense));
}
};
This function can be checked without creating a World or spawning an Actor. The gameplay Actor can read input values and apply the result, while a much smaller Functional Test verifies that the Unreal-side wiring is correct.
The goal is not to remove every Unreal type from the codebase. The goal is to separate:
- Logic that benefits from many fast boundary-value tests
- Engine integration that requires a World, Actor, Component, Blueprint, or level
Where to Put Test Code
Epic's documentation shows Automation Tests near the related module, commonly under Private/Tests.
Source/MyGame/
├─ Public/Combat/DamageCalculator.h
└─ Private/
├─ Combat/DamageCalculator.cpp
└─ Tests/DamageCalculatorTest.cpp
This is a reasonable starting point for small tests that only depend on modules such as Core and Engine.
However, wrapping registration code with WITH_DEV_AUTOMATION_TESTS does not remove .Build.cs dependencies. When tests begin to require CQTest, editor-only modules, or Functional Testing support, move them into a dedicated module such as Source/MyGameTests or a Tests plugin.
Source/MyGameTests/
├─ MyGameTests.Build.cs
└─ Private/
├─ Unit/
└─ Functional/
The examples below omit repeated guards for readability. In real files, guard C++ test registration code—Automation Tests, Specs, and CQTest tests—with the appropriate development-test configuration. Keep test-only classes and dependencies out of Shipping targets according to your project's build policy.
The sample names FInventoryService, FProfileService, APickupActor, and UInventoryComponent represent project-specific types. Replace them with your own implementations and dependencies.
Simple Automation Tests
A traditional Automation Test derives from FAutomationTestBase. A single test can be registered with IMPLEMENT_SIMPLE_AUTOMATION_TEST.
#include "Misc/AutomationTest.h"
#include "Combat/DamageCalculator.h"
#if WITH_DEV_AUTOMATION_TESTS
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDamageCalculatorMinimumDamageTest,
"MyGame.Unit.Combat.DamageCalculator.MinimumDamage",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ProductFilter)
bool FDamageCalculatorMinimumDamageTest::RunTest(const FString& Parameters)
{
const int32 Actual = FDamageCalculator::CalculateFinalDamage(10, 100);
TestEqual(
TEXT("Positive attacks return at least one damage"),
Actual,
1);
return true;
}
#endif
RunTest can use helpers such as TestEqual, TestTrue, TestFalse, and TestNotNull. Stop early when a missing prerequisite would make later code unsafe.
UObject* Object = CreateObjectUnderTest();
if (!TestNotNull(TEXT("The object under test was created"), Object))
{
return false;
}
Treat Test Names as a CI Hierarchy
The test name is both a display name and a command-line filter.
MyGame.Unit.Combat.DamageCalculator.MinimumDamage
MyGame.Integration.Save.LoadRoundTrip
MyGame.Functional.Gameplay.Pickup
A pattern such as Project.Layer.Feature.Subject.Condition makes it easy to run MyGame.Unit before submission and reserve MyGame.Functional for a heavier job.
Common flags include:
-
EditorContext: Run in the Editor -
ClientContext/ServerContext: Run in a client or server context -
SmokeFilter: Extremely short checks -
ProductFilter: Project or product functionality -
PerfFilter/StressFilter: Performance or stress testing -
NonNullRHI: Requires a rendering environment
ProductFilter is normally appropriate for game-project tests, while EngineFilter is intended for engine functionality. Epic's guidance treats Smoke Tests as checks that should complete in roughly one second or less.
CQTest for Fixture-Style C++ Tests
CQTest extends the Automation Test infrastructure with fixtures, setup and teardown, assertions, and latent actions.
Add CQTest to the module that builds the tests—not automatically to the product Runtime module.
PrivateDependencyModuleNames.AddRange(
new string[] { "Core", "CoreUObject", "Engine", "CQTest" }
);
A minimal test is concise:
#include "CQTest.h"
#include "Combat/DamageCalculator.h"
TEST(DamageCalculator_StandardReduction, "MyGame.Unit.Combat")
{
const int32 Actual = FDamageCalculator::CalculateFinalDamage(100, 30);
ASSERT_THAT(AreEqual(70, Actual));
}
Use TEST_CLASS when several tests share a fixture.
TEST_CLASS(InventoryServiceTest, "MyGame.Unit.Inventory")
{
TUniquePtr<FInventoryService> Inventory;
BEFORE_EACH()
{
Inventory = MakeUnique<FInventoryService>(10);
}
AFTER_EACH()
{
Inventory.Reset();
}
TEST_METHOD(AddItem_IncreasesCount)
{
ASSERT_THAT(IsTrue(Inventory->AddItem(TEXT("Potion"), 3)));
ASSERT_THAT(AreEqual(
3,
Inventory->GetItemCount(TEXT("Potion"))));
}
TEST_METHOD(AddItem_RejectsCapacityOverflow)
{
Inventory->AddItem(TEXT("Potion"), 10);
ASSERT_THAT(IsFalse(Inventory->AddItem(TEXT("Ether"), 1)));
}
};
Rebuilding the state before each method reduces order dependence. CQTest is a strong default for new fixture-style C++ tests, but an existing project that already uses Automation Spec consistently does not need to replace it only for stylistic reasons.
Automation Spec for Readable Behavior Scenarios
Automation Spec uses a BDD-style structure with Describe, BeforeEach, and It.
BEGIN_DEFINE_SPEC(
FInventoryServiceSpec,
"MyGame.Unit.Inventory.InventoryService",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ProductFilter)
TUniquePtr<FInventoryService> Inventory;
END_DEFINE_SPEC(FInventoryServiceSpec)
void FInventoryServiceSpec::Define()
{
BeforeEach([this]()
{
Inventory = MakeUnique<FInventoryService>(10);
});
Describe("when adding an item to an empty inventory", [this]()
{
It("increases the count by the added amount", [this]()
{
Inventory->AddItem(TEXT("Potion"), 3);
TestEqual(
TEXT("Potion count"),
Inventory->GetItemCount(TEXT("Potion")),
3);
});
});
}
Specs work well when the scenarios themselves should read like documentation. Epic recommends the .spec.cpp suffix.
Avoid deeply nested Describe blocks. Two or three levels are usually enough; beyond that, the active setup becomes difficult to understand.
Complex Automation Tests for Parameter Sets
A Complex Automation Test uses GetTests to expose multiple cases and passes each command string to RunTest.
#include "Misc/LexFromString.h"
IMPLEMENT_COMPLEX_AUTOMATION_TEST(
FDamageCalculatorCasesTest,
"MyGame.Unit.Combat.DamageCalculator.Cases",
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::ProductFilter)
void FDamageCalculatorCasesTest::GetTests(
TArray<FString>& Names,
TArray<FString>& Commands) const
{
Names.Add(TEXT("Standard"));
Commands.Add(TEXT("100,30,70"));
Names.Add(TEXT("MinimumDamage"));
Commands.Add(TEXT("10,100,1"));
}
bool FDamageCalculatorCasesTest::RunTest(const FString& Parameters)
{
TArray<FString> Values;
Parameters.ParseIntoArray(Values, TEXT(","));
if (!TestEqual(TEXT("Parameter count"), Values.Num(), 3))
{
return false;
}
int32 BaseDamage = 0;
int32 Defense = 0;
int32 Expected = 0;
if (!LexTryParseString(BaseDamage, *Values[0]) ||
!LexTryParseString(Defense, *Values[1]) ||
!LexTryParseString(Expected, *Values[2]))
{
AddError(FString::Printf(
TEXT("Could not parse numeric parameters: %s"),
*Parameters));
return false;
}
TestEqual(
TEXT("Final damage"),
FDamageCalculator::CalculateFinalDamage(BaseDamage, Defense),
Expected);
return true;
}
FCString::Atoi turns invalid text into zero, which can hide broken test data. LexTryParseString lets the test fail explicitly.
For only a few scenarios, separate CQTest or Spec cases may be more readable. Complex Tests become especially useful when enumerating every map, Data Table row, or asset in a folder because each input appears as a separate test result.
Testing Work That Spans Multiple Frames
Asset loading, level transitions, HTTP, async tasks, PIE startup, and similar work cannot finish in one frame. Do not block the Game Thread with Sleep; wait for an observable completion condition.
CQTest provides TestCommandBuilder. The following assumes that the same TEST_CLASS owns a TUniquePtr<FProfileService> initialized in BEFORE_EACH.
TEST_METHOD(LoadProfile_CompletesWithSavedName)
{
TestCommandBuilder
.Do([this]()
{
ProfileService->BeginLoad(TEXT("Player01"));
})
.Until(
TEXT("Wait for profile loading to complete or fail"),
[this]()
{
return ProfileService->IsLoadCompleted() ||
ProfileService->HasFailed();
},
FTimespan::FromSeconds(10))
.Then([this]()
{
ASSERT_THAT(IsFalse(ProfileService->HasFailed()));
ASSERT_THAT(AreEqual(
FString(TEXT("Alice")),
ProfileService->GetLoadedProfile().PlayerName));
})
.OnTearDown([this]()
{
ProfileService->Cancel();
});
}
Prefer Until over a fixed WaitDelay. A timeout must still exist: otherwise, a condition that never becomes true is one of the hardest CI failures to investigate. Include the operation, target ID, last observed state, and error code in diagnostics.
Traditional Simple Automation Tests can use DEFINE_LATENT_AUTOMATION_COMMAND and ADD_LATENT_AUTOMATION_COMMAND. For new code, CQTest or Spec latent features often keep the sequence easier to read.
Expected Warnings and Errors
The Automation Framework records warnings and errors emitted during a test. Register an error first when the error is the expected behavior.
AddExpectedError(
TEXT("Inventory capacity exceeded"),
EAutomationExpectedErrorFlags::Contains,
1);
The count is checked as well. Keep the matching text narrow: a broad Contains pattern can accidentally absorb an unrelated error. Prefer a stable identifier and an exact occurrence count.
Routine invalid input is often easier to test through return values or result types instead of Error logs. Reserve Error logging for genuinely abnormal states.
Functional Tests for Actors and Levels
Use Functional Tests for behavior that depends on Actors, Components, Blueprints, or level wiring—for example, collecting a pickup, opening a door through a trigger, AI movement, or startup spawning.
Enable the Functional Testing plugin and place a Functional Test Actor in a small dedicated test map. A C++ module that derives from AFunctionalTest needs the FunctionalTesting dependency.
The lifecycle is:
- Start preparation in
PrepareTest - Wait in
IsReadywhen preparation spans frames - Perform assertions in
StartTest - Call
FinishTestfor both success and failure - Restore state during cleanup
Forgetting FinishTest leaves the test running until timeout.
The following example expects an APickupFunctionalTest instance in a dedicated map, with PickupClass and Inventory configured in Details. It is intentionally simplified. A production test should usually own a test Pawn or Fixture Actor and obtain the Component from that fixture instead of depending on ordinary gameplay state.
// PickupFunctionalTest.h
#pragma once
#include "FunctionalTest.h"
#include "PickupFunctionalTest.generated.h"
class APickupActor;
class UInventoryComponent;
UCLASS()
class MYGAMETESTS_API APickupFunctionalTest : public AFunctionalTest
{
GENERATED_BODY()
protected:
virtual void PrepareTest() override;
virtual bool IsReady_Implementation() override;
virtual void StartTest() override;
virtual void CleanUp() override;
private:
UPROPERTY(EditAnywhere, Category = "Test")
TSubclassOf<APickupActor> PickupClass;
UPROPERTY(EditInstanceOnly, Category = "Test")
TObjectPtr<UInventoryComponent> Inventory;
TWeakObjectPtr<APickupActor> SpawnedPickup;
bool bPreparationFinished = false;
};
void APickupFunctionalTest::PrepareTest()
{
Super::PrepareTest();
bPreparationFinished = false;
if (PickupClass && Inventory && GetWorld())
{
APickupActor* Pickup = GetWorld()->SpawnActor<APickupActor>(
PickupClass,
GetActorLocation(),
GetActorRotation());
if (Pickup)
{
SpawnedPickup = Pickup;
RegisterAutoDestroyActor(Pickup);
}
}
bPreparationFinished = true;
}
bool APickupFunctionalTest::IsReady_Implementation()
{
// Wait only for preparation to finish so StartTest can report failures.
return bPreparationFinished;
}
void APickupFunctionalTest::StartTest()
{
Super::StartTest();
if (!PickupClass)
{
FinishTest(EFunctionalTestResult::Failed,
TEXT("PickupClass is not configured"));
return;
}
if (!Inventory)
{
FinishTest(EFunctionalTestResult::Failed,
TEXT("Inventory is not configured"));
return;
}
if (!GetWorld())
{
FinishTest(EFunctionalTestResult::Failed,
TEXT("GetWorld() returned nullptr"));
return;
}
if (!SpawnedPickup.IsValid())
{
FinishTest(EFunctionalTestResult::Failed,
TEXT("Failed to spawn APickupActor"));
return;
}
const int32 Before = Inventory->GetItemCount(TEXT("Potion"));
SpawnedPickup->Collect(Inventory);
const int32 After = Inventory->GetItemCount(TEXT("Potion"));
const bool bSucceeded = AssertEqual_Int(
After,
Before + 1,
TEXT("Collecting the pickup adds one Potion"),
this);
FinishTest(
bSucceeded
? EFunctionalTestResult::Succeeded
: EFunctionalTestResult::Failed,
bSucceeded ? TEXT("Succeeded") : TEXT("Item count did not increase"));
}
void APickupFunctionalTest::CleanUp()
{
SpawnedPickup.Reset();
bPreparationFinished = false;
Super::CleanUp();
}
RegisterAutoDestroyActor removes the spawned Actor when the test ends. Do not wait forever in IsReady when a class reference is missing or spawning failed. Complete preparation and report a specific failure in StartTest.
Prefer several small test maps over one giant functional-test map. Smaller maps reduce startup cost, hidden dependencies, and interference between tests.
Running Tests in the Editor
In UE 5.8, open the automation interface from Tools > Test Automation. Depending on the version, enabled plugins, and layout, it may also be available through Tools > Session Frontend and its Automation tab.
A typical workflow is:
- Enable the required testing plugins
- Compile C++ and restart the Editor
- Open Test Automation
- Select tests under
MyGame - Start the tests
- Inspect the result events for failures
When a test is missing, verify the module is loaded, the context flags match, dependencies such as CQTest are present, required plugins are enabled, and the Editor has been fully restarted. New registrations may not appear reliably after Live Coding alone.
Running Tests from the Command Line
Use Automation RunTest through -ExecCmds.
"C:\Program Files\Epic Games\UE_5.8\Engine\Binaries\Win64\UnrealEditor-Cmd.exe" ^
"D:\Projects\MyGame\MyGame.uproject" ^
-Unattended ^
-NoSplash ^
-NullRHI ^
-ExecCmds="Automation RunTest MyGame.Unit;Quit" ^
-ReportExportPath="D:\Projects\MyGame\Saved\AutomationReports\Unit"
-Unattended suppresses blocking dialogs. -ReportExportPath writes report data, including JSON and HTML-related files. Preserve both the report directory and Saved/Logs as CI artifacts.
Do Not Trust Only the Process Exit Code
Use the process result to detect startup failures, crashes, and job timeouts, but also parse the Automation Report JSON.
Fail the CI job when:
- A test failed or timed out
- A required test unexpectedly remained
NotRunor incomplete - The expected report JSON was not generated
Decide explicitly whether reasoned exclusions reported as Skipped are allowed. Preserve reports even on success, and upload HTML-related output plus logs on failure.
Functional Tests also need the map containing the test Actor.
"C:\Program Files\Epic Games\UE_5.8\Engine\Binaries\Win64\UnrealEditor-Cmd.exe" ^
"D:\Projects\MyGame\MyGame.uproject" ^
"/Game/Tests/Maps/L_PickupFunctionalTest" ^
-Unattended ^
-NoSplash ^
-ExecCmds="Automation RunTest MyGame.Functional.Gameplay.Pickup;Quit" ^
-ReportExportPath="D:\Projects\MyGame\Saved\AutomationReports\Pickup"
-NullRHI reduces cost when rendering is unnecessary. Remove it for screenshots, Slate, Viewports, Materials, GPU-specific checks, and tests marked NonNullRHI. Separate rendering and non-rendering jobs instead of assuming every Functional Test can run headlessly.
Groups can be defined in DefaultEngine.ini.
[/Script/AutomationController.AutomationControllerSettings]
+Groups=(Name="PreSubmit", Filters=((Contains="MyGame.Unit.", MatchFromStart=true)))
+Groups=(Name="Nightly", Filters=((Contains="MyGame.Functional.", MatchFromStart=true)))
-ExecCmds="Automation RunTest Group:PreSubmit;Quit"
Temporarily exclude a flaky test through configuration instead of commenting it out, and include both a reason and a removal condition.
+ExcludeTest=(Test="MyGame.Functional.Gameplay.Pickup",Reason="UE-12345: Remove after fixing spawn wait",Warn=False)
A Practical CI Split
Running every test on every change makes feedback too slow. Running everything only at night allows several unrelated changes to accumulate before a failure appears.
Pull Request or Pre-Submit
Keep this within a few minutes:
- CQTest and Simple Automation Tests for deterministic C++
- Important serialization round trips
- Critical data validation
- Smoke Tests that finish in roughly one second
Use -NullRHI when rendering is not required.
After Updating the Main Branch
Add moderately expensive integration checks:
- Small Functional Tests
- Important map loads
- Blueprint compilation
- Save/load with real files
- Asset Registry reference validation
Nightly or Scheduled Jobs
Reserve expensive environments for:
- Loading all maps and major assets
- Screenshot comparisons
- Multiple clients and a Dedicated Server
- Packaged builds
- Long-running memory or stress tests
- Target-device execution
This is where Gauntlet becomes useful. It can launch and coordinate multiple Unreal sessions, clients, and servers. Do not move ordinary rule tests into Gauntlet; use it when the packaged runtime or process topology is part of what must be proven.
Python Automation Tests for Content Workflows
Python tests are useful for naming rules, folder structure, import settings, and forbidden references. With the Python Automation Test support enabled, scripts under Content/Python named test_*.py can be discovered.
The following sample uses EditorAssetLibrary, so it assumes an Editor environment with the required Python and Editor Scripting plugins. Confirm API and plugin names for your exact engine version.
import unreal
assets = unreal.EditorAssetLibrary.list_assets(
"/Game/Characters",
recursive=True,
include_folder=False,
)
invalid_assets = [
path for path in assets
if not path.rsplit("/", 1)[-1].startswith(("SK_", "SM_", "T_", "MI_"))
]
max_reported = 20
preview = "\n".join(f"- {path}" for path in invalid_assets[:max_reported])
omitted = max(0, len(invalid_assets) - max_reported)
omitted_note = f"\n...and {omitted} more" if omitted else ""
if invalid_assets:
raise AssertionError(
f"Naming violations: {len(invalid_assets)}\n"
f"{preview}{omitted_note}\n"
"Rename assets to use SK_, SM_, T_, or MI_."
)
Do not dump thousands of paths into CI logs. Report the count, a limited preview, and the correction rule. When combining several checks, log each summary and raise an exception at the end; logging errors without failing can leave the CI job green.
Python execution does not automatically advance Editor ticks during asynchronous work. Split such operations with unreal.AutomationScheduler.add_latent_command.
Automation Driver for UI Input
Automation Driver simulates mouse input, clicks, keys, scrolling, and drag-and-drop. Give UI elements stable driver IDs instead of locating them only by visible text or hierarchy.
SNew(STextBlock)
.Text(ViewModel, &FViewModel::GetPlayerName)
.AddMetaData(FDriverMetaData::Id("PlayerNameLabel"));
The driver is disabled by default. Enable it at test start and always disable it during cleanup.
Synchronous Driver APIs block until completion. Calling them on the Game Thread can deadlock. Keep input operations outside the Game Thread, but do not move Slate or UObject validation indiscriminately to a worker thread. Slate TSharedPtr instances are not thread-safe by default and should not be copied into worker-thread lambdas.
A safe conceptual split is:
BeforeEach [Game Thread]
Create the Widget, Window, ViewModel, and Driver; enable the Driver
↓
Input phase [outside Game Thread]
Perform clicks, typing, scrolling, and other synchronous Driver actions
↓
Validation phase [Game Thread]
Inspect UObject, Slate, and ViewModel state
↓
AfterEach [Game Thread]
Destroy the Driver, close the Window, and disable the Driver
Use Spec async blocks or latent commands to return to the Game Thread after input completes. Ensure failure paths still reach Disable().
UI tests are slower and more fragile than service-layer tests. Limit them to representative flows such as starting from the title screen, saving settings, or selecting a save slot. Test detailed branches in C++ beneath the UI.
When to Use Low-Level Tests
UE5 Low-Level Tests use Catch2 and run through a dedicated test executable with their own .Build.cs and .Target.cs. Avoiding full Editor startup can make a large low-level suite much faster.
They are not limited strictly to pure C++; with the right target and modules, they can exercise UObject, assets, and Engine Components. However, they require dedicated build and CI setup, and World- or Editor-workflow-heavy tests may remain easier in Automation or Functional Tests.
Consider Low-Level Tests when you already have hundreds of small tests, Editor startup dominates execution time, or BuildGraph should run them as an independent job. For the first test in a project, CQTest or a Simple Automation Test is usually the shorter path.
Common Failure Modes
Waiting with Sleep
FPlatformProcess::Sleep can stop the Game Thread from ticking. Use latent actions and observable completion conditions.
Depending on Test Order
Each test must create its own state and restore it during teardown. A test that assumes a previous test already logged in will fail in isolation or parallel execution.
Leaving Files or Actors Behind
Register generated Actors with RegisterAutoDestroyActor. Write files under a test-only temporary directory and remove them in teardown. Clean stale artifacts before startup as well, and make cleanup run after failures.
Comparing Floating-Point Values Exactly
Use an explicit tolerance for physics, timing, and coordinates.
TestTrue(
TEXT("X is within tolerance"),
FMath::IsNearlyEqual(Actual.X, Expected.X, 0.1f));
Creating Unreproducible Random Tests
Fix the seed and print both the seed and generated input on failure. Randomized testing should supplement deterministic boundary cases, not replace them.
Writing Vague Failure Messages
Include the condition, expected value, actual value, and target ID. A developer should be able to begin investigation from the CI log alone.
Expecting Live Coding to Register Everything
When a new test is missing from the list, close the Editor, run a normal build, and restart it. Registration depends on module loading.
Starting with Coverage Percentage
Begin with failures that matter: save compatibility, currency rules, combat boundaries, data references, or a level that cannot start. Frequently changed rules and QA flows that are checked manually every time are also strong candidates.
The first objective is not a percentage. It is preventing one important regression from returning.
A Gradual Adoption Plan
- Add one World-independent rule test with CQTest or a Simple Automation Test.
- Add a regression test before fixing the next reproducible bug.
- Automate save/load compatibility and static-data validation.
- Stabilize one representative Functional Test in a small dedicated map.
- Commit the command-line scripts so local and CI execution use the same commands.
- Track duration, retry success rate, and timeout locations, then remove flakiness instead of normalizing reruns.
Conclusion
Unreal Engine testing works best as a layered system:
- CQTest, Automation Spec, and Simple or Complex Automation Tests for fast C++ behavior
- Functional Tests for Actors, Components, Blueprints, and level wiring
- Python Automation Tests for content-production rules
- Automation Driver for a small number of representative UI flows
- Gauntlet for packaged builds and multi-process environments
- Low-Level Tests when dedicated execution speed justifies the extra build setup
For multi-frame work, wait for explicit completion or failure conditions with a clear timeout. In CI, parse Automation Reports instead of trusting only the Editor process exit code.
Do not start by building an end-to-end test for the whole game. Start with deterministic tests that run quickly and fail with enough information to diagnose the problem.
The useful milestone is not “we reached a coverage target.” It is:
When an important old bug returns, the build stops automatically.
References
- Unreal Engine 5.8 is now available
- Automation Test Framework in Unreal Engine
- Write C++ Tests in Unreal Engine
- CQTest Test Framework for Unreal Engine
- Automation Spec in Unreal Engine
- Functional Testing in Unreal Engine
- Run Automation Tests in Unreal Engine
- Configure Automation Tests in Unreal Engine
- Automation Driver in Unreal Engine
- Write Editor Tests with Python in Unreal Engine
- Gauntlet Automation Framework in Unreal Engine
- Low-Level Tests in Unreal Engine
Top comments (0)