안녕하세요!

Android 앱이 UI 자동화 테스트되는 과정의 일부에 대한 동영상입니다 :)

저는 앱서비스팀에서 Android/iOS의 배달의민족 앱 QA를 담당하고 있는 최윤주입니다.

배달의 민족 앱은 2주 단위로 정기 업데이트를 합니다. 정기 업데이트에는 사용자가 인지할 수 있을 만큼 큰 기능 변화가 있기도 하고, 왜 업데이트를 받았는지 의아할 정도로 작은 기능의 변화가 있기도 합니다.

전자이든 후자이든 사용자들은 먹고 싶은 음식을 먹고 싶은 곳에서 먹기 위해 배달의 민족 앱을 사용하고, 그 과정에서 앱이 갖고 있는 결함에 의해 음식을 먹지 못하거나 불쾌한 경험을 하는 일은 없어야 합니다.

저는 배달의민족 앱 QA를 담당하게 된 이후로 사용자들에게 이러한 가치를 꾸준히 전달하기 위해서 어떻게 해야 할지 고민하고 있습니다. 고민을 통해 여러 가지 방법들을 업무에 적용하고 있고, 그중에 꼭 해야겠다고 마음먹은 방법은 자동화 테스트 구현이었습니다.

앱이 통합 빌드 도구(Jenkins나 Bitrise 등)를 통해 빌드될 때마다 앱의 주요 기능들에 대해서 테스트가 반복적으로 수행되어서, 지속적인 변경으로부터 안정적인 품질로 앱이 배포될 수 있게 하고 싶었습니다.

앱이 배포된 후 뒤늦게 주요 기능 이슈들이 발견되어 핫픽스를 하게 되는 것을 최대한 막고 싶기도 했습니다.ㅠㅠ

아마 자동화 테스트의 중요성을 알고 실천하려는 분들의 목표도 저와 같으리라 생각합니다. (최근에 작성된 Spock Extension과 Elasticsearch + Kibana 조합으로 테스트 결과를 빠르게 피드백받기 블로그 글도 함께 참고해주세요.)

우선Android와 iOS 중 Android부터 UI 자동화 테스트를 구현해보자는 목표를 정하여 구현 중이었습니다. 그럼 이제, 자동화 테스트를 구현하며 알게 된 것과 느낀 점들에 대해서 이번 기술 블로그를 통해서 이야기하겠습니다.


테스트 코드 구현 대상으로 적합한 테스트 케이스 선정하기

자동화 테스트가 지속적으로 수행될 때 어떤 테스트 케이스들을 대상으로 수행하면 효과적일지도 중요합니다.

저는 우선 배민 앱 정기 업데이트마다 수행하고 있는 ‘small TC’를 대상으로 삼았습니다. ‘small TC’는 배포하는 날 매번 꼭 수행하고 넘어가는 테스트 케이스 묶음입니다. 이 ‘small TC’는 앱에 있는 기능을 모두 나열하고, 각각의 기능들에 대한 중요도를 측정해서 중요도가 높은 기능들 중 1시간 내 수행 가능한 기능들로 선정된 테스트 케이스 묶음입니다. (중요도가 높고 낮음에 대해서는 ‘이 기능이 수행되지 않으면 사용자에게 어떤 영향을 주는가?’라는 질문을 토대로 위험 영향도를 측정하여 결정하였습니다.) 따라서, 앱의 주요 기능들에 대한 케이스이기도 하면서 반복적으로 수행되는 케이스로, 자동화 하기 적합했습니다.


그러면 이제 Android UI 자동화 테스트 구현 방법에 대해서 이야기하도록 하겠습니다.

저는 Appium과 Cucumber를 사용하여 테스트 코드를 구현하였습니다.

Appium

Appium은 iOS/ Android/ Windows 세 가지 platform을 지원하고 있습니다.

Appium client-server architecture입니다

