icon안동민 개발노트

유닛 테스트 개요


 언리얼 엔진 C++ 프로젝트에서 유닛 테스트는 코드의 품질과 안정성을 보장하는 중요한 도구입니다.

 이 절에서는 언리얼 엔진에서 유닛 테스트를 구현하고 실행하는 방법을 살펴보겠습니다.

언리얼 엔진의 자동화된 테스트 프레임워크 소개

 언리얼 엔진은 Automation System이라는 내장 테스트 프레임워크를 제공합니다. 이 프레임워크를 사용하여 유닛 테스트, 기능 테스트, 그리고 복잡한 시나리오 테스트를 작성할 수 있습니다.

테스트 모듈 설정 방법

  1. 프로젝트의 Build.cs 파일에 테스트 모듈 추가
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay" });
PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
 
// 테스트 모듈 추가
if (Target.Configuration != UnrealTargetConfiguration.Shipping)
{
    PrivateDependencyModuleNames.Add("AutomationController");
}
  1. 테스트 파일을 위한 디렉토리 생성 (예 : Source/MyProject/Tests/)

기본적인 테스트 케이스 작성법

 테스트 파일 예시 (MyMathTest.cpp)

##include "CoreMinimal.h"
##include "Misc/AutomationTest.h"
##include "MyMath.h" // 테스트할 클래스 헤더
 
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FMyMathAddTest, "MyProject.Math.Addition", EAutomationTestFlags::ApplicationContextMask | EAutomationTestFlags::ProductFilter)
 
bool FMyMathAddTest::RunTest(const FString& Parameters)
{
    // 테스트 로직
    const int32 Result = UMyMath::Add(2, 3);
    TestEqual("2 + 3 should equal 5", Result, 5);
 
    return true;
}

게임플레이 요소에 대한 유닛 테스트 작성 전략

 게임플레이 요소 테스트 예시

IMPLEMENT_SIMPLE_AUTOMATION_TEST(FPlayerHealthTest, "MyProject.Gameplay.PlayerHealth", EAutomationTestFlags::ApplicationContextMask | EAutomationTestFlags::ProductFilter)
 
bool FPlayerHealthTest::RunTest(const FString& Parameters)
{
    // 테스트 환경 설정
    UWorld* World = UWorld::CreateWorld(EWorldType::Game, false);
    FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Game);
    WorldContext.SetCurrentWorld(World);
 
    // 플레이어 생성 및 초기화
    AMyPlayerCharacter* Player = World->SpawnActor<AMyPlayerCharacter>();
    TestNotNull("Player should be spawned", Player);
 
    // 체력 테스트
    const float InitialHealth = Player->GetHealth();
    TestEqual("Initial health should be 100", InitialHealth, 100.0f);
 
    Player->TakeDamage(20.0f);
    TestEqual("Health after taking 20 damage should be 80", Player->GetHealth(), 80.0f);
 
    // 정리
    GEngine->DestroyWorldContext(World);
    World->DestroyWorld(false);
 
    return true;
}

목(mock) 객체 및 스텁(stub) 사용법

 목 객체를 사용한 테스트 예시

class MockWeapon : public IWeapon
{
public:
    MOCK_METHOD0(Fire, void());
    MOCK_METHOD0(Reload, void());
};
 
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FPlayerWeaponTest, "MyProject.Gameplay.PlayerWeapon", EAutomationTestFlags::ApplicationContextMask | EAutomationTestFlags::ProductFilter)
 
bool FPlayerWeaponTest::RunTest(const FString& Parameters)
{
    MockWeapon MockWeapon;
    AMyPlayerCharacter* Player = World->SpawnActor<AMyPlayerCharacter>();
 
    EXPECT_CALL(MockWeapon, Fire()).Times(1);
    Player->SetWeapon(&MockWeapon);
    Player->FireWeapon();
 
    return true;
}

비동기 코드 테스트 방법

 비동기 코드 테스트 예시

