Android ui test automation cover

UIAutomator2와 DeviceFarm을 활용한 UI 테스트 자동화

많은 사람이 자동화된 UI 테스트는 까다롭고 복잡하다는 편견이 있지만, UI 테스트를 수동으로 진행한다면 많은 시간과 비용이 소모됩니다. UiAutomator2와 AWS Device Farm을 활용하면 클라우드에서 자동화된 UI 테스트를 수행하고 결과를 보고받을 수 있고 이를 통해 테스트 시간과 장비 구매 비용을 획기적으로 줄일 수 있습니다. 스타트업 ‘숨고’에서 자동화된 UI 테스팅을 수행하며 얻은 값진 팁과 교훈을 공유합니다.

소개

저는 Soomgo라는 스타트업에서 안드로이드 개발자로 일하고 있습니다. 이번 강연에서는 UIAutomator2 프레임워크와 AWS Device Farm을 활용해서 실제 UI 테스트 자동화를 해본 경험과 관련 팁을 전달해드리고자 합니다.

UI 테스트 자동화

UITest-overview

UI 테스트를 자동화해야 하는 이유는 기존 수동 테스트가 가지고 있는 단점을 극복하기 위해서입니다. 수동 테스트는 사람들이 직접 테스트해야 하므로 모든 케이스를 다 커버하기 힘들었고 많은 인력과 시간이 필요했습니다. 또한 안드로이드는 각 기기에서 어떤 문제가 발생할지 알 수 없으므로 다양한 기기를 확보해야 했었죠. 저도 스타트업의 유일한 모바일 개발자로서 한정된 자원으로 더욱 나은 앱을 만들기 위해 자동 테스트를 도입하게 됐습니다. 자동 테스트의 경우 테스트를 잘 작성하기만 하면 수동 테스트의 한계를 많이 극복할 수 있었습니다. 특히 클라우드로 테스트하는 경우 실제 기기가 필요하지 않기 때문에 미국에서 개발하던 환경의 저희에게 적합한 솔루션이었습니다.

UIAutomator2

구글에서는 테스트 프레임워크로 Esspresso와 UIAutomator2, 두 가지를 제공하고 있는데 Esspresso는 단일 앱 테스트 목적으로 사용할 수 있고 UIAutomator2는 멀티 앱 테스트 목적으로 사용할 수 있습니다. 이 중 Esspresso는 소스코드가 있는 화이트박스 테스팅이며 다른 패키지로 이동할 경우 권한이 없어서 테스트를 원활히 할 수 없었고, UIAutomator2는 블랙박스 테스팅이었기 때문에 실제로 사용자가 앱을 쓰는듯한 시나리오를 모두 점검하기 위한 목적으로 UIAutomator2를 선택했습니다.

하지만 막상 UIAutomator2를 적용하려고 하니 관련 문서가 적어서 작년 Droid Kaigi의 일본어 발표 자료를 한국어로 번역한 자료를 참고할 수밖에 없었습니다. 이번 강연이 다른 분들께 도움이 되길 바랍니다.

API

  • By: BySelector 객체를 생성하는 유틸리티 클래스
  • BySelector: 화면상에서 UI 요소를 찾기 위한 선택자
  • UiDevice: 기기의 상태에 접근하고 기기를 제어할 수 있는 클래스
  • UiObject2: UiAutomator 2.0에 추가된 클래스로 더 강력한 기능 지원

이 네 가지 클래스만 알면 대부분의 테스트를 작성할 수 있습니다.

시작하기

UITest-start

먼저 build.gradle의 디펜던시에 UIAutomator를 넣습니다. 기본적으로 UITest는 Instrumentation 테스트이므로 안드로이드 테스트에다 넣어서 테스트합니다. @RunWith라는 Runner를 JUnit으로 설정해주세요. mDevice라는 UiDevice를 가져와서 null이 아니면 openApp()을 합니다. openApp()은 디바이스의 홈 버튼을 눌러서 바깥으로 빠져나간 다음 앱 이름으로 인텐트를 생성해서 앱을 실행하고, 비동기적으로 로딩되는 UI 요소를 넉넉한 타임아웃을 설정해서 wait로 기다려서 동기화해줍니다. 타임아웃은 테스트가 실패하지 않을 정도로 설정하면 됩니다.

UI 요소 찾기

UITest-find-ui1