위 이미지는 Appium client-server 간의 구조도입니다. Appium은 Node.js로 구현된 Web Server입니다. 이 Server는 아래와 같은 역할들을 합니다.

  1. 클라이언트로부터 연결을 수신받아 세션을 시작합니다.
  2. 어떤 행위를 하라고 했는지 듣습니다.
  3. 지시받은 행위를 수행합니다.
  4. 수행 후, 수행 상태(결과)를 알려줍니다.

Appium Server는 클라이언트로부터 HTTP를 통해 JSON 객체 형식으로 연결을 받습니다. JSON에 정의된 대로 세션을 생성하고 테스트가 진행됩니다. Appium Server는 Node.js로 작성되었습니다. 그렇기 때문에 아래와 같이 Node부터 Brew를 통해 설치를 해줍니다. (Mac 환경에서 Java와 Brew가 설치되었다는 가정하에 작성되었습니다. Brew 설치가 필요하신 분들은 링크를 참고해주세요.)

Appium 설치

# node.js 설치
$ brew install node

# appium 설치
$ npm install -g appium

# appium-doctor 설치
$ npm install -g appium-doctor

Appium 설치가 완료되면, 아래 명령어를 통해 Appium 설치 상태를 확인합니다.

$ appium-doctor --android

android-sdk 설치

$ brew cask install android-sdk

만약 permission denided로 진행이 안되면 아래 명령으로 실행해야 합니다.

$ sudo install -d -o $(whoami) -g admin /usr/local/Caskroom

환경설정 파일에 android-sdk 경로를 추가하고 ANDROID_HOME을 추가합니다.

$ ln -s /usr/local/Caskroom/android-sdk/4333796 ~/Android

~/.zshrc 에 추가하고 저장합니다.

export ANDROID_SDK_ROOT="/usr/local/share/android-sdk"
export ANDROID_HOME="~/Android"

변경 내용을 적용합니다.

 $ source ~/.zshrc

설치된 후에는 /usr/local/Caskroom/android-sdk/4333796/tools/bin 경로로 이동합니다.

여기서 아래와 같이 sdkmanager 명령을 통해, 설치 가능한 Android 패키지 목록을 확인할 수 있습니다.

$ sdkmanager --list

AVD(Android Virtual Device)관련 패키지 설치

$ sdkmanager "build-tools;28.0.3"

  Path                                        | Version | Description                                | Location
  -------                                     | ------- | -------                                    | -------
  build-tools;28.0.3                          | 28.0.3  | Android SDK Build-Tools 28.0.3             | build-tools/28.0.3/
  emulator                                    | 29.2.1  | Android Emulator                           | emulator/
  patcher;v4                                  | 1       | SDK Patch Applier v4                       | patcher/v4/
  platform-tools                              | 29.0.4  | Android SDK Platform-Tools                 | platform-tools/
  platforms;android-28                        | 6       | Android SDK Platform 28                    | platforms/android-28/
  system-images;android-28;google_apis;x86_64 | 9       | Google APIs Intel x86 Atom_64 System Image | system-images/android-28/google_apis/x86_64/
  tools                                       | 26.1.1  | Android SDK Tools 26.1.1                   | tools/

설치 후 아래와 같은 명령어를 통해 emulator를 실행했을 때, 설치한 emulator가 실행되면 성공입니다! (commandline에서 Emulator를 실행하는 방법은 링크의 내용을 참고해주세요.)

emulator -avd avd_name [ {-option [value]}]

왜 Android Studio를 사용하지 않았는지

여기까지 글을 읽으신 몇몇 분들은 ‘Android Studio를 통해서도 emulator를 설치하고 실행할 수 있는데? 왜 이 방법을 소개할까?’ 하고 생각하실 수 있습니다.

사실 emulator는 Android Studio가 설치되어 있다면 Android Studio를 사용하여서도 설치가 가능합니다. (Android Studio를 통해 emulator를 설치하는 방법은 링크의 내용을 참고해주세요.)

