PART 10 - 강의 1/6

Automation Testing

IMPLEMENT_SIMPLE/COMPLEX_AUTOMATION_TEST와 Spec 기반 테스트

01

UE5 Automation Test Framework

언리얼 엔진의 자동화 테스트 시스템 이해

테스트 타입

테스트 타입 용도 실행 시간 사용 시점
Unit Test 개별 함수/클래스 검증 밀리초 코드 변경 시마다
Feature Test 기능 단위 검증 기능 완성 후
Smoke Test 빠른 기본 동작 확인 빌드 후
Stress Test 성능/안정성 검증 릴리스 전

테스트 플래그

Test Flags // 테스트 컨텍스트 플래그 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
02

Simple Automation Test

단순한 유닛 테스트 작성하기

기본 구조

SimpleTest.cpp #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

검증 함수 목록

Assertion Functions // 동등 비교 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"));

실제 게임 코드 테스트 예제

InventoryTest.cpp #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
03

Complex Automation Test

파라미터화된 테스트 케이스 작성

파라미터화된 테스트

ParameterizedTest.cpp #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
Complex Test 장점
  • 동일한 테스트 로직으로 여러 케이스 검증
  • 테스트 케이스 추가가 데이터 추가만으로 가능
  • 에디터에서 각 케이스를 개별적으로 확인 가능
04

Spec 기반 테스트 (UE5 권장)

BDD 스타일의 구조화된 테스트 작성

Spec 테스트 기본 구조

InventorySpec.cpp #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
Spec 테스트 키워드
  • Describe - 테스트 그룹 정의 (중첩 가능)
  • It - 개별 테스트 케이스
  • BeforeEach - 각 테스트 전 실행
  • AfterEach - 각 테스트 후 실행
  • xIt / xDescribe - 테스트 비활성화 (접두사 x)
05

테스트 베스트 프랙티스

Epic Games 가이드라인 기반 테스트 작성 규칙

Epic 가이드라인

독립성

테스트는 순서에 상관없이 독립적으로 실행되어야 함

정리

생성한 파일/객체는 테스트 종료 후 반드시 삭제

상태 가정 금지

게임/에디터 상태를 가정하지 말 것

재실행 대비

이전 실행에서 불완전 종료되었을 수 있음을 고려

파일 테스트 예제

FileTestExample.cpp #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 통합

Command Line # 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초 이내 권장)
  • 테스트 이름은 검증하는 동작을 명확히 설명
SUMMARY

핵심 요약

  • IMPLEMENT_SIMPLE_AUTOMATION_TEST로 단순 유닛 테스트 작성
  • IMPLEMENT_COMPLEX_AUTOMATION_TEST로 파라미터화된 테스트 케이스 작성
  • Spec 기반 테스트 (BEGIN_DEFINE_SPEC)로 BDD 스타일의 구조화된 테스트 작성
  • TestEqual, TestTrue, TestNotNull 등 다양한 검증 함수 활용
  • 테스트는 독립적이어야 하며, 생성한 리소스는 반드시 정리
다음 강의 예고

다음 강의에서는 Latent Command를 사용하여 맵 로딩, 네트워크 요청 등 비동기 작업을 테스트하는 방법을 학습합니다.

PRACTICE

도전 과제

배운 내용을 직접 실습해보세요

실습 1: 기본 자동화 테스트 작성

IMPLEMENT_SIMPLE_AUTOMATION_TEST로 RPG 인벤토리의 아이템 추가/제거 테스트를 작성하세요. TestEqual(), TestTrue()로 결과를 검증하고, 에디터의 Session Frontend > Automation에서 실행하세요.

실습 2: 복합 자동화 테스트

IMPLEMENT_COMPLEX_AUTOMATION_TEST로 여러 파라미터 조합(다양한 아이템 타입, 수량)에 대한 인벤토리 테스트를 자동화하세요. GetTests()에서 테스트 케이스를 동적으로 생성하세요.

심화 과제: CI 파이프라인 테스트 통합

커맨드라인에서 RunTests 명령으로 자동화 테스트를 실행하는 배치 스크립트를 작성하세요. Jenkins/GitHub Actions에서 PR마다 자동으로 테스트가 실행되는 CI 파이프라인을 구축하세요.