테스트를 위해서는 UiObject2를 먼저 찾아야 하는데, BySelector 선택자로 UiDevice나 UiObject2에서 하위 요소를 검색할 수 있습니다. UiDevice와 UiObject2에서 찾는 메서드가 각각 두 개씩 있는데 위 메서드는 단일 오브젝트를, 아래 메서드는 다수의 오브젝트를 찾는 것입니다.

이런 개발 뉴스를 더 만나보세요

UITest-find-ui2

혹은 셀렉터로 요소들이 있는지 검사하는 메서드를 사용해서 assertTrue에 사용할 수 있습니다. 단, 여기서 중요한 것은 화면에 보이는 영역에서만 찾아주기 때문에 스크롤 아래에 숨어 있는 요소는 찾을 수 없으므로 먼저 스크롤하는 코드를 작성해주는 것이 좋습니다. 사용자들이 사용하는 시나리오와 유사하게 만든다고 보시면 됩니다.

선택자 생성

UI 요소를 검색하는 데 필요한 선택자인 BySelector는 다음 중 한 가지 방식으로 찾을 수 있습니다.

  • 패키지
  • 클래스
  • 컨텐트 설명
  • 리소스
  • 텍스트
  • 상태
  • 하위 관계

By.pkg(String applicationPackage)
By.pkg(Pattern applicationPackage)

// ex

BySelector selector1 = By.pkg("com.soomgo");
BySelector selector2 = By.pkg(Pattern.compile("com\\.soomgo.+"));

먼저 패키지는 특정 패키지에 속하는 요소가 있는지 찾을 때 사용할 수 있습니다. 모든 요소가 하나의 패키지에 있을 때는 사용하지 않겠지만, 멀티 패키지를 테스트하는 경우에는 어떤 화면이 종료되고 다른 화면이 켜졌을 때 해당 패키지 내부에서 탐색하는 데 사용할 수 있습니다.


By.pkg(String applicationPackage)
By.pkg(Pattern applicationPackage)

// ex

BySelector selector1 = By.clazz("android.support.v7.widget.RecyclerView");
BySelector selector2 = By.clazz("android.support.v7.widget", "RecyclerView");
BySelector selector3 = By.clazz(RecyclerView.class);
BySelector selector4 = By.clazz(Pattern.compile("[^.]|\\.RecyclerView"));

클래스는 뷰에 해당하는 클래스의 이름으로 찾을 수 있습니다. 위 예제는 현재 화면상에서 리사이클러뷰를 찾아주는 예제입니다. 마지막 메서드는 패턴으로 정규 표현식을 사용했습니다.


By.desc(String contentDescription)
By.descContains(String substring)
By.descStartsWith(String substring)
By.descEndsWith(String substring)
By.desc(Pattern contentDescription)

// ex
BySelector selector1 = By.desc("button");
BySelector selector2 = By.desc(Patter.compile(".+button"));

뷰에 있는 컨텐트디스크립션으로도 찾을 수 있습니다. 텍스트로 뷰를 찾을 수 있어서 유용합니다. 버튼이라든지 에디트텍스트라든지, 혹은 텍스트뷰에 실제 정확한 값이 들어가 있는지 확인하기 위해 사용할 수 있습니다. 리사이클러뷰에 어떤 아이템이 들어있는지 모두 확인하기는 어려울 수 있으므로 아이템에 디스크립션으로 특정 값을 적어주고 찾아주면 유용합니다.


By.res(String resourceName)
By.res(String resourcePackage, String resourceId)
By.res(Pattern resourceName)

// ex
BySelector selector1 = By.res("com.soomgo:id/button_sign_in");
BySelector selector2 = By.res("com.soomgo", "button_sign_in");
BySelector selector2 = By.res(Patter.compile(".*:id/button_sign_in"));

리소스 ID로도 요소를 찾을 수 있습니다. button_sign_in이라는 ID로 찾을 수 있는데, 어떤 요소의 ID가 무엇인지 알아야 사용할 수 있으므로 블랙박스 테스트의 철학과는 맞지 않긴 합니다.


By.text(String text)
By.textContains(String substring)
By.textStartsWith(String substring)
By.textEndsWith(String substring)
By.text(Pattern regex)

// ex
BySelector selector1 = By.text("로그인");
BySelector selector2 = By.text(Pattern.compile(".*로그인"));

또한, 가장 많이 사용하는 텍스트로도 요소를 찾을 수 있습니다. 화면에 표시되는 텍스트를 전체, 혹은 시작이나 끝부분 텍스트나 정규 표현식으로 찾아서 사용하면 됩니다.


