자동화 드라이버

프로그래머가 사용자 입력을 시뮬레이션할 수 있게 지원하는 새 자동화 드라이버 기능의 개요입니다.

Choose your operating system:

Windows

macOS

Linux

추가 참고

자동화 드라이버(Automation Driver) 는 프로그래머가 플루언트 문법을 사용하여 애플리케이션을 통해 사용자 입력을 시뮬레이션할 수 있게 지원하는 언리얼 엔진 기능입니다. 자동화 드라이버는 브라우저 입력 시뮬레이션에서 볼 수 있는 기타 외부 라이브러리와 피처 세트가 아주 유사하며, 이러한 라이브러리와 마찬가지로 사용자 행동을 시뮬레이션하는 기능 테스트를 작성하는 데 주로 사용됩니다.

자동화 드라이버

자동화 드라이버의 기능

자동화 드라이버는 커서 움직임, 클릭, 누르기, 타이핑, 스크롤링, 드래그 앤 드롭 등의 입력을 시뮬레이션합니다. 이 자동화 드라이버의 초기 리비전은 모든 종류의 기존 데스크톱 입력, 다시 말하자면 사실상 모든 키보드 및 마우스 관련 입력을 지원합니다. 앞으로는 여기에 터치 제스처, 컨트롤러 입력, 모션 탐지도 포함하도록 확장할 수도 있습니다.

자동화 드라이버는 이 입력을 플루언트하고 읽을 수 있는 방식으로 시뮬레이션하는 데 특화되어 있습니다. 이 방식은 유지하기 쉽고 상대적으로 유연합니다. 가장 중요한 것은 자동화 드라이버가 플랫폼 레이어의 입력을 시뮬레이션하기 때문에 퍼블릭 API가 슬레이트에 의존하지 않는다는 것입니다. 조금만 작업을 하면 씬 액터나 그 이상의 것들과 함께 작동하도록 확장할 수 있습니다.

또한 이 기능은 플랫폼에 구애받지 않기에 해당 플랫폼의 주요 입력을 시뮬레이션할 수 있다면 어떤 플랫폼에서도 사용 가능합니다.

작동 방식

코어(Core) 내에는 거의 모든 외부 입력이 지나가는 인터페이스 세트가 있습니다. 자동화 드라이버는 이러한 인터페이스에 shim 구현을 생성하며, 약간의 기본 종속성 주입을 통해 실제 구현을 '패스스루' 베리에이션으로 교체할 수 있습니다. 그러면 이 '패스스루' 베리에이션은 애플리케이션에 도달하는 플랫폼 입력과 그렇지 않은 것을 델리게이션할 기회를 얻고 자체 입력 전체를 위조합니다. 이것이 자동화 드라이버의 작동 방식입니다.

사용 방법

자동화 드라이버는 기본적으로 비활성화되어 있습니다. 모듈 API에서 Enable() 함수를 호출하여 활성화할 수 있습니다. 비활성화하려면 Disable() 함수를 호출합니다.

IAutomationDriverModule::Get().Enable();

//@todo 여기에 사용자 행동 시뮬레이션 작성

IAutomationDriverModule::Get().Disable();

자동화 드라이버는 활성화되면 애플리케이션에서 받는 거의 모든 플랫폼 입력을 차단하기 시작합니다. 이 지점에서 자체 드라이버 인스턴스를 생성할 수 있습니다.

FAutomationDriverPtr Driver = IAutomationDriverModule::Get().CreateDriver();

드라이버 인스턴스를 생성한 뒤에는 입력을 시뮬레이션할 수 있습니다. 다음은 가입 양식과 관련된 입력의 시뮬레이션 예시입니다.

FDriverElementRef SignUpForm = Driver->FindElement(By::Id("Form"));
FDriverElementRef SubmitBtn = Driver->FindElement(By::Path("#Form//Submit"));

FDriverSequenceRef Sequence = Driver->CreateSequence();
Sequence->Actions()
    .Focus(SignUpForm)
    .Type(TEXT("FirstName\tLastName\tFirstName.LastName@example.com"))
    .Click(SubmitBtn);
Sequence->Perform();

API 사용하기

자동화 드라이버와 인터랙션할 주요 API에는 동기 API와 비동기 API 두 가지가 있습니다. 동기 API는 코드를 작성하기 가장 쉬운 방법이지만, GameThread에서 코드를 실행할 수 없습니다. 이는 동기 API가 계속하기 전에 입력 시뮬레이션이 완료되기를 기다리기 때문입니다. 입력 시뮬레이션이 GameThread에서 실행될 레이턴트 로직으로 계속 대기하는 동안, 차단하는 동기 드라이버 API는 시뮬레이션된 입력의 처리를 막아 데드락 상태가 됩니다. 이런 일은 피해야 합니다.

