서론

테스트 자동화를 진행하다 보면 처음에는 버튼은 찾아서 클릭하는 것부터 시작하는데요. 테스트해야하는 것이 복잡해지면 GameObject를 정확히 집어서 찾기가 어려워지는 걸 경험하게 됩니다.

이런 경우를 어떻게 해결해야 할까요? 이번 글에서는 DoguRpgSample을 예시로 하나씩 설명해 나가도록 하겠습니다.

DoguRpgSample 샘플 프로젝트 열기

  • gamium-unity-samples 프로젝트를 다운로드 받아 주세요. 이후 다운로드 받은 zip파일을 압축해제 해주세요.
  • Unity Hub를 통해 프로젝트를 열어주세요.
  • Projects 창에서 Scene > Login.unity 를 더블클릭하여 Scene을 열어주세요.
  • Play 버튼을 눌러 정상 실행되는지 확인해주세요.

전체 Sample 진행해보기

진행하기 전에 미리 작성된 스크립트를 한 번 실행해볼까요?

  • Unity Editor창에서 Play버튼을 누릅니다.
  • 터미널에서 gamium-unity-samples/client/python 로 이동 후 스크립트를 실행합니다.
cd gamium-unity-samples/client/python
python3 dogurpgsample-test.py
  • 아래와 같이 자동으로 계정생성 후 플레이를 하는 것을 볼 수 있습니다~
0:00
/0:57

로그인 기록에 맞추어 계정생성하기 - ui.try_find(locator)

그런데 말이죠. 위 테스트 스크립트를 한 번 더 실행해 보면 계정 생성 과정이 조금 다른 걸 확인할 수 있는데요. 한 번 영상으로 비교해 볼까요?

0:00
/0:03
0:00
/0:03

눈치채셨나요? 첫 번째 자동화에서는 처음부터 바로 로그인 선택 창이 나타나 있지만, 두 번째 자동화에서는 이미 로그인을 진행한 기록이 있기 때문에 게임 진입 창이 바로 표시되었습니다.

그래서 두 번째 자동화에서는 계정삭제 버튼을 누른 뒤에 새로 로그인을 한 결과랍니다.

그렇다면 이 과정을 어떻게 스크립트로 작성했을까요? 바로 ui.try_find(locator) 함수를 활용했습니다.

def create_account():
    # 계정삭제 버튼을 찾아본다.
    ret = ui.try_find(By.path("/Canvas[1]/Start[1]/DeleteAccountButton[1]"))
    # 계정삭제 버튼이 존재하고, 클릭이 가능할 경우 클릭한다.
    if ret.success and (ret.value.try_wait_interactable()).success:
        ret.value.click()
    # 로그인 버튼을 누르고 계성생성을 진행한다.
    ui.click(By.path("/Canvas[1]/Login[1]/Panel[1]/GuestLoginBtn[1]"))
    ui.set_text(By.path("/Canvas[1]/Register[1]/InputField[1]"), str(uuid.uuid4())[2:11])
    ui.click(By.path("/Canvas[1]/Register[1]/OkBtn[1]"))
    ui.click(By.path("/Canvas[1]/Start[1]/Desc[1]"))

dogurpgsample-test.py 중 계정삭제하는 부분

위 과정을 풀어보면 다음과 같습니다.

  1. 계정 삭제 버튼이 있는지 한 번 확인해보고, 해당 버튼이 상호작용 가능하다면? 클릭한다.
  2. 로그인 창이 표시되어 Guest 계정 생성이 진행할 수 있는 상황이 되었습니다.
  3. Guest 로그인 버튼을 누르고 계정 생성을 진행한다.

이렇게 되면 아래와 같이 두 가지 시나리오에서 모두 계정을 생성하고 진입이 가능합니다.

A. 첫 번째 실행(로그아웃 상태)

  • 1번에서 계정삭제 버튼이 상호작용이 불가능하여 클릭 시도하지 않고 넘어갑니다.
  • 2번인 Guest 계정 생성이 가능한 상황이 되어 3번으로 진행하고 계정 생성을 진행합니다.

B. 두 번째 실행 (앱에 로그인 기록이 있어 자동로그인된 상태)

  • 1번에서 계정삭제 버튼이 존재하고 상호작용이 가능하여 클릭으로 로그인된 계정을 삭제합니다.
  • 2번인 Guest 계정 생성이 가능한 상황이 되어 3번으로 진행하고 계정 생성을 진행합니다.
💡
ui.find(locator) 함수와 ui.try_find(locator)는 무슨 차이가 있을까요?

locator로 찾고자 하는 GameObject가 없을 경우
- ui.find(locator)는 에러를 발생시킵니다. 따라서 따로 에러를 catch하지 않는 이상 python 스크립트가 종료되어요.
- ui.try_find(locator)는 리턴값.success 에 False를 담아 리턴합니다. 따라서 python 스크립트를 종료시키지 않고 존재유무를 간단히 파악할 수 있어요.