By.checkable(boolean isCheckable)
By.checked(boolean isChecked)
By.clickable(boolean isClickable)
By.enabled(boolean isEnabled)
By.focusable(boolean isFocusable)
By.focused(boolean isFocused)
By.longClickable(boolean isLongClickable)
By.scrollable(boolean isScrollable)
By.selected(boolean isSelected)

여러 가지 상태로도 요소를 찾을 수 있습니다. 체크됐는지 클릭 가능한지 등을 기반으로 찾습니다.


By.depth(int depth)
By.hasChild(BySelector childSelector)
By.hasDescendant(BySelector descendantSelector)
By.hasDescendant(BySelector descendantSelector, int maxDepth)

마지막으로 하위 요소 포함 관계로도 요소를 찾을 수 있습니다. 차일드가 있는지, 자손이 있는지 등등으로도 검색할 수 있습니다. BySelector를 조합하면 거의 모든 요소를 찾을 수 있고, 이렇게 찾은 요소를 가지고 테스트를 할 수 있습니다. 테스트란 말 그대로 어떤 버튼을 클릭해서 화면이 넘어가면 어떤 요소가 있는지, 그 요소를 눌렀으면 어떤 창이 뜨는지 등등을 기술하는 것입니다.

헬퍼 메서드

앞서 보여드린 기본 클래스에 헬퍼 메서드를 작성해봤습니다. 나중에 테스트할 때 사용하시면 유용합니다.


// BaseTest.java
protected UiObject2 findButton(int textResourceId) { 
  // textResourceId가 표시된 버튼을 찾는다.
  return findObject(button(textResourceId));
}

protected BySelector button(int textResourceId) {
  // textResourceId가 표시된 버튼을 나타내는 BySelector.
  return By.text(string(textResourceId)).clickable(true);
}

protected UiObject2 findByText(int textResourceId) {
  // textResourceId가 표시된 요소를 찾는다.
  return findObject(byText(textResourceId));
}

protected BySelector byText(int textResourceId) {
  // textResourceId가 표시된 요소를 나타내는 BySelector.
  return By.text(string(textResourceId));
}

protected UiObject2 findByDesc(int textResourceId) {
  // textResourceId를 컨텐트 설명으로 갖는 요소를 찾는다.
  return findObject(byDesc(textResourceId));
}

protected BySelector byDesc(int textResourceId) {
  // textResourceId를 컨텐트 설명으로 갖는 BySelector.
  return By.desc(string(textResourceId));
}

protected String string(int textResourceId) {
  // textResourceId에 해당하는 문자열.
  return getTargetContext().getString(textResourceId);
}

protected UiObject2 findObject(BySelector selector) {
  mDevice.wait(Until.hasObject(selector), DEFAULT_TIMEOUT);
  return mDevice.findObject(selector);
}

findButton은 특정 아이디의 버튼을 찾을 수 있는 메서드로 buttonclickabletext로 특정 텍스트가 들어갔고 클릭이 가능한 버튼인지 찾아줍니다. 이런 헬퍼 메서드는 UIAutomator에서 제공하는 것은 아니지만 다음에 테스트하실 때 이용하시면 좋을 것 같은 팁이어서 공유합니다.


// BaseTest.java
protected void hideKeyboard() {
  InputMethodManager manager = (InputMethodManager) getTargetContext()
    .getSystemService(Context.INPUT_METHOD_SERVICE);
  if (manager.isAcceptingText()) {
    mDevice.pressBack();
  }
}

protected void assertHas(BySelector selector) {  
  assertTrue(mDevice.hasObject(selector));
}

또한 hideKeyboard()는 키보드가 떠있는 경우 닫아주는 헬퍼 메서드로, 키보드 밑에 요소가 가려서 테스트할 수 없는 경우를 방지해줍니다. 아래는 어떤 셀렉터에 해당하는 요소가 있는지 없는지 검사하는 헬퍼 메서드입니다.

UI 테스트 작성하기

UITest-login1

Soomgo앱의 로그인 화면입니다. 세 가지 버튼이 있는데, MainActivityTestBaseTest를 상속받아서 before() 메서드를 갖고 있습니다. before()에서는 먼저 Session을 초기화시켜서 로그아웃 해주고 있습니다. 그다음 테스트는 백 버튼을 눌러서 홈 화면으로 나간 후 hasObject로 아무 패키지에 해당하는 요소가 없음을 검사합니다.


// BaseTest.java
@RunWith(AndroidJUnit4.class)
public abstract class BaseTest {
  protected UiDevice mDevice;
  