이 가이드의 모든 예시는 로직이 GameThread에서 실행되지 않는다는 전제하에 동기 API를 사용합니다.

이 콘셉트가 어렵다면 새로운 자동화 스펙(Automation Spec) 테스트 타입 관련 내용을 읽어 보세요.

엘리먼트 찾기

유용한 입력을 생성하기 위한 첫 단계는 애플리케이션이 인터랙션할 핵심 부분을 식별하는 것입니다. 자동화 드라이버는 로케이터를 통해 이를 수행합니다. 또한 기존 로케이터 일부를 사용하여 슬레이트 기반 엘리먼트를 발견할 수 있는 플루언트 방식이 몇 가지 있습니다.

By::Id()

가장 이상적인 메서드는 엘리먼트를 ID로 찾는 것입니다. 프로그래머가 위젯에 명시적 자동화 드라이버 메타데이터 ID를 태그해야 하므로 멈출 위험이 매우 적습니다.

위젯 태깅은 매우 간단하며 다음과 같이 수행 가능합니다.

SNew(STextBlock)
.Text(InViewModel, &IViewModel::GetFirstName)
.AddMetaData(FDriverMetaData::Id("SignUpFormFirstNameField"))

ID는 다른 ID와 충돌 없이 쉽게 참조가 가능하도록 최대한 구체적으로 만드는 것이 좋습니다. ID는 패스로 참조될 수 있으며, 따라서 다른 Id 및 엘리먼트에 의해 범위가 지정될 수 있습니다. 그러나 고유한 ID를 생성하여 테스트가 다루기 힘든 범위 컨텍스트에 의존할 필요가 없게 하는 것이 가장 좋습니다.

예를 들어 확실한 고유 ID라면 다음과 같은 방식으로 해당 위젯을 찾을 수 있습니다.

FDriverElementRef FirstNameField = Driver->FindElement(By::Id("SignUpFormFirstNameField")); 

ID가 고유한 건 아니지만, 고유한 엘리먼트 세트를 나타내는 경우 다음 방식으로 모든 엘리먼트를 한꺼번에 찾을 수 있습니다.

FDriverElementCollectionRef SignUpFormFields = Driver->FindElements(By::Id("SignUpFormField"));
TArray<FDriverElementRef> Fields = SignUpFormFields->GetElements();

엘리먼트 컬렉션에서 GetElements() 메서드를 호출할 때, 실제로 엘리먼트를 찾기 시작합니다. 따라서 찾는 엘리먼트가 나타났다가 사라지는 경우에 유의해야 합니다. 찾는 엘리먼트가 나타나도록 명시적으로 대기해야 할 수도 있습니다.

By::Path()

엘리먼트를 패스로 찾는 것은 가장 다루기 힘들고 강력한 방식입니다. By::Path() 로케이터를 사용하면 태그, ID, 타입 매칭의 계층구조를 통해 특정 엘리먼트를 획득할 수 있습니다.

다음은 몇 가지 예시 구문입니다.

By::Path("#SignUpFormFirstNameField")
By::Path("FormField"))
By::Path("Documents//Tiles")
By::Path("<SAutomationDriverSpecSuite>")
By::Path("#Piano//#KeyB/<STextBlock>")
By::Path("#Suite//Form//Rows//#A1//<SEditableText>")
패스 구문

구문

설명

#SignUpFormFirstNameField

# 다음 텍스트가 명시적 ID임을 나타냅니다. SWidget 의 경우 자동화 드라이버 ID 메타데이터로 태그되어야 합니다.

FormField

일반 태그를 나타내는 플레인 텍스트입니다. SWidget 의 경우 일치하는 플레인 텍스트 값이 있는 Tag 또는 TagMetadata 가 필요합니다.

<STextBlock>

<> 는 타입을 나타냅니다. SWidget 의 경우 SNew 컨스트럭션에서 사용되는 명시적 타입이어야 합니다. 위젯 리플렉터를 참조하면 타입을 사용하여 쉽게 패스를 생성할 수 있습니다.

/

계층구조는 포워드 슬래시를 통해 표현되며, 포워드 슬래시 1개는 다음 값이 이전에 일치된 엘리먼트의 직계 자손과 일치해야 한다는 것을 나타냅니다.