locator로 찾고자 하는 GameObject가 있을 경우
- ui.find(locator)는 바로 찾은 UIElement를 리턴합니다.
- ui.try_find(locator)는 리턴값.value 에 UIElement를 담아 리턴합니다.

스크롤바가 있는 상품 구매하기 - gamium.wait(condition)

이번에는 자동화 과정 중에 좀 난이도가 있는 부분을 설명하고자 합니다.
바로 상품들을 구매하는 과정인데요. 먼저 영상을 확인해 볼까요?

0:00
/0:14

위 과정의 목적은 상품들을 전부 구매하는 것인데요. RedPotion들의 경우에는 바로 보여서 구매가 가능하지만 BluePotion2의 경우는 스크롤바를 내려야 합니다.

이 경우에 직관적으로 떠오르는 해결책은 "스크롤바를 내리면 BluePotion2이 있을 것이라 보고 스크롤바를 내리고 상품을 구매한다."인데요. 이렇게 하게되면 스크롤바를 어느 정도 움직여야 할까? 라는 문제에 부딪히게 됩니다. 스크롤바를 너무 많이 내리면 BluePotion2을 넘어가 버릴 수도 있고, 또 너무 조금 내리면 BluePotion2가 아예 보이지 않을 수 있기 때문입니다.

여기선 이 문제를 이렇게 해결했습니다.

"BluePotion2가 보일 때까지 스크롤바를 조금씩 내린다."

스크립트로 이렇게 구현하였습니다.

def buy_products():
    # 상품 GameObjects들을 얻어온다.
    products = ui.finds(By.path("/Canvas[1]/ShopView[1]/UIRoot[1]/Layout[1]/LeftPanel[1]/Products[1]/Scroll View[1]/Viewport[1]/Content[1]/ProductSlot(Clone)"))
    # 상품창 스크롤바를 얻어온다.
    scrollBar = ui.find(
        By.path("/Canvas[1]/ShopView[1]/UIRoot[1]/Layout[1]/LeftPanel[1]/Products[1]/Scroll View[1]/Scrollbar[1]/Sliding Area[1]/Handle[1]/Image 1[1]"),
    )
    # 스크롤 바와 상호작용이 가능할때까지 기다린다.
    scrollBar.wait_interactable()

    # 상품들을 순회한다.
    for item in products:
        # 특정 상품이 상호작용 가능하지 않다면 스크롤바를 내리는 함수
        def scroll_down_if_item_isnt_interactable():
            ret = item.try_is_interactable()
            if not ret.success:
                scrollBar.drag(
                    Vector2(scrollBar.info.position.x, scrollBar.info.position.y - 100),
                    ActionDragOptions(duration_ms=100, interval_ms=10),
                )
                return False
            item.click()
            return True

        # scroll_down_if_item_isnt_interactable가 true를 반환할때까지 반복합니다.
        gamium.wait(scroll_down_if_item_isnt_interactable, WaitOptions(timeout_ms=10000))

        # 상품 구매 Confirm 버튼을 누릅니다.
        ui.click(By.path("/Canvas[1]/ShopView[1]/MultipurposePopup(Clone)[1]/UIRoot[1]/Bottom[1]/Confirm[1]/Text[1]"))

이번에는 스크립트가 좀 긴 편에 속하는 것 같은데요. 여기서 중요한 부분은 scroll_down_if_item_isnt_interactable() 함수와 gamium.wait(condition, waitOptions) 입니다.

  • scroll_down_if_item_isnt_interactable() 함수를 보시면 item.try_is_interactable()의 결과에 따라 상호작용이 불가능하다면 스크롤바를 아래로 내리고 False리턴, 그 외의 경우 True를 리턴하는데요. 이 리턴값은 gamium.wait()함수에서 호출을 그만두어야 할지 판단하기 위해 사용합니다.
    True를 리턴한다는 것은 원하는 조건이 충족되어 더 이상 호출하지 않아도 된다는 것을 의미해요.
  • gamium.wait(condition, waitOptions) 함수는 condition으로 True, False를 리턴하는 함수를 인자로 받고, waitOptions로 얼마의 시간 동안 호출을 반복할지를 인자로 받습니다.
    이 두 가지 인자를 사용해서 gamium.wait은 condition함수를 특정 시간 동안 반복해서 호출합니다. 그리고 만약 시간제한을 넘어간다면 에러를 발생시키게 됩니다.

따라서 위 과정은 아래와 같은 과정이랍니다.

  • 전체 상품 목록을 하나씩 순회합니다.
  • 각각의 상품에 대해 상품이 상호작용이 가능한지 확인합니다.
  • 가능하지 않다면 가능할때까지 스크롤 바를 아래로 내리고, 상호작용이 가능한 상태가 되면 상품을 구매합니다.

전체 상품 조회하기 - ui.finds(locator)

위에서 추가로 살펴볼 부분이 있는데요. 바로 상품목록 ProductSlot(Clone) 들을 얻어오는 부분이랍니다.