하지만 테스트를 실행할 때 Android Studio의 다른 기능들은 사용하지 않고 Emulator만 사용기도 하고, 나중에 리눅스 환경에서 테스트가 실행되는 것도 함께 고려하여 Command line을 통해 Emulator를 설치하는 방법을 사용하게 되었습니다.

프로젝트 생성

그럼 이제 테스트 케이스를 구현하기 위한 Gradle 프로젝트를 생성해보겠습니다.

build.gradle 파일에 아래와 같이 의존성 정의를 합니다.

   dependencies {
      implementation (
            'org.slf4j:jcl-over-slf4j:2.0.0-alpha1',
            'org.slf4j:slf4j-api:2.0.0-alpha1',
            'org.slf4j:slf4j-simple:2.0.0-alpha1',
            'org.slf4j:slf4j-log4j12:2.0.0-alpha1',
            'ch.qos.logback:logback-classic:1.3.0-alpha4',
            'com.konghq:unirest-java:3.1.04',
            'com.konghq:unirest-objectmapper-jackson:3.1.04',
            'org.projectlombok:lombok:1.18.10',
            'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.10.0.pr1',
            'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.10.0.pr1'
      )
      testImplementation (
            'junit:junit:4.13-beta-3',
            'io.appium:java-client:7.2.0',
            'io.cucumber:cucumber-java8:4.7.4',
            'io.cucumber:cucumber-junit:4.7.4',
            'org.hamcrest:hamcrest:2.1',
            'org.hamcrest:hamcrest-all:1.3'
      )
      compileOnly (
         'org.projectlombok:lombok:1.18.10',
      )
      testAnnotationProcessor(
         'org.projectlombok:lombok:1.18.10',
      )
   }

   task cucumber() {
      dependsOn assemble, compileTestJava, testClasses

      doLast {
         javaexec {
            main = "cucumber.api.cli.Main"
            classpath = configurations.testRuntimeClasspath + sourceSets.main.output + sourceSets.test.output
            args = ['--plugin', 'pretty', '--glue', 'com.woowabros.qa.baemin', 'src/test/resources/features']
         }
      }
   }

그리고 추가로 필요한 것은, capability 클래스 파일입니다. capability는 테스트 작동 방식이 정의된 JSON 객체입니다. 여기에는 아래와 같은 정보들이 담기게 됩니다.

  public static DesiredCapabilities getDesiredCapabilities() {
      DesiredCapabilities capabilities = new DesiredCapabilities();

      // appium path
      capabilities.setCapability(MobileCapabilityType.APP, new File("app/com.sampleapp.cbt.apk").getAbsolutePath());
      // appium version
      capabilities.setCapability(MobileCapabilityType.APPIUM_VERSION, "1.15.1");
      // which mobile OS platform to use
      capabilities.setCapability(MobileCapabilityType.PLATFORM_NAME, "Android");
      // mobile os version
      capabilities.setCapability(MobileCapabilityType.PLATFORM_VERSION, "9");
      // The kind of mobile device or emulator to use
      capabilities.setCapability(MobileCapabilityType.DEVICE_NAME, "Android Emulator");
      // network connection
      capabilities.setCapability(AndroidMobileCapabilityType.SUPPORTS_NETWORK_CONNECTION, true);
      // alert handle
      capabilities.setCapability(AndroidMobileCapabilityType.SUPPORTS_ALERTS, true);
      // Move directly into Webview context. Default false
      capabilities.setCapability(MobileCapabilityType.AUTO_WEBVIEW, false);
      // Name of mobile web browser to automate. Should be an empty string if automating an app instead.
      // Dont reset app state before this session.
      capabilities.setCapability(MobileCapabilityType.NO_RESET, false); // false
      // Perform a complete reset.
      capabilities.setCapability(MobileCapabilityType.FULL_RESET, true); // true
      // Timeunit seconds
      capabilities.setCapability(MobileCapabilityType.NEW_COMMAND_TIMEOUT, 60);
      capabilities.setCapability(MobileCapabilityType.CLEAR_SYSTEM_FILES, true);
      // app Activity
      capabilities.setCapability(AndroidMobileCapabilityType.APP_ACTIVITY, "com.baemin.presentation.ui.GateWayActivity");
      capabilities.setCapability(AndroidMobileCapabilityType.APP_WAIT_ACTIVITY, "*");
      //capabilities.setCapability("gpsEnabled", true);

      // 접근 권한 허용
      capabilities.setCapability(AndroidMobileCapabilityType.AUTO_GRANT_PERMISSIONS, true);
      // take screenshot
      capabilities.setCapability(AndroidMobileCapabilityType.TAKES_SCREENSHOT, true);
      capabilities.setCapability("autoLanuch", true);
      capabilities.setCapability("autoAcceptAlerts", true);

      return capabilities;
   }