//

계층구조는 포워드 슬래시를 통해 표현되며, 포워드 슬래시 2개는 다음 값이 이전에 일치된 엘리먼트의 불특정 후손과 일치해야 한다는 것을 나타냅니다.

향후 패스 로케이터에 더 많은 구문 옵션이 추가될 예정이지만, 현재 사용 가능한 옵션은 위와 같습니다.

이스케이프 캐릭터는 지원되지 않으며, 따라서 패스 로케이터는 < 또는 # 문자를 앞에 포함하는 태그 또는 ID를 매칭할 수 없습니다.

추가 구문 사용 예시를 확인하려면 다음을 읽어 보세요. Engine/Source/Developer/AutomationDriver/Private/Specs/AutomationDriver.spec.cpp

By::Cursor()

이 로케이터는 엘리먼트를 커서의 현재 위치 바로 아래에 반환합니다.

FDriverElementRef ElementUnderCursor = Driver->FindElement(By::Cursor());

By::Delegate()

기존 로케이터로 원하는 결과를 얻을 수 없다면 By::Delegate() 또는 그 다양한 오버로드를 통해 자체 델리게이트나 람다를 전달할 수 있습니다. 자동화 드라이버는 엘리먼트 찾기를 시도할 때 해당 코드 블록을 게임 스레드에서 호출할 것입니다.

FDriverElementRef CustomElement = Driver->FindElement(By::WidgetLambda([this](TArray<TSharedRef<SWidget>>& OutWidgets){
    OutWidgets.Add(SpeciallyCachedWidget);
}));

액션 수행하기

자동화 드라이버로 액션을 수행하는 주된 방식에는 두 가지가 있습니다. 단일 엘리먼트(또는 소규모 엘리먼트 세트)로 작업할 때 가장 쉬운 방법은 FDriverElementRef 에서 직접 사용 가능한 액션을 사용하는 것입니다. 또 다른 옵션은 FDriverSequenceRef 를 생성하는 것입니다. 이를 통해 여러 엘리먼트에(또는 엘리먼트 지정 없이) 수행 가능한 긴 액션들을 큐에 등록할 수 있습니다.

엘리먼트

사용 가능한 여러 액션은 드라이버 엘리먼트 레퍼런스를 획득한 후에 거기서 직접 수행될 수 있습니다. 예를 들면 다음과 같습니다.

Driver->FindElement(By::Id("Submit"))->Click();

이 예시에서 #Submit 엘리먼트에 대한 레퍼런스를 획득한 뒤에 디폴트 Click 메서드를 직접 호출합니다. 드라이버 엘리먼트 레퍼런스에서 직접 사용 가능한 모든 액션은 호출된 엘리먼트에만 영향을 미칩니다. 위 Click 예시의 경우, 자동화 드라이버가 먼저 커서를 #Submit 엘리먼트로 옮기려고 시도합니다. 해당 엘리먼트가 슬레이트 DOM에 존재하지 않으면, 드라이버는 자동화 드라이버 환경설정에서 정의된, 묵시적으로 환경설정된 시간 범위까지 대기합니다. 시간이 초과되기 전에 엘리먼트가 나타나면 드라이버가 해당 엘리먼트로 커서를 옮기려고 시도합니다. 엘리먼트가 화면 밖에 있으면 드라이버는 뷰로 스크롤하는 등 엘리먼트를 화면으로 가져올 방법을 찾습니다. 엘리먼트가 뷰 안에 들어오면 커서가 엘리먼트 위로 이동하며, 그 경우에만 전체 클릭이 시뮬레이션됩니다.

모든 드라이버 엘리먼트 레퍼런스 메서드는 이 방식으로 작동하며, 메서드의 액션은 해당 엘리먼트에만 영향을 미칩니다.

시퀀스

드라이버 시퀀스는 드라이버에 액션을 발행하는 보다 안정적인 방법입니다. 시퀀스를 사용하면 원래대로라면 엘리먼트에 영향을 줄 수 있는 액션을 엘리먼트를 무시하며 수행할 수 있습니다. 아니면 특정 엘리먼트 세트에 액션을 수행할 수 있습니다. 또한 시퀀스는 여러 차례 호출할 수 있으므로 재사용성이 높고 헬퍼 라이브러리로도 좋습니다.

FDriverSequenceRef Sequence = Driver->CreateSequence();
Sequence->Actions()
    .MoveToElement(By::Id("Submit"))
    .Click(EMouseButtons::Left);
Sequence->Perform();

