Automation Testing
IMPLEMENT_SIMPLE/COMPLEX_AUTOMATION_TEST와 Spec 기반 테스트
UE5 Automation Test Framework
언리얼 엔진의 자동화 테스트 시스템 이해
테스트 타입
| 테스트 타입 | 용도 | 실행 시간 | 사용 시점 |
|---|---|---|---|
Unit Test |
개별 함수/클래스 검증 | 밀리초 | 코드 변경 시마다 |
Feature Test |
기능 단위 검증 | 초 | 기능 완성 후 |
Smoke Test |
빠른 기본 동작 확인 | 초 | 빌드 후 |
Stress Test |
성능/안정성 검증 | 분 | 릴리스 전 |
테스트 플래그
// 테스트 컨텍스트 플래그
EAutomationTestFlags::EditorContext // 에디터에서 실행
EAutomationTestFlags::ClientContext // 게임 클라이언트에서 실행
EAutomationTestFlags::ServerContext // 서버에서 실행
EAutomationTestFlags::CommandletContext // 커맨드렛에서 실행
EAutomationTestFlags::ApplicationContextMask // 모든 컨텍스트
// 테스트 필터 플래그
EAutomationTestFlags::SmokeFilter // 스모크 테스트 (빠른 검증)
EAutomationTestFlags::EngineFilter // 엔진 레벨 테스트
EAutomationTestFlags::ProductFilter // 제품 레벨 테스트
EAutomationTestFlags::PerfFilter // 성능 테스트
EAutomationTestFlags::StressFilter // 스트레스 테스트
EAutomationTestFlags::NegativeFilter // 실패 케이스 테스트
// 조합 예시
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter
에디터에서: Window - Developer Tools - Session Frontend - Automation 탭
명령줄에서:
UnrealEditor.exe MyProject -ExecCmds="Automation RunTests MyGame" -unattended -nopause
Simple Automation Test
단순한 유닛 테스트 작성하기
기본 구조
#include "Misc/AutomationTest.h"
// 테스트 코드는 WITH_DEV_AUTOMATION_TESTS로 감싸기
#if WITH_DEV_AUTOMATION_TESTS
/**
* 간단한 유닛 테스트 예제
*/
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FMyMathTest, // 테스트 클래스명
"MyGame.Core.Math.Addition", // 테스트 경로 (계층적)
EAutomationTestFlags::EditorContext |
EAutomationTestFlags::EngineFilter |
EAutomationTestFlags::ProductFilter
)
bool FMyMathTest::RunTest(const FString& Parameters)
{
// 테스트 로직
int32 Result = 2 + 2;
// 검증 함수들
TestEqual(TEXT("2 + 2 should equal 4"), Result, 4);
TestTrue(TEXT("Result should be positive"), Result > 0);
TestFalse(TEXT("Result should not be negative"), Result < 0);
return true; // 테스트 완료
}
#endif // WITH_DEV_AUTOMATION_TESTS
검증 함수 목록
// 동등 비교
TestEqual(TEXT("Description"), Actual, Expected);
TestNotEqual(TEXT("Description"), Actual, NotExpected);
// 불리언 검증
TestTrue(TEXT("Description"), bCondition);
TestFalse(TEXT("Description"), bCondition);
// 포인터 검증
TestNull(TEXT("Description"), Pointer);
TestNotNull(TEXT("Description"), Pointer);
// 유효성 검증
TestValid(TEXT("Description"), SharedPtr);
TestInvalid(TEXT("Description"), SharedPtr);
// 범위 검증
TestEqual(TEXT("Float comparison"), Actual, Expected, Tolerance);
// 문자열 검증
TestEqual(TEXT("String comparison"), ActualString, ExpectedString);
// 수동 실패
AddError(TEXT("Error message"));
AddWarning(TEXT("Warning message"));
실제 게임 코드 테스트 예제
#if WITH_DEV_AUTOMATION_TESTS
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FInventoryAddItemTest,
"MyGame.Inventory.AddItem",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter
)
bool FInventoryAddItemTest::RunTest(const FString& Parameters)
{
// Arrange - 테스트 준비
FInventoryContainer Inventory;
FItemData TestItem;
TestItem.ItemId = FName(TEXT("TestSword"));
TestItem.Quantity = 1;
TestItem.MaxStackSize = 99;
// Act - 테스트 실행
bool bResult = Inventory.AddItem(TestItem);
// Assert - 결과 검증
TestTrue(TEXT("AddItem should return true"), bResult);
TestEqual(TEXT("Item count should be 1"), Inventory.GetItemCount(), 1);
TestEqual(TEXT("TestSword quantity should be 1"),
Inventory.GetQuantity(FName(TEXT("TestSword"))), 1);
// 스택 테스트
Inventory.AddItem(TestItem); // 같은 아이템 추가
TestEqual(TEXT("Item count should still be 1 (stacked)"),
Inventory.GetItemCount(), 1);
TestEqual(TEXT("TestSword quantity should be 2"),
Inventory.GetQuantity(FName(TEXT("TestSword"))), 2);
return true;
}
#endif
Complex Automation Test
파라미터화된 테스트 케이스 작성
파라미터화된 테스트
#if WITH_DEV_AUTOMATION_TESTS
IMPLEMENT_COMPLEX_AUTOMATION_TEST(
FDamageCalculationTest,
"MyGame.Combat.DamageCalculation",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter
)
// 테스트 케이스 목록 생성
void FDamageCalculationTest::GetTests(
TArray<FString>& OutBeautifiedNames,
TArray<FString>& OutTestCommands) const
{
// 테스트 케이스 정의: DisplayName, Parameters (CSV 형식)
// 기본 물리 데미지
OutBeautifiedNames.Add(TEXT("Physical Damage - No Armor"));
OutTestCommands.Add(TEXT("100,0,Physical,100")); // 공격력,방어력,타입,예상결과
OutBeautifiedNames.Add(TEXT("Physical Damage - With Armor"));
OutTestCommands.Add(TEXT("100,50,Physical,50"));
// 마법 데미지
OutBeautifiedNames.Add(TEXT("Magic Damage - No Resistance"));
OutTestCommands.Add(TEXT("100,0,Magic,100"));
OutBeautifiedNames.Add(TEXT("Magic Damage - With Resistance"));
OutTestCommands.Add(TEXT("100,30,Magic,70"));
// 크리티컬
OutBeautifiedNames.Add(TEXT("Critical Hit"));
OutTestCommands.Add(TEXT("100,0,Physical,200,true")); // 크리티컬 플래그 추가
}
// 각 테스트 케이스 실행
bool FDamageCalculationTest::RunTest(const FString& Parameters)
{
// 파라미터 파싱
TArray<FString> Params;
Parameters.ParseIntoArray(Params, TEXT(","));
if (Params.Num() < 4)
{
AddError(TEXT("Invalid parameter count"));
return false;
}
float AttackPower = FCString::Atof(*Params[0]);
float Defense = FCString::Atof(*Params[1]);
FString DamageType = Params[2];
float ExpectedDamage = FCString::Atof(*Params[3]);
bool bIsCritical = Params.Num() > 4 && Params[4] == TEXT("true");
// 데미지 계산
FDamageCalculator Calculator;
float ActualDamage = Calculator.CalculateDamage(
AttackPower, Defense, DamageType, bIsCritical);
// 검증
TestEqual(
FString::Printf(TEXT("Damage should be %.0f"), ExpectedDamage),
ActualDamage,
ExpectedDamage,
0.01f // 허용 오차
);
return true;
}
#endif
- 동일한 테스트 로직으로 여러 케이스 검증
- 테스트 케이스 추가가 데이터 추가만으로 가능
- 에디터에서 각 케이스를 개별적으로 확인 가능
Spec 기반 테스트 (UE5 권장)
BDD 스타일의 구조화된 테스트 작성
Spec 테스트 기본 구조
#if WITH_DEV_AUTOMATION_TESTS
// Spec 정의 시작
BEGIN_DEFINE_SPEC(
FInventorySpec, // 클래스명
"MyGame.Inventory", // 테스트 경로
EAutomationTestFlags::ProductFilter |
EAutomationTestFlags::ApplicationContextMask
)
// 테스트에서 사용할 멤버 변수
TSharedPtr<FInventoryContainer> Inventory;
FItemData TestSword;
FItemData TestPotion;
END_DEFINE_SPEC(FInventorySpec)
// Spec 구현
void FInventorySpec::Define()
{
// 테스트 그룹: Describe
Describe("Adding items", [this]()
{
// 각 테스트 전 실행
BeforeEach([this]()
{
Inventory = MakeShared<FInventoryContainer>();
TestSword.ItemId = FName(TEXT("Sword"));
TestSword.Quantity = 1;
TestSword.MaxStackSize = 1;
TestPotion.ItemId = FName(TEXT("Potion"));
TestPotion.Quantity = 1;
TestPotion.MaxStackSize = 99;
});
// 각 테스트 후 실행
AfterEach([this]()
{
Inventory.Reset();
});
// 개별 테스트: It
It("should increase item count when adding new item", [this]()
{
Inventory->AddItem(TestSword);
TestEqual(TEXT("Item count"), Inventory->GetItemCount(), 1);
});
It("should stack stackable items", [this]()
{
Inventory->AddItem(TestPotion);
Inventory->AddItem(TestPotion);
TestEqual(TEXT("Item count"), Inventory->GetItemCount(), 1);
TestEqual(TEXT("Potion quantity"),
Inventory->GetQuantity(TestPotion.ItemId), 2);
});
It("should not stack non-stackable items", [this]()
{
Inventory->AddItem(TestSword);
bool bResult = Inventory->AddItem(TestSword);
TestFalse(TEXT("Should fail to add duplicate"), bResult);
});
});
Describe("Removing items", [this]()
{
BeforeEach([this]()
{
Inventory = MakeShared<FInventoryContainer>();
TestPotion.ItemId = FName(TEXT("Potion"));
TestPotion.Quantity = 5;
Inventory->AddItem(TestPotion);
});
It("should decrease quantity when removing", [this]()
{
Inventory->RemoveItem(TestPotion.ItemId, 2);
TestEqual(TEXT("Remaining quantity"),
Inventory->GetQuantity(TestPotion.ItemId), 3);
});
It("should remove item entry when quantity reaches zero", [this]()
{
Inventory->RemoveItem(TestPotion.ItemId, 5);
TestEqual(TEXT("Item count"), Inventory->GetItemCount(), 0);
});
It("should fail when removing more than available", [this]()
{
bool bResult = Inventory->RemoveItem(TestPotion.ItemId, 10);
TestFalse(TEXT("Should fail"), bResult);
TestEqual(TEXT("Quantity unchanged"),
Inventory->GetQuantity(TestPotion.ItemId), 5);
});
});
// 중첩 Describe
Describe("Inventory limits", [this]()
{
Describe("when inventory is full", [this]()
{
BeforeEach([this]()
{
Inventory = MakeShared<FInventoryContainer>(2); // 2칸 제한
FItemData Item1, Item2;
Item1.ItemId = FName(TEXT("Item1"));
Item2.ItemId = FName(TEXT("Item2"));
Inventory->AddItem(Item1);
Inventory->AddItem(Item2);
});
It("should reject new items", [this]()
{
FItemData Item3;
Item3.ItemId = FName(TEXT("Item3"));
bool bResult = Inventory->AddItem(Item3);
TestFalse(TEXT("Should fail"), bResult);
});
});
});
}
#endif
- Describe - 테스트 그룹 정의 (중첩 가능)
- It - 개별 테스트 케이스
- BeforeEach - 각 테스트 전 실행
- AfterEach - 각 테스트 후 실행
- xIt / xDescribe - 테스트 비활성화 (접두사 x)
테스트 베스트 프랙티스
Epic Games 가이드라인 기반 테스트 작성 규칙
Epic 가이드라인
독립성
테스트는 순서에 상관없이 독립적으로 실행되어야 함
정리
생성한 파일/객체는 테스트 종료 후 반드시 삭제
상태 가정 금지
게임/에디터 상태를 가정하지 말 것
재실행 대비
이전 실행에서 불완전 종료되었을 수 있음을 고려
파일 테스트 예제
#if WITH_DEV_AUTOMATION_TESTS
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FSaveGameTest,
"MyGame.SaveSystem.SaveAndLoad",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter
)
bool FSaveGameTest::RunTest(const FString& Parameters)
{
// 테스트 파일 경로
const FString TestFilePath =
FPaths::ProjectSavedDir() / TEXT("Tests/TestSaveGame.sav");
// 1. 이전 실행 잔여물 정리 (중요!)
if (FPaths::FileExists(TestFilePath))
{
IFileManager::Get().Delete(*TestFilePath);
}
// 2. 테스트 실행
UMySaveGame* SaveGame = NewObject<UMySaveGame>();
SaveGame->PlayerLevel = 10;
SaveGame->PlayerGold = 1000;
// 저장
bool bSaveSuccess = UGameplayStatics::SaveGameToSlot(
SaveGame, TEXT("TestSlot"), 0);
TestTrue(TEXT("Save should succeed"), bSaveSuccess);
// 로드
UMySaveGame* LoadedGame = Cast<UMySaveGame>(
UGameplayStatics::LoadGameFromSlot(TEXT("TestSlot"), 0));
TestNotNull(TEXT("Loaded game should not be null"), LoadedGame);
if (LoadedGame)
{
TestEqual(TEXT("Level should match"), LoadedGame->PlayerLevel, 10);
TestEqual(TEXT("Gold should match"), LoadedGame->PlayerGold, 1000);
}
// 3. 정리
UGameplayStatics::DeleteGameInSlot(TEXT("TestSlot"), 0);
if (FPaths::FileExists(TestFilePath))
{
IFileManager::Get().Delete(*TestFilePath);
}
return true;
}
#endif
CI/CD 통합
# Jenkins/GitHub Actions에서 테스트 실행
UnrealEditor-Cmd.exe MyProject.uproject ^
-run=RunTests ^
-tests="MyGame" ^
-ReportOutputPath="TestResults" ^
-log ^
-unattended ^
-nosplash ^
-nullrhi
# 특정 테스트만 실행
-tests="MyGame.Inventory"
# 스모크 테스트만 실행
-filter=Smoke
# JUnit 형식 결과 출력 (CI 도구 연동)
-ReportExportPath="TestResults/junit.xml"
- 테스트 내에서
GEngine,GWorld등 전역 상태에 의존하지 말 것 - 비동기 작업은 Latent Command 사용 (다음 강의에서 다룸)
- 테스트 실행 시간을 최소화 (1개 테스트는 1초 이내 권장)
- 테스트 이름은 검증하는 동작을 명확히 설명
핵심 요약
- IMPLEMENT_SIMPLE_AUTOMATION_TEST로 단순 유닛 테스트 작성
- IMPLEMENT_COMPLEX_AUTOMATION_TEST로 파라미터화된 테스트 케이스 작성
- Spec 기반 테스트 (BEGIN_DEFINE_SPEC)로 BDD 스타일의 구조화된 테스트 작성
- TestEqual, TestTrue, TestNotNull 등 다양한 검증 함수 활용
- 테스트는 독립적이어야 하며, 생성한 리소스는 반드시 정리
다음 강의에서는 Latent Command를 사용하여 맵 로딩, 네트워크 요청 등 비동기 작업을 테스트하는 방법을 학습합니다.
도전 과제
배운 내용을 직접 실습해보세요
IMPLEMENT_SIMPLE_AUTOMATION_TEST로 RPG 인벤토리의 아이템 추가/제거 테스트를 작성하세요. TestEqual(), TestTrue()로 결과를 검증하고, 에디터의 Session Frontend > Automation에서 실행하세요.
IMPLEMENT_COMPLEX_AUTOMATION_TEST로 여러 파라미터 조합(다양한 아이템 타입, 수량)에 대한 인벤토리 테스트를 자동화하세요. GetTests()에서 테스트 케이스를 동적으로 생성하세요.
커맨드라인에서 RunTests 명령으로 자동화 테스트를 실행하는 배치 스크립트를 작성하세요. Jenkins/GitHub Actions에서 PR마다 자동으로 테스트가 실행되는 CI 파이프라인을 구축하세요.