테스트할 App Package가 있는 경로의 위치, 사용할 Appium의 버전, 어떤 platform의 어떤 OS 버전을 사용할지 설정합니다. 그리고 deviceName에서는 테스트를 수행할 단말의 이름을 입력해줍니다. 또, 시스템에서 제공하는 alert을 제어할 건지 여부에 대한 설정, 세션 내에서 앱 상태를 reset 할 건지 여부 등등 다양한 설정이 있습니다.

저는 Android 앱을 자동화했기 때문에, Android에서 필요한 설정들을 추가했습니다.

추가한 내용에서 중요했던 것들은 바로 appActivity와 appWaitActivity입니다. 테스트하려는 앱 package에서 사용하고 있는 activity의 이름을 appActivity에 정의합니다. 또한 테스트 중 대기 상황이 발생할 수 있는데, 그때도 어떤 activity에 대해서 대기할 건지 설정해주어야 합니다. 기본적으로 appWaitActivity는 appActivity와 동일합니다. 따라서 저도 “*” 와일드 카드를 사용하였습니다.

(다른 설정들에 대해서 보고 싶으신 분들은 Appium Capability 설정 링크를 참고해주세요. )

코드 구현

이제, small TC라는 시나리오를 어떻게 테스트 코드에 녹여냈는지에 대해서 이야기해보겠습니다.

저는 Cucumber를 사용하였습니다. Cucumber는 Behaviour-Driven Development(BDD)를 지원하는 툴입니다 . (오이처럼 시원하게 BDD를 지원한다 하여 Cucumber라고 하네요.) Cucumber는 feature라는 파일에 텍스트로 작성된 시나리오를 읽고, 소프트웨어가 정의된 시나리오대로 작동하는지 확인할 수 있습니다.

(Cucumber에 대한 자세한 설명은 링크를 참고해주세요.)

Feature: 배달의 민족 앱을 사용하여 주문까지 완료하기
    Scenario: 배민 가게에서 회원이 메뉴를 담고 주문하기 지면으로 이동합니다.
        Given 배민 앱 메인화면 이동하여 회원 계정으로 로그인한 상태입니다.
        When 자동화 테스트_치킨맛집 가게로 이동하여 땡초치킨 메뉴를 장바구니에 추가합니다.
        Then 장바구니에 추가한 메뉴 정보가 표시되는 것을 확인합니다. 

이 테스트의 대상과 목적이 있고, 어떤 상황에서 특정 절차를 통해 행위를 했을 때 무엇을 확인하려고 하는지에 대해서 한눈에 확인이 가능합니다.

이렇게 feature 파일에 정의된 시나리오는 StepDefinition이라는 클래스 파일을 통해 아래와 같이 테스트 코드로 구현됩니다.

public class BaeminUserOrderStepDefinition implements En {

    MainPage mainPage;
    BaeminShopListPage baeminShopListPage;
    CartPage cartPage;

