자동화 스펙

기존 자동화 테스팅 프레임워크에 추가된 새로운 자동화 테스트 타입 '스펙'의 개요입니다.

Choose your operating system:

Windows

macOS

Linux

추가 참고

기존 자동화 테스팅 프레임워크에 새 자동화 테스트 타입을 추가했습니다. 이 새 타입은 스펙(Spec) 이라고 합니다. '스펙'은 행동 기반 디자인(Behavior Driven Design, BDD) 방법론에 따라 빌드된 테스트를 가리키는 용어입니다. 웹 개발 테스팅에서 아주 흔하게 사용되는 방법론이며, 이를 C++ 프레임워크에 맞게 조정했습니다.

스펙을 사용하는 이유는 아래를 포함하여 여러 가지가 있습니다.

  • 자체 문서화

  • 플루언트하며 보통 훨씬 드라이함

    드라이(DRY, Don't Repeat Yourself)

  • 스레드화된 테스트 코드나 레이턴트 테스트 코드 작성에 용이

  • 예상(테스트) 격리

  • 거의 모든 테스트에 사용 가능(펑셔널, 통합, 유닛)

스펙 구성 방법

스펙의 헤더를 정의하는 메서드에는 두 가지가 있습니다. 둘 모두 테스트 타입 정의에 사용하는 기존 메서드와 유사합니다.

가장 쉬운 메서드는 DEFINE_SPEC 매크로를 사용하는 것입니다. 이는 나머지 테스트 정의 매크로와 완전히 동일한 파라미터를 사용합니다.

DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomSpec", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
void MyCustomSpec::Define()
{
    //@todo 여기에 예상 쓰기
}

그 외의 유일한 대안은 BEGIN_DEFINE_SPECEND_DEFINE_SPEC 매크로를 사용하는 것입니다. 이 매크로로 자체 멤버를 테스트의 일부로 정의할 수 있습니다. 다음 섹션에서 볼 수 있듯 this 포인터에 관련된 값을 가질 수 있습니다.

BEGIN_DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomSpec", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
    TSharedPtr<FMyAwesomeClass> AwesomeClass;
END_DEFINE_SPEC(MyCustomSpec)
void MyCustomSpec::Define()
{
    //@todo 여기에 예상 쓰기
}

유일한 주의 사항은 다른 테스트 타입과 같은 RunTests() 멤버 대신 스펙 클래스의 Define() 멤버에 대한 구현을 작성해야 한다는 것입니다.

스펙은 .spec.cpp 확장자 파일로 정의되어야 하며 이름에 'Test'가 있어서는 안 됩니다. 예를 들어 FItemCatalogService 클래스는 ItemCatalogService.h , ItemCatalogService.cpp , ItemCatalogService.spec.cpp 파일을 가질 수 있습니다.

이는 권장 가이드라인이며 기술적인 제한이 아닙니다.

예상을 정의하는 방법

BDD의 중요 요소는 구체적인 구현을 테스트하는 대신 퍼블릭 API 예상을 테스트하는 것입니다. 이렇게 하면 테스트의 경직도가 훨씬 감소하며 관리하기 쉬워지며, 동일한 API의 서로 다른 다수의 구현이 발생하는 경우에도 작동할 가능성이 높습니다.

스펙에서는 Describe()It() 두 가지 주요 함수를 사용하여 예상을 정의합니다.

Describe

Describe() 는 복잡한 예상을 더 읽기 쉽고 드라이하도록 범위를 지정하는 데 사용됩니다. Describe() 를 사용하면 아래에서 다루는 BeforeEach()AfterEach() 등 다른 지원 함수와의 인터랙션을 기반으로 코드를 더 드라이하게 만들 수 있습니다.

void Describe(const FString& Description, TFunction<void()> DoWork)

Describe() 는 예상의 범위를 설명하는 스트링과 그 예상을 정의하는 람다를 받습니다.

Describe() 를 다른 Describe() 에 넣어서 Describe() 를 종속적으로 처리할 수 있습니다.

Describe() 는 테스트가 아니며 실제 테스트 도중 실행되지 않는다는 점에 유의하세요. 스펙 내에서 처음 예상(또는 테스트)을 정의할 때 한 번만 실행됩니다.

It

It() 은 스펙의 실제 예상을 정의하는 코드입니다. 루트 Define() 메서드 또는 Describe() 람다 내에서 It() 을 호출할 수 있습니다. It() 은 예상을 assert하는 데만 쓰는 것이 이상적이지만, 테스트 중인 시나리오의 구성 최종 일부에 쓸 수도 있습니다.

일반적으로 It() 호출 설명 스트링은 "it should"를 함축하는 "should"라는 스트링으로 시작하는 것이 좋습니다.

기본 예상 정의하기

다음은 지금까지 알아본 내용을 모두 사용하여 아주 단순한 예상을 만드는 예시입니다.

BEGIN_DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomClass", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
    TSharedPtr<FMyCustomClass> CustomClass;
END_DEFINE_SPEC(MyCustomSpec)
void MyCustomSpec::Define()
{
    Describe("Execute()", [this]()
    {
        It("성공 시 true를 반환해야 함", [this]()
        {
            TestTrue("Execute", CustomClass->Execute());
        });

        It("실패 시 false를 반환해야 함", [this]()
        {
            TestFalse("Execute", CustomClass->Execute());
        });
    });
}

보시다시피 이는 테스트를 자체 문서화합니다. 특히 프로그래머가 서로 다른 예상들을 서로 결합하지 않고 예상을 정확하게 설명하는 데 시간을 들이는 경우 더욱 그렇습니다. 스펙은 모든 Describe()It() 을 통합하여 대체로 읽기 좋은 문장을 만들도록 고안되었습니다. 예를 들면 다음과 같습니다.

Execute()는 성공 시 true를 반환해야 함
Execute()는 실패 시 false를 반환해야 함

다음은 고급 스펙이 현재 자동화 테스트 UI에서 어떤 모습인지를 보여주는 보다 복잡한 예시입니다.

AutomationSpec_MatureExample.png

이 예시에서는 Driver , Element , Click 이 각 Describe() 호출이며 다양한 'should...' 메시지가 It() 호출로 정의되고 있습니다.

각각의 It() 호출은 실행되어야 할 개별 테스트가 되며, 하나가 실패하고 다른 하나가 성공할 경우 구분하여 실행할 수 있습니다. 이렇게 하면 디버깅이 간편해지므로 테스트 관리가 쉬워집니다. 또한 테스트가 자체 문서화되고 구분되기에 하나가 실패하면 테스트 보고서를 읽는 사람은 단지 코어(Core)라는 대형 버킷이 실패했다는 것만 아는 데 그치지 않고, 어떤 항목이 작동하지 않는지 훨씬 구체적으로 이해할 수 있습니다. 이렇게 하면 문제가 적임자에게 더 빠르게 전달되고, 문제를 조사하는 데 걸리는 시간이 줄어듭니다.

마지막으로 위 테스트 가운데 하나를 클릭하면 이를 정의한 It() 명령문으로 직접 이동할 수 있습니다.

스펙 예상을 테스트로 변환하는 방법

상세 설명은 다음과 같습니다. 스펙 테스트 타입의 기반 행동을 이해하면 다음의 복잡한 기능 일부를 더 쉽게 이해할 수 있을 것입니다.

스펙 테스트 타입은 루트 Define() 함수를 한 번 실행하지만, 필요할 때가 와야 실행합니다. 이 실행에서는 Describe 가 아닌 모든 람다를 수집합니다. Define() 이 완료된 다음에는 수집한 람다 또는 코드 블록을 거쳐 되돌아가서 각 It() 에 대한 레이턴트 명령 배열을 생성합니다.

그러므로 모든 BeforeEach() , It() , AfterEach() 람다 코드 블록은 단일 테스트를 위한 실행 체인으로 결합됩니다. 특정 테스트를 수행하도록 요청되면 스펙 테스트 타입은 실행을 위해 해당 특정 테스트에 대한 모든 명령을 큐에 등록합니다. 그렇게 되면 각 블록은 이전 블록이 실행을 완료했다고 신호를 보내기 전까지는 계속되지 않습니다.

추가 기능

스펙 테스트 타입에는 복잡한 테스트 작성을 쉽게 만드는 몇 가지 기능이 더 있습니다. 특히, 강력하지만 번거로운 자동화 테스트 프레임워크의 레이턴트 명령 시스템을 직접 사용할 필요가 전반적으로 없어집니다.

다음은 보다 복잡한 시나리오에 유용한 스펙 테스트 타입의 지원 기능 목록입니다.

BeforeEach 및 AfterEach

BeforeEach()AfterEach() 는 가장 사소한 스펙 이상의 모든 것을 작성하는 핵심 함수입니다. BeforeEach() 는 후속 It() 코드가 실행되기 전에 코드를 실행하도록 지원합니다. AfterEach()It() 코드가 실행된 뒤에 코드를 실행하게 합니다.

각 '테스트'는 단일 It() 호출로 구성되어 있다는 점을 기억하세요.

예:

BEGIN_DEFINE_SPEC(AutomationSpec, "System.Automation.Spec", EAutomationTestFlags::SmokeFilter | EAutomationTestFlags::ApplicationContextMask)
    FString RunOrder; 
END_DEFINE_SPEC(AutomationSpec)
void AutomationSpec::Define()
{
    Describe("BeforeEach 및 AfterEach를 사용하는 스펙", [this]()
    {
        BeforeEach([this]()
        {
            RunOrder = TEXT("A");
        });

        It("Describe 내의 각 스펙 앞과 Describe 내의 각 스펙 뒤의 코드를 실행함", [this]()
        {
            TestEqual("RunOrder", RunOrder, TEXT("A"));
        });

        AfterEach([this]()
        {
            RunOrder += TEXT("Z");
            TestEqual("RunOrder", RunOrder, TEXT("AZ"));
        });
    });
}

이 예시에서 코드 블록은 상단에서 하단으로 실행되며, BeforeEach() 가 정의되고, 그 다음 It() , AfterEach() 순입니다. 필수는 아니지만 호출에서 이 논리 순서를 유지할 것을 권장합니다. 하지만 위 세 호출의 순서를 뒤섞을 수도 있으며 결과는 항상 동일한 테스트로 이어질 것입니다.

또한 위 예시에서는 AfterEach() 에서 예상을 확인하는데, 이는 매우 정상적이지 않은 경우이며 스펙 테스트 타입 자체를 테스트하는 데 따르는 부작용입니다. 그러므로 AfterEach() 는 클린업 외 다른 용도로 사용하지 않을 것을 권합니다.

또한 다수의 BeforeEach()AfterEach() 호출을 만들 수도 있으며, 이는 정의된 순서대로 호출될 것입니다. 첫 BeforeEach() 호출이 두 번째 BeforeEach() 호출 전에 실행되듯이 AfterEach() 도 거의 동일한 방식으로 작동합니다. 첫 번째 호출이 실행된 뒤에 후속 호출이 실행됩니다.

BeforeEach([this]()
{
    RunOrder = TEXT("A");
});

BeforeEach([this]()
{
    RunOrder += TEXT("B");
});

It("Describe 내의 각 스펙 앞과 Describe 내의 각 스펙 뒤의 코드를 실행함", [this]()
{
    TestEqual("RunOrder", RunOrder, TEXT("AB"));
});

AfterEach([this]()
{
    RunOrder += TEXT("Y");
    TestEqual("RunOrder", RunOrder, TEXT("ABY"));
});

AfterEach([this]()
{
    RunOrder += TEXT("Z");
    TestEqual("RunOrder", RunOrder, TEXT("ABYZ"));
});

또한 BeforeEach()AfterEach() 는 호출된 Describe() 범위에 의해 영향을 받습니다. 둘 다 호출된 범위 내에 있는 It() 호출에 대해서만 실행됩니다.

다음은 호출 순서가 잘못됐지만 모두 올바르게 작동하는 복잡한 예시입니다.

BEGIN_DEFINE_SPEC(AutomationSpec, "System.Automation.Spec", EAutomationTestFlags::SmokeFilter | EAutomationTestFlags::ApplicationContextMask)
    FString RunOrder; 
END_DEFINE_SPEC(AutomationSpec)
void AutomationSpec::Define()
{
    Describe("BeforeEach 및 AfterEach를 사용하는 스펙", [this]()
    {
        BeforeEach([this]()
        {
            RunOrder = TEXT("A");
        });

        AfterEach([this]()
        {
            RunOrder += TEXT("Z");

            // 어떤 It()이 실행되는지에 따라
            // TestEqual("RunOrder", RunOrder, TEXT("ABCYZ"));

// 또는 다음이 실행될 수 있습니다
            // TestEqual("RunOrder", RunOrder, TEXT("ABCDXYZ"));
        });

        BeforeEach([this]()
        {
            RunOrder += TEXT("B");
        });

        Describe("다른 Describe 안에 중첩된 동안", [this]()
        {
            AfterEach([this]()
            {
                RunOrder += TEXT("Y");
            });

It("모든 BeforeEach 블록 및 모든 AfterEach 블록을 실행함", [this]()
            {
                TestEqual("RunOrder", RunOrder, TEXT("ABC"));
            });

            BeforeEach([this]()
            {
                RunOrder += TEXT("C");
            });

            Describe("또 다른 Describe 안에 중첩된 동안", [this]()
            {
                It("모든 BeforeEach 블록 및 모든 AfterEach 블록을 실행함", [this]()
                {
                    TestEqual("RunOrder", RunOrder, TEXT("ABCD"));
                });

                AfterEach([this]()
                {
                    RunOrder += TEXT("X");
                });

                BeforeEach([this]()
                {
                    RunOrder += TEXT("D");
                });
            });
        });
    });
}

AsyncExecution

스펙 테스트 타입은 단일 코드 블록의 실행 방식도 쉽게 정의하도록 지원합니다. 적절한 EAsyncExecution 타입을 BeforeEach() , It() , AfterEach() 의 오버로드된 버전에 전달하기만 하면 됩니다.

예:

BeforeEach(EAsyncExecution::TaskGraph, [this]() 
{
// 뭔가 구성합니다
));

It("뭔가 멋진 걸 해야 함", EAsyncExecution::ThreadPool, [this]()
{
    // 뭔가 합니다
});

AfterEach(EAsyncExecution::Thread, [this]() 
{
    // 뭔가 제거합니다
));

위의 각 코드 블록은 다르게 실행되지만, 반드시 정해진 순서대로 실행됩니다. BeforeEach() 블록은 TaskGraph 의 태스크로 실행되며, It() 은 스레드 풀의 오픈 스레드에서 실행되고, AfterEach() 는 코드 블록 실행을 위해 자체적인 전용 스레드를 생성할 것입니다.

이 옵션은 자동화 드라이버(Automation Driver) 등 스레드가 중요한 시나리오를 시뮬레이션할 때 대단히 유용합니다.

AsyncExecution 기능은 레이턴트 완료 기능과 결합할 수 있습니다.

레이턴트 완료

때로는 쿼리 수행 등 다수의 프레임이 필요한 액션을 수행해야 하는 테스트를 작성해야 할 수도 있습니다. 이런 시나리오에서는 오버로드된 LatentBeforeEach() , LatentIt() , LatentAfterEach() 멤버를 사용할 수 있습니다. 각 멤버는 비 레이턴트 베리에이션과 동일하지만 람다가 Done 이라는 단순 델리게이트를 받는다는 점에서 다릅니다.

레이턴트 베리에이션을 사용할 때 스펙 테스트 타입은 실행 도중인 잠복성 코드 블록이 Done 델리게이트를 호출하기 전까지는 테스트 시퀀스에서 다음 코드 블록으로 넘어가지 않습니다.

LatentIt("사용 가능한 아이템을 반환해야 함", [this](const FDoneDelegate& Done)
{
    BackendService->QueryItems(this, &FMyCustomSpec::HandleQueryItemComplete, Done);
});

void FMyCustomSpec::HandleQueryItemsComplete(const TArray<FItem>& Items, FDoneDelegate Done)
{
    TestEqual("Items.Num() == 5", Items.Num(), 5);
Done.Execute();
}

예시에서 보시다시피 Done 델리게이트를 페이로드로 다른 콜백에 전달하여 레이턴트 코드에 액세스 가능하게 할 수 있습니다. 위 테스트를 실행할 때는 It() 코드 블록이 이미 실행을 마쳤다고 해도 Done 델리게이트가 실행되기 전까지는 It() 에 대한 AfterEach() 코드 블록을 실행하지 않을 것입니다.

레이턴트 완료 기능은 AsyncExecution 기능과 결합할 수 있습니다.

파라미터화된 테스트

때로는 데이터 기반 방식으로 테스트를 생성해야 할 수도 있습니다. 때로 이는 파일에서 오는 입력을 읽고, 입력에서 테스트를 생성하는 것을 뜻합니다. 그렇지 않은 경우에는 단지 코드 중복을 줄이는 것이 이상적일 수도 있습니다. 어느 쪽이든 테스트 타입은 아주 자연스러운 방식으로 파라미터화된 테스트를 허용합니다.

Describe("기본 산수", [this]()
{
    for (int32 Index = 0; Index < 5; Index++)
    {
        It(FString::Printf(TEXT("should resolve %d + %d = %d"), Index, 2, Index + 2), [this, Index]()
        {
            TestEqual(FString::Printf(TEXT("%d + %d = %d"), Index, 2, Index + 2), Index + 2, Index + 2);
        });
    }
});

위 예시에서 보듯이 파라미터화된 테스트를 만들기 위해서는 동적으로 다른 스펙 함수를 호출하면서 파라미터화된 데이터를 람다 페이로드의 일부로 전달하는 동시에 고유한 설명을 생성해야 합니다.

몇몇 경우에는 파라미터화된 테스트를 사용하면 테스트가 부풀려질 수 있습니다. 단순하게 입력에서 모든 시나리오를 단일 테스트의 일부로서 실행하는 것이 합리적일 수도 있습니다. 입력의 수와 생성되는 결과 테스트를 고려해야 합니다. 파라미터화된 방식으로 데이터 기반 테스트를 생성할 때의 주요 이점은 각 테스트가 구분되어 실행되므로 재현이 쉬워진다는 것입니다.

재정의

파라미터화된 테스트로 작업할 경우, 때로는 입력을 주도하는 외부 파일을 런타임에서 변경하고 테스트를 자동으로 새로고침하는 것이 편할 수도 있습니다. Redefine() 은 스펙 테스트 타입의 멤버이며, 호출되면 Define() 프로세스를 다시 수행합니다. 이는 모든 테스트용 코드 블록을 다시 수집 및 분석합니다.

위 작업을 수행하는 가장 편리한 방법은 입력 파일 변경 사항을 수신하는 코드를 약간 작성하고 필요에 따라 Redefine() 을 호출하는 것입니다.

테스트 비활성화

스펙 테스트 타입의 모든 Describe() , BeforeEach() , It() , AfterEach() 멤버에는 앞에 'x'가 붙는 베리에이션이 있습니다. 예를 들면 xDescribe() , xBeforeEach() , xIt() , xAfterEach() 등이 있습니다. 이 베리에이션을 쓰면 보다 간편하게 코드 블록이나 Describe() 를 비활성화할 수 있습니다. xDescribe() 가 사용되면 xDescribe() 내의 모든 코드도 비활성화됩니다.

이는 반복작업이 필요한 예상을 코멘트 처리하는 것보다 더 쉬울 수도 있습니다.

고급 예시

스펙 테스트 타입의 고급 예시는 Engine/Source/Developer/AutomationDriver/Private/Specs/AutomationDriver.spec.cpp 에서 볼 수 있습니다. 이 스펙은 현재 120가지 이상의 예상을 포함하며 일정 시점에서 고급 기능 대부분을 활용합니다.

에픽게임즈 런처 팀은 스펙 프레임워크 고급 사용 사례를 다수 보유하고 있으며, 최고급 사용 사례 중 하나는 BuildPatchServices 를 중심으로 작성된 스펙입니다.