시퀀스는 Perform() 이 호출될 때까지 액션을 수행하지 않습니다. 액션을 시퀀스에 추가하면 제거할 수 없습니다. 수행 중에는 추가 액션을 시퀀스에 추가할 수 없지만 완료되면 추가가 가능합니다.

액션이 실패하면 전체 시퀀스가 실패하며 실행은 시리즈의 해당 지점에서 중단됩니다.

액션

시뮬레이션 가능한 모든 액션 타입에 관한 상세 정보는 다음 파일을 참조하세요.

  • Engine/Source/Developer/AutomationDriver/Public/IDriverElement.h

  • Engine/Source/Developer/AutomationDriver/Public/IDriverSequence.h

액션 세트는 현재 키보드 및 마우스 입력으로 제한되어 있으며 여기에는 일반적으로 다음이 포함됩니다.

  • 마우스 이동

  • 마우스 휠 스크롤

  • 클릭

  • 더블클릭

  • 버튼 누르고 있기(Press and Hold)

  • 버튼 놓기(Release)

  • 타입

  • Ctrl + Shift + S

  • 키 누르고 있기(Press and Hold)

  • 키 놓기(Release)

  • 포커스

여기에는 특정 엘리먼트가 뷰에 들어올 때까지 스크롤하기, 텍스트 구하기, 엘리먼트에 표시되는 크기 또는 위치 구하기 등 보조 액션이 포함됩니다.

현재 이 기능을 적극적으로 개발하고 있지는 않지만, 다음 지원이 추가될 수 있습니다.

  • 씬/액터 인터랙션

  • 컨트롤러 입력

  • 터치 입력/제스처

  • 모션 탐지

대기

사용자 시뮬레이팅 자동화 테스트를 빌드할 때에는 다양한 이벤트의 발생을 기다리는 것이 일반적입니다. 자동화 드라이버는 대기를 쉽게 만드는 지원이 내장되어 있습니다.

모든 자동화 드라이버 액션은 설정된 ImplicitWait 시간이 초과되어 액션 실패가 일어날 때까지 종속 시나리오가 발생하기를 자동으로 기다립니다. 클릭 이벤트를 시뮬레이션하기 전에 엘리먼트가 존재하고 표시되기를 기다리는 것이 그 예시입니다.

ImplicitWait 시간의 범위를 동적으로 설정하고 시뮬레이션 도중에 자동화 드라이버의 구성 옵션을 통해 필요한 만큼 조정할 수 있습니다. 예를 들어:

현재 디폴트 ImplicitWait 시간 범위는 3초이며, 다음과 같이 명시적 대기 또는 조건부 대기를 수행할 수도 있습니다.

Driver->Wait(FTimespan::FromSeconds(2));

FDriverSequenceRef Sequence = Driver->CreateSequence();
Sequence->Actions()
    .Wait(Until::ElementExists(ElementA, FWaitTimeout::InSeconds(3)))
    .Focus(ElementA);

Driver->Wait(Until::ElementIsVisible(ElementA, FWaitInterval::InSeconds(0.25), FWaitTimeout::InSeconds(1)));

Driver->Wait(Until::ElementIsInteractable(ElementA, FWaitInterval::InSeconds(0.25), FWaitTimeout::InSeconds(1)));

Driver->Wait(Until::ElementIsScrolledToBeginning(ScrollBox, FWaitTimeout::InSeconds(3)));

시뮬레이션을 계속하기 위해 반드시 성공해야 하는 자체 델리게이트 또는 람다 조건문을 지정할 수도 있습니다. 마지막으로 모든 대기에는 타임아웃 실행인자가 필요하며 선택 사항으로 간격 시간 범위를 지정할 수 있습니다. 이는 대기 조건을 얼마나 자주 재평가할지 정의합니다.

모두 결합하기

자동화 드라이버 API를 GameThread 에서 실행할 수 없으므로 시뮬레이션을 작성하기가 조금 어려울 수는 있습니다. 하지만 자동화 드라이버를 새 스펙 테스트 타입과 함께 사용하면 쉽게 해결할 수 있습니다.

다음은 스펙 예상(테스트)에 대한 120개 이상의 테스트 중 하나를 보여주는 스니펫입니다. 이는 자동화 드라이버 자체가 제대로 기능하는지 확인하는 데 사용됐습니다.

다음 스니펫을 더 잘 이해하려면 자동화 스펙 테스트 타입 문서를 참조하세요.