products = ui.finds(By.path("/Canvas[1]/ShopView[1]/UIRoot[1]/Layout[1]/LeftPanel[1]/Products[1]/Scroll View[1]/Viewport[1]/Content[1]/ProductSlot(Clone)"))

각 상품들에 대한 GameObject Path를 Gamium Editor로 확인해보았을때는 아래와 같이 각자 다 다른 번호가 뒤에 붙어있는데요.

/Canvas[1]/ShopView[1]/.../ProductSlot(Clone)[1]
/Canvas[1]/ShopView[1]/.../ProductSlot(Clone)[2]
/Canvas[1]/ShopView[1]/.../ProductSlot(Clone)[3]
/Canvas[1]/ShopView[1]/.../ProductSlot(Clone)[4]
/Canvas[1]/ShopView[1]/.../ProductSlot(Clone)[5]
/Canvas[1]/ShopView[1]/.../ProductSlot(Clone)[6]

뒤에 붙어있는 [인덱스] 를 삭제한다면 위 ProductSlot(Clone) 이 모두 검색대상이 됩니다.

/Canvas[1]/ShopView[1]/.../ProductSlot(Clone)

따라서 아래와 같이 스크립트를 작성하면 전체 상품목록을 얻어올 수 있답니다.

products = ui.finds(By.path("/Canvas[1]/ShopView[1]/.../ProductSlot(Clone)"))

추가로 만약 ProductSlot(Clone)하위에 있는 제품 이름들을 모두 얻어오고 싶은경우에도 사용가능합니다.
중간에 있는 ProductSlot(Clone)뒤에 [인덱스]를 삭제해서 검색하면 아래와 같이 전부 얻어올 수 있습니다.

/Canvas[1]/ShopView[1]/.../ProductSlot(Clone)/TextPanel[1]/ProductName[1]/Text[1]

예를 들어 아래 스크립트를 실행한다면

items = ui.finds(By.path("/Canvas[1]/ShopView[1]/UIRoot[1]/Layout[1]/LeftPanel[1]/Products[1]/Scroll View[1]/Viewport[1]/Content[1]/ProductSlot(Clone)/TextPanel[1]/ProductName[1]/Text[1]"))
for item in items:
    print(item.get_text())

아래와 같이 전체 상품의 이름이 출력되는 것을 확인할 수 있어요.

INFO: GamiumClient.finds By: 0, str: /Canvas[1]/ShopView[1]/UIRoot[1]/Layout[1]/LeftPanel[1]/Products[1]/Scroll View[1]/Viewport[1]/Content[1]/ProductSlot(Clone)/TextPanel[1]/ProductName[1]/Text[1]
INFO: GamiumClient.finds By: 0, str: /Canvas[1]/ShopView[1]/UIRoot[1]/Layout[1]/LeftPanel[1]/Products[1]/Scroll View[1]/Viewport[1]/Content[1]/ProductSlot(Clone)[1]/TextPanel[1]/ProductName[1]/Text[1]
RedPotion
INFO: GamiumClient.finds By: 0, str: /Canvas[1]/ShopView[1]/UIRoot[1]/Layout[1]/LeftPanel[1]/Products[1]/Scroll View[1]/Viewport[1]/Content[1]/ProductSlot(Clone)[2]/TextPanel[1]/ProductName[1]/Text[1]
RedPotion2
INFO: GamiumClient.finds By: 0, str: /Canvas[1]/ShopView[1]/UIRoot[1]/Layout[1]/LeftPanel[1]/Products[1]/Scroll View[1]/Viewport[1]/Content[1]/ProductSlot(Clone)[3]/TextPanel[1]/ProductName[1]/Text[1]
RedPotion3
INFO: GamiumClient.finds By: 0, str: /Canvas[1]/ShopView[1]/UIRoot[1]/Layout[1]/LeftPanel[1]/Products[1]/Scroll View[1]/Viewport[1]/Content[1]/ProductSlot(Clone)[4]/TextPanel[1]/ProductName[1]/Text[1]
BluePotion
INFO: GamiumClient.finds By: 0, str: /Canvas[1]/ShopView[1]/UIRoot[1]/Layout[1]/LeftPanel[1]/Products[1]/Scroll View[1]/Viewport[1]/Content[1]/ProductSlot(Clone)[5]/TextPanel[1]/ProductName[1]/Text[1]
BluePotion2
INFO: GamiumClient.finds By: 0, str: /Canvas[1]/ShopView[1]/UIRoot[1]/Layout[1]/LeftPanel[1]/Products[1]/Scroll View[1]/Viewport[1]/Content[1]/ProductSlot(Clone)[6]/TextPanel[1]/ProductName[1]/Text[1]
BluePotion3
💡
위와 같이 검색하는 방법은 xpath에서도 사용되고 있는 방법인데요. gamium 에서도 유사한 방법이 적용되는 것은 GameObject Path를 통한 검색을 XPath Syntax 와 유사하게 적용하려는 노력의 일부랍니다.