  @Before
  public void before() {
    mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());      
    assertNotNull(mDevice);
    openApp();
  }
}

이 중 before()에서 호출한 super.before()는 위와 같이 앱을 실행시켜주는 코드입니다.

UITest-login2

위 버튼은 고수로 가입을 눌렀을 때를 테스트하는 코드입니다. “고수로 가입”이라는 스트링을 지정해서 findButton으로 해당 버튼을 찾고 clickAndWait로 새 창이 뜰 때까지 UI를 기다렸다가 “시작하기”라는 스트링이 있는 버튼이 있으면 assert를 해줍니다. “시작하기”는 다음 화면에 있는 스트링입니다.

UITest-find-ui3

마찬가지로 다른 버튼을 눌렀을 때 화면이 넘어가고 특정 요소들이 있는지 검사하는 코드입니다.

UI 동기화

UI 요소는 비동기적으로 작동하므로 동기화를 해주지 않으면 테스트가 실패합니다. 이런 UI 동기화를 하는 첫번째 방법은 EventCondition을 활용해서 특정 이벤트가 일어나기까지 기다리는 것입니다.


UiObject2#clickAndWait(EventCondition<R> condition, long timeout)
// UiObject2를 클릭하고 condition을 만족할 때까지 기다린다.

UiDevice#performActionAndWait(Runnable action, EventCondition<R> condition, long timeout)
// action을 수행하고 condition을 만족할 때까지 기다린다.

// ex
findButton(R.string.sign_up_as_a_pro).clickAndWait(Until.newWindow(), DEFAULT_TIMEOUT);
mDevice.performActionAndWait(() -> {
  findButton(R.string.sign_up_as_a_pro).click();
}, Until.newWindow(), DEFAULT_TIMEOUT);

기본으로 제공되는 EventConditionUntil의 스태틱 메서드로 존재하는 newWindow()scrollFinished()가 있습니다. 원하는 액션이 일어날 때까지 기다렸다가 다음 assert를 해주면 됩니다.


Until.newWindow()
// 새로운 창(Activity 혹은 Dialog)이 생성됐는지 여부.

Until.scrollFinished(final Direction direction)
// direction 방향으로 스크롤이 끝났는지 여부.

그 밖에도 화면에 UI 요소가 있는지 기다리려면 SearchCondition을 사용합니다.


UiDevice#wait(SearchCondition<R> condition, long timeout)
// 화면에서 condition이 성립될 때까지 기다린다.

UiObject2#wait(SearchCondition<R> condition, long timeout)
// 해당 UiObject2 객체 내에서 condition이 성립될 떄까지 기다린다.

// ex
mDevice.wait(Until.hasObject(By.scrolllable(true)), DEFAULT_TIMEOUT);
findObject(By.scrollable(true)).wait(Until.hasObject(By.clickable(true)), DEFAULT_TIMEOUT);

// 기본 제공 SearchCondition

Until.gone(final BySelector selector)
// selector에 해당하는 요소가 사라졌는지 여부.

Until.hasObject(final BySelector selector)
// selector에 해당하는 요소가 발견했는지 여부. 발견 여부를 리턴한다.

Until.findObject(final BySelector selector)
// selector에 해당하는 요소를 발견했는지 여부. 발견한 요소를 리턴한다.

Until.findObjects(final BySelector selector)
// selector에 해당하는 요소를 발견했는지 여부. 발견한 여러개의 요소를 리턴한다.

마지막으로 UiObject2 객체의 상태가 특정 조건을 만족할 때까지 기다리려면 UiObject2Condition을 사용합니다.


UiObject2#wait(UiObject2Condition<R> condition, long timeout)
// UiObject2의 상태가 condition과 일치할 때까지 기다린다.

// ex
findByDesc(R.string.check_box).wait(Until.selected(true), DEFAULT_TIMEOUT);

// 기본 제공 UiObject2Condition

Until.checkable()
Until.checked()
Until.clickable()
Until.enabled()
Until.focusable()
Until.focused()
Until.longClickable()
Until.scrollable()
Until.selected()
Until.descMatches()
Until.descEquals()
Until.descContains()
Until.descStartsWith()
Until.descEndsWith()
Until.textMatches()
Until.textNotEquals()
Until.textEquals()
Until.textContains()
Until.textStartsWith()
Until.textEndsWith()

이렇게 총 세 가지 방법으로 UI 상태를 동기화시켜서 테스트 실패를 막을 수 있습니다.