    public BaeminUserOrderStepDefinition() {
        Given("^배민 앱 메인화면 이동하여 회원 계정으로 로그인합니다.$", () -> {
            PermissionNoticeDialogPage permissionNoticeDialogPage = new PermissionNoticeDialogPage(Helper.getDriver());
            permissionNoticeDialogPage.onClickConfirmButton();

            AgreeToTermsOfServicesPage agreeToTermsOfServices = new AgreeToTermsOfServicesPage(Helper.getDriver());
            agreeToTermsOfServices.requiredAgree();
            agreeToTermsOfServices.startUsing();

            AndroidDriver driver = Helper.getDriver();
            driver.toggleLocationServices();
            driver.setLocation(new Location(37.5148, 127.103, 0.0));

            AgreeToReceiveMarketingInformationPopup agreeToReceiveMarketingInformationPopup = new AgreeToReceiveMarketingInformationPopup(driver);
            agreeToReceiveMarketingInformationPopup.onClick();

            driver.toggleLocationServices();

            mainPage = new MainPage(Helper.getDriver());

            ... (생략) ...
						
            baeminShopListPage = mainPage.clickBaeminMainIcon(TestDataConstant.BAEMIN_ICON_NAME);
        });

        When("^자동화 테스트_치킨맛집 가게로 이동하여 땡초치킨 메뉴를 장바구니에 추가합니다$", () -> {
            boolean popupCheckEnabled = true;

            BaeminShopDetailPage baeminShopDetailPage = baeminShopListPage.clickShop(TestDataConstant.BAEMIN_SHOP_NAME);
            if (popupCheckEnabled) {
                baeminShopDetailPage.getEventPopup().closePopup();
            }

            BaeminMenuDetailPage baeminMenuDetailPage = baeminShopDetailPage.clickNormalMenu(TestDataConstant.BAEMIN_PEPPERCHICKEN);
            baeminShopDetailPage = baeminMenuDetailPage.clickCartAddBth();

            assertThat("반마리 선택에서 2개를 선택해야 합니다", equalTo(baeminMenuDetailPage.getToastMessage()));
            Thread.sleep(3000);

            baeminMenuDetailPage.checkOptions(TestDataConstant.BAEMIN_OPTION_FRIEDHALF);
            baeminShopDetailPage = baeminMenuDetailPage.clickCartAddBth();
            assertThat("반마리 선택에서 2개를 선택해야 합니다", equalTo(baeminMenuDetailPage.getToastMessage()));
            Thread.sleep(3000);

            baeminMenuDetailPage.checkOptions(TestDataConstant.BAEMIN_OPTION_BAEMIN_PEPPERCHICKENDHALF);
            baeminShopDetailPage = baeminMenuDetailPage.clickCartAddBth();
            assertThat("장바구니에 메뉴를 추가했습니다", equalTo(baeminShopDetailPage.getToastMessage()));

            cartPage = baeminShopDetailPage.clickCartBtn();
        });

        Then("^장바구니에 추가한 메뉴 정보가 표시되는 것을 확인합니다.$", () -> {
            assertThat(TestDataConstant.BAEMIN_SHOP_NAME, equalTo(cartPage.getShopTitle()));
            assertThat(TestDataConstant.BAEMIN_PEPPERCHICKEN, equalTo(cartPage.getMenuName()));
            assertThat(TestDataConstant.BAEMIN_OPTION_INFO_FIRST, equalTo(cartPage.getOptionFirstText()));
            assertThat(TestDataConstant.BAEMIN_OPTION_INFO_SECOND, equalTo(cartPage.getOptionSecondText()));
            assertThat(TestDataConstant.BAEMIN_PRICE_INFO_SCENE2, equalTo(cartPage.getPriceText()));
            assertThat(TestDataConstant.BAEMIN_FLOATING_BUTTON_QUANTITY_INFO, equalTo(cartPage.getFloatingBtnQuantityInfo()));
            assertThat(TestDataConstant.BAEMIN_FLOATING_BUTTON_PRICE_INFO_SCENE2, equalTo(cartPage.getFloatingBtnPriceInfo()));
        });
    }
}

이렇게 구현된 테스트 코드는 아래와 같이 빌드를 하면 emulator를 통해 테스트가 실행되게 됩니다.