IMPLEMENT_SIMPLE_AUTOMATION_TEST(FAsyncLoadTest, "MyProject.Async.AssetLoading", EAutomationTestFlags::ApplicationContextMask | EAutomationTestFlags::ProductFilter)
 
bool FAsyncLoadTest::RunTest(const FString& Parameters)
{
    ADD_LATENT_AUTOMATION_COMMAND(FEngineWaitLatentCommand(5.0f));
    ADD_LATENT_AUTOMATION_COMMAND(FLoadAssetLatentCommand(this));
    ADD_LATENT_AUTOMATION_COMMAND(FCheckAssetLoadedLatentCommand(this));
 
    return true;
}
 
DEFINE_LATENT_AUTOMATION_COMMAND_ONE_PARAMETER(FLoadAssetLatentCommand, FAsyncLoadTest*, Test);
bool FLoadAssetLatentCommand::Update()
{
    // 에셋 비동기 로드 시작
    Test->AssetLoader.RequestAsyncLoad("Path/To/Asset");
    return true;
}
 
DEFINE_LATENT_AUTOMATION_COMMAND_ONE_PARAMETER(FCheckAssetLoadedLatentCommand, FAsyncLoadTest*, Test);
bool FCheckAssetLoadedLatentCommand::Update()
{
    if (Test->AssetLoader.IsAssetLoaded())
    {
        Test->TestTrue("Asset should be loaded", Test->AssetLoader.GetLoadedAsset() != nullptr);
        return true;
    }
    return false;
}

테스트 주도 개발(TDD) 접근법의 언리얼 엔진 적용

 TDD를 언리얼 엔진 프로젝트에 적용하는 단계

  1. 실패하는 테스트 작성
  2. 최소한의 코드로 테스트 통과
  3. 리팩토링
  4. 반복

지속적 통합(CI) 파이프라인에 유닛 테스트 통합 방법

 Jenkins나 GitLab CI를 사용한 CI 파이프라인 설정 예시

stages:
  - build
  - test
 
build_job:
  stage: build
  script:
    - path/to/UnrealBuildTool.exe MyProject Development Win64 -Project=path/to/MyProject.uproject -TargetType=Editor
 
test_job:
  stage: test
  script:
    - path/to/UnrealEditor.exe path/to/MyProject.uproject -ExecCmds="Automation RunTests MyProject" -Unattended -NullRHI -NoSound -NoSplash -Log

효과적인 테스트 커버리지 관리 전략

  1. 코드 커버리지 도구 사용 (예 : OpenCppCoverage)
  2. 중요 기능에 대한 테스트 우선 작성
  3. 정기적인 커버리지 리포트 검토

대규모 프로젝트에서의 유닛 테스트 관리 방법

  1. 테스트 카테고리 및 태그 활용
  2. 테스트 실행 자동화
  3. 병렬 테스트 실행 구현
IMPLEMENT_COMPLEX_AUTOMATION_TEST(FPerformanceCriticalTest, "MyProject.Performance", EAutomationTestFlags::ApplicationContextMask | EAutomationTestFlags::HighPriority | EAutomationTestFlags::ProductFilter)
 
void FPerformanceCriticalTest::GetTests(TArray<FString>& OutBeautifiedNames, TArray<FString>& OutTestCommands) const
{
    OutBeautifiedNames.Add("CriticalFunction1");
    OutTestCommands.Add("CriticalFunction1");
 
    OutBeautifiedNames.Add("CriticalFunction2");
    OutTestCommands.Add("CriticalFunction2");
}
 
bool FPerformanceCriticalTest::RunTest(const FString& Parameters)
{
    if (Parameters == "CriticalFunction1")
    {
        // CriticalFunction1 테스트 로직
    }
    else if (Parameters == "CriticalFunction2")
    {
        // CriticalFunction2 테스트 로직
    }
 
    return true;
}