애니메이션 비활성화

정말 중요한 팁은 애니메이션이 진행되는 동안 타임아웃이 걸릴 수 있으므로 애니메이션을 비활성화시켜야 한다는 것입니다. 물론 실제 기기에서 테스트를 한다면 설정에서 기기의 애니메이션 옵션을 끌 수 있겠지만, 클라우드에서 테스팅하는 경우에는 코드로 작성해줘야 합니다.


// AndroidManifest.xml
<uses-permission android:name=“android.permission.SET_ANIMATION_SCALE"/>

// app/build.gradle
task grantAnimationPermission(type: Exec, dependsOn: 'installDebug') {
  commandLine "adb shell pm grant com.soomgo android.permission.SET_ANIMATION_SCALE".split(' ')
}
  
tasks.whenTaskAdded { task ->
  if (task.name.startsWith('connected')) {
    task.dependsOn grantAnimationPermission
  }
}

Manifest에 권한을 넣어주고 build.gradle에 위 코드를 넣어줍니다. 디버그 설치를 할 때 셸 명령어로 권한을 할당해주는 코드입니다.


// BaseTest.java
@Before
public void before() throws IOException {
  // ... 
  mSystemAnimations = new SystemAnimations(getTargetContext());
  mSystemAnimations.disableAll();
}

@After
public void after() {
  if (mSystemAnimations != null) {
    mSystemAnimations.enabledAll();
  }
}

그다음 BaseTest에서 테스트가 시작하기 전에 애니메이션을 끄는 코드와 테스트가 끝난 후에 다시 켜주는 코드를 넣어줍니다.

클라우드 테스팅

UITest-cloud-testing

위 그림과 같은 세 가지 선택지가 있습니다. Firebase와 같은 경우 기기 수가 너무 적었고, Xamarin은 기기는 많지만 비용이 많이 들고 Calabash나 Xamarin UITest로만 사용해야 했기 때문에 마지막 선택지인 AWS Device Farm을 선택했습니다. 기기도 많고 기기마다 OS 버전별로 선택할 수 있어서 국내 대부분의 기기를 대응할 수 있었습니다.

먼저 1단계에서 안드로이드 플랫폼을 선택한 후 앱 APK를 업로드합니다. 다음으로 2단계에서는 UI Automator가 아니라 꼭 Instrumentation을 선택해야 합니다. ./gradlew assembleAndroidTest로 테스트 APK를 생성하고 “app/build/outputs/apk”의 테스트 APK를 업로드합니다. 다음으로 3단계에서 원하는 디바이스와 버전을 골라서 디바이스 셋을 만듭니다. 4단계에서는 각각의 디바이스의 Wi-fi, GPS 등의 상태를 설정합니다. 마지막으로 5단계에서 디바이스가 언제 끝날지 timeout을 설정하는 데, 테스트는 통과하면서 분 단위의 과금이 최소화되도록 조절하면서 선택하는 것이 좋습니다.

테스트가 끝나면 위 화면과 같이 테스트 결과를 보여주고, 만약 테스트가 실패하면 실패하기 전후 화면을 비디오 녹화로 보여주며, 파일 상태나 퍼포먼스, 스크린샷까지 다양한 결과를 알려줍니다.


본 영상과 글은 Droid Knights의 비디오 스폰서인 Realm에서 제공합니다. 모바일 개발자가 더 나은 앱을 더 빠르게 만들도록 돕는 Realm 모바일 데이터베이스Realm 모바일 플랫폼을 통해 핵심 로직에 집중하고 개발 효율을 높여 보세요! 공식 문서에서 단 몇 분 만에 시작할 수 있습니다. 또한 Realm 홈페이지에서는 모바일 개발자를 위한 다양한 최신 기술 뉴스와 튜토리얼을 제공하고 있으니 즐겨찾기하고 자주 들러 주세요!

다음: Realm Java의 새로운 기능을 만나 보세요!

General link arrow white

컨텐츠에 대하여

2017년 3월에 진행한 Droid Knights 행사의 강연입니다. 영상 녹화와 제작, 정리 글은 Realm에서 제공하며, 주최 측의 허가 하에 이곳에서 공유합니다.

유윤재

2010년부터 안드로이드 개발을 해왔고 3번의 스타트업 창업 경험이 있습니다. 현재는 스타트업 ‘숨고’에서 모바일 앱 개발 리드로 일하고 있습니다.

4 design patterns for a RESTless mobile integration »

close