gradle clean cucumber -pbaemin-android

어려웠던 것들

모바일 앱 UI 자동화 테스트를 해야겠다고 마음먹은 후로 정말 셀 수 없이 많은 삽질을 했습니다. 특히 capability 설정 관련된 부분에서 시간을 많이 썼고, 그중 gpsEnabled 설정이 가장 어려웠습니다.

배달의민족 앱은 설치 후 최초 실행할 때 위치 정보를 단말로부터 가져오지 못하는 상황이면 주소를 직접 입력하여 설정하도록 유도하는 지면을 띄우고 있습니다. 단말에서 위치 정보를 사용하도록 앱 설치할 때 권한을 부여하지 않았거나, 위치 정보를 사용하도록 하였어도 서버 장애 상황에서 timeout이 발생한 경우가 이 경우에 해당할 수 있습니다.

이 문제를 쉽게 해결하려면 주소 설정 지면이 떴을 때 주소를 직접 검색 후 설정하는 코드를 테스트 케이스에 추가 구현하는 방법이 있습니다.

하지만 앱의 가장 기본 기능들이 정상 동작하는지 확인하고자 하는 목적이 있는 테스트에서 위치를 못 가져온 상황을 가정하여 테스트가 진행되도록 해야 한다는 부분이 목적에 맞지 않는다고 생각했습니다. 따라서 GPS 설정을 어떻게 하면 할지 알아보게 되었고, 결국 capability의 gpsEnabled를 true로 설정해야 테스트 코드에서 위경도 좌값을 임의로 지정하여 테스트가 진행되도록 할 수 있다는 것을 알게 되었습니다.

capability 내의 설정이 조금만 잘못되어도 의도했던 대로 테스트가 되지 않았던 부분들이 어려웠고, 지금도 capability 내의 몇몇 설정들에 대해서 꼭 해야 하는지 여전히 의문인 것들도 존재합니다. 이 부분은 제가 꾸준히 테스트 코드를 구현해나가면서, ‘이 설정이 꼭 필요했구나!’ 또는 ‘이 설정을 해야 했구나!’라고 끝없는 삽질 끝에 깨닫는 과정을 겪으며 더 알아가야 할 부분이라고 생각합니다.

저는 앞으로..

이제 막 첫걸음을 뗀 단계여서 구현된 케이스가 많지는 않지만, 저는 앞으로 계속 구현된 테스트 케이스를 유지 보수하는 작업을 꾸준히 이어가려 합니다.

열심히 유지 보수해서 배달의민족 앱의 품질 안정성을 확보하는데 기여하고 싶습니다.

그리고 아직은 제 Local 환경에서만 테스트 코드를 실행해보고 있습니다. 실행 결과에 대해서 확인이 필요한 부분들이 있으면 근처 자리에 앉아 계신 개발자에게 달려가서 확인을 하고 있습니다. 사실 테스트 결과를 저만 볼 수 있고, 문제가 발견되었을 때 바로 전달이 안 되는 부분은 진정한 자동화 테스트라고 이야기할 수 없다고 생각합니다.

그래서 저의 다음 목표는 Local에서만 실행되고 있는 테스트 코드를 별도의 테스트 환경에서 빌드가 나올 때마다 실행될 수 있도록 개선해보는 것입니다. 이 목표를 이루기 위해 저는 앞으로도 지속적인 노력을 하려 합니다.

목표를 이뤄서 다음번 기술 블로그도 작성할 수 있는 기회가 생기면 좋겠습니다.. :)

긴 글 읽어주셔서 감사합니다.

모두 새해 복 많이 받으세요! :)


[참고 서적]

Mobile Test Automation with Appium / Book by Nishant Verma

[참고 링크]

Cucumber : https://cucumber.io/docs/guides/overview

BDD에 대한 간략한 정리: https://www.popit.kr/bdd-behaviour-driven-development%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B0%84%EB%9E%B5%ED%95%9C-%EC%A0%95%EB%A6%AC/

Appium : http://appium.io/