성능에 민감한 코드의 테스트 전략

  1. 프로파일링 도구와 테스트 통합
  2. 성능 벤치마크 테스트 작성
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FPerformanceBenchmarkTest, "MyProject.Performance.Benchmark", EAutomationTestFlags::ApplicationContextMask | EAutomationTestFlags::PerformanceFilter)
 
bool FPerformanceBenchmarkTest::RunTest(const FString& Parameters)
{
    const double StartTime = FPlatformTime::Seconds();
 
    // 성능 테스트할 함수 호출
    PerformanceIntensiveFunction();
 
    const double EndTime = FPlatformTime::Seconds();
    const double ElapsedTime = EndTime - StartTime;
 
    TestTrue("Function should complete within 100ms", ElapsedTime < 0.1);
 
    return true;
}

테스트 가능한 코드 설계 원칙

  1. 단일 책임 원칙 (SRP) 준수
  2. 의존성 주입 활용
  3. 인터페이스 기반 프로그래밍

 테스트 가능한 코드 예시

class IWeaponSystem
{
public:
    virtual void Fire() = 0;
    virtual int32 GetAmmo() const = 0;
};
 
class AMyCharacter : public ACharacter
{
    UPROPERTY()
    TScriptInterface<IWeaponSystem> WeaponSystem;
 
public:
    void SetWeaponSystem(TScriptInterface<IWeaponSystem> NewWeaponSystem)
    {
        WeaponSystem = NewWeaponSystem;
    }
 
    void FireWeapon()
    {
        if (WeaponSystem)
        {
            WeaponSystem->Fire();
        }
    }
};
 
// 테스트 코드
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FCharacterWeaponTest, "MyProject.Character.Weapon", EAutomationTestFlags::ApplicationContextMask | EAutomationTestFlags::ProductFilter)
 
bool FCharacterWeaponTest::RunTest(const FString& Parameters)
{
    AMyCharacter* Character = World->SpawnActor<AMyCharacter>();
    
    // 목 객체 생성
    TScriptInterface<IWeaponSystem> MockWeapon = NewObject<UMockWeaponSystem>();
    Character->SetWeaponSystem(MockWeapon);
 
    Character->FireWeapon();
 
    // MockWeaponSystem에서 Fire() 메서드가 호출되었는지 확인
    UMockWeaponSystem* MockWeaponPtr = Cast<UMockWeaponSystem>(MockWeapon.GetObject());
    TestTrue("Fire method should be called", MockWeaponPtr->WasFireCalled());
 
    return true;
}

 유닛 테스트는 언리얼 엔진 C++ 프로젝트의 품질을 향상시키는 핵심 도구입니다. 자동화된 테스트 프레임워크를 활용하여 기본적인 테스트 케이스부터 복잡한 게임플레이 요소의 테스트까지 다양한 수준의 테스트를 구현할 수 있습니다.

 목 객체와 스텁을 활용하면 복잡한 의존성을 가진 코드도 효과적으로 테스트할 수 있으며, 비동기 코드 테스트를 위한 특별한 기법도 제공됩니다. TDD 접근법을 적용하면 더 견고하고 유지보수가 쉬운 코드를 작성할 수 있습니다.

 CI 파이프라인에 유닛 테스트를 통합하면 지속적으로 코드의 품질을 모니터링하고 유지할 수 있습니다. 대규모 프로젝트에서는 테스트의 체계적인 관리와 실행이 중요하며, 성능에 민감한 코드의 경우 특별한 테스트 전략이 필요합니다.

 마지막으로, 테스트 가능한 코드를 설계하는 것이 중요합니다. 단일 책임 원칙을 준수하고, 의존성 주입을 활용하며, 인터페이스 기반으로 프로그래밍하면 더 쉽게 테스트할 수 있는 코드를 작성할 수 있습니다. 이러한 원칙들을 따르면 장기적으로 유지보수가 용이하고 안정적인 게임 코드를 개발할 수 있습니다.