BEGIN_DEFINE_SPEC(FAutomationDriverSpec, "System.Automation.Driver", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
    TSharedPtr<SWindow> SuiteWindow;
    TSharedPtr<SAutomationDriverSpecSuite> SuiteWidget;
    TSharedPtr<IAutomationDriverSpecSuiteViewModel> SuiteViewModel;
    FAutomationDriverPtr Driver;
END_DEFINE_SPEC(FAutomationDriverSpec)
void FAutomationDriverSpec::Define()
{
    BeforeEach([this]() {
        if (IAutomationDriverModule::Get().IsEnabled())
        {
            IAutomationDriverModule::Get().Disable();
        }

        IAutomationDriverModule::Get().Enable();

        if (!SuiteViewModel.IsValid())
        {
            SuiteViewModel = FSpecSuiteViewModelFactory::Create();
        }

        if (!SuiteWidget.IsValid())
        {
            SuiteWidget = SNew(SAutomationDriverSpecSuite, SuiteViewModel.ToSharedRef());
        }

        if (!SuiteWindow.IsValid())
        {
            SuiteWindow = FSlateApplication::Get().AddWindow(
                SNew(SWindow)
                .Title(FText::FromString(TEXT("Automation Driver Spec Suite")))
                .ClientSize(FVector2D(600, 540))
                [
                    SuiteWidget.ToSharedRef()
                ]);
        }

        SuiteWidget->RestoreContents();
        SuiteWindow->BringToFront(true);
        SuiteViewModel->Reset();

        Driver = IAutomationDriverModule::Get().CreateDriver();
    });

    Describe("Element", [this]()
    {
        Describe("Type", [this]()
        {
            It("엘리먼트를 포커스하고 지정된 스트링의 문자를 타이핑해야 함", EAsyncExecution::ThreadPool, [this]()
            {
                FDriverElementRef Element = Driver->FindElement(By::Id("A1"));
                Element->Type(TEXT("ABCDEFGHIJKLMNOPQRSTUVWXYZ"));
                TEST_EQUAL(SuiteViewModel->GetFormString(EFormElement::A1), TEXT("ABCDEFGHIJKLMNOPQRSTUVWXYZ"));
            });
        });
    });

    AfterEach([this]() {
        Driver.Reset();
        IAutomationDriverModule::Get().Disable();
    });
}

이 스니펫의 핵심은 BeforeEach() 에 전달된 람다가 GameThread 에서 실행되며 테스트 시나리오를 구성하고, 위젯을 생성하고, 창을 적절한 위치에 배치한다는 것입니다. It() 람다에서 실제 입력 시뮬레이션이 발생하며, 자세히 보면 EAsyncExecution::ThreadPool 값이 It() 에 전달되는 것을 알 수 있습니다. 이렇게 하면 람다가 GameThread 와 별개의 스레드에서 실행되도록 합니다. 해당 스레드에서는 입력 시뮬레이션을 안전하게 수행할 수 있습니다. 따라서 자동화 드라이버 코드에 중단점을 넣고 다양한 액션이 수행될 때 하나씩 살펴볼 수 있습니다. 마지막으로 AfterEach() 는 환경을 클린업하며 GameThread 에서 다시 실행됩니다.

마치며

자동화 드라이버 코드로 작업할 때는 GameThread 에서 작업하는 게 아니라는 걸 유념해야 합니다. 따라서 스레드 세이프 방식이 아닌 SharedPtr 사본을 만드는 것은 안전하지 않습니다. 슬레이트는 스레드 세이프 방식이 아닌 SharedPtr 만 독점 사용하므로 이는 중요한 사항입니다.

스레드 세이프 방식이 아닌 SharedPtr 를 사용하여 액세스해야 하는 경우, 테스트를 위해 전용 BeforeEach() 블록을 만들어야 합니다. 해당 블록은 행동을 시뮬레이션 및 체크할 때 스레드 간의 바운스를 지원합니다.

SharedPtr 가 테스트 실행 도중 소멸하지 않는다는 것을 안다면, 다른 대안은 이를 테스트 클래스 자체에 캐시하여 SharedPtr 가 람다에 의해 액세스는 되지만 복제되지 않아서 레퍼런스 카운팅으로 경쟁 조건을 일으키지 않게 해야 합니다. 위 스니펫에서는 SuiteViewModel 을 사용하여 이를 수행했습니다.

일반적으로 모든 자동화 드라이버 시뮬레이션은 전용 BeforeEach() 블록에서 수행하는 것이 좋으며, 그런 다음 GameThreadIt() 에서 예상을 확인합니다.