코드를 짜다 보면 "이거 진짜 되는지 확인해봐야지" 하는 순간이 계속 온다. 그런데 그 확인을 매번 손으로 하고 있다면, 앱이 커질수록 확인해야 할 항목도 같이 늘어난다. 버튼 하나 눌러보고, 입력창에 글자 쳐보고, 화면 전환되는지 보고를 반복하다 보면 어느 순간 "코드를 짜는 시간"보다 "손으로 확인하는 시간"이 더 길어지는 지점이 온다.
테스트 코드는 이 반복 확인 작업을 코드로 대신 시키는 것이다. 한 번 짜두면 그다음부터는 실행 버튼만 누르면 몇 초 만에 같은 확인을 다시 해준다. 여기서 얻는 이점은 크게 세 가지로 정리할 수 있다.
첫째, 장애를 빨리 알아챌 수 있다. 코드를 수정한 직후에 바로 테스트를 돌려보면, 문제가 생겼을 때 "방금 건드린 부분" 안에서 원인을 찾으면 된다. 반면 한참 뒤에 수동으로 확인하다가 문제를 발견하면, 그사이 쌓인 변경 사항 전체를 뒤져야 한다.
둘째, 리팩터링을 겁내지 않게 된다. 리팩터링이란 겉으로 보이는 동작은 그대로 두고 코드 내부 구조만 정리하는 작업인데, "혹시 내가 뭔가 망가뜨리진 않았을까" 하는 불안이 항상 따라온다. 테스트가 있으면 리팩터링 후에 테스트를 돌려서 초록불이 뜨는지만 확인하면 되니까, 이 불안이 훨씬 줄어든다.
셋째, 개발 속도가 안정적으로 유지된다. 테스트 없이 개발하면 초반에는 빠르지만, 기능이 쌓일수록 "이거 고치면 저게 깨지지 않을까" 하는 걱정 때문에 점점 손이 느려진다. 테스트가 이 걱정을 대신 감당해주기 때문에, 프로젝트가 커져도 개발 속도가 크게 떨어지지 않는다.
일반적인 단위 테스트(Unit Test)는 함수 하나에 값을 넣고 결과가 맞는지 확인하는 정도라 JVM 위에서 빠르게 돈다. 그런데 UI 테스트는 다르다. 실제로 화면이 그려지고, 사용자가 버튼을 누르고, 그 결과로 화면이 어떻게 바뀌는지까지 확인해야 한다. 그래서 UI 테스트는 실제 안드로이드 기기나 에뮬레이터 위에서 앱을 실제로 띄워놓고 동작을 검증하는 계측 테스트(Instrumentation Test) 방식으로 이루어진다. 이 방식은 실제 기기 동작에 충실한 대신, 단위 테스트보다 느리다.
문제는 안드로이드 앱이 확인해야 할 조합의 수가 너무 많다는 데 있다. 같은 화면이라도 API 레벨이 다른 기기, 화면 크기가 다른 기기, 언어 설정이 다른 기기, 세로 모드와 가로 모드, 폰과 태블릿과 폴더블까지 조합하면 사람이 손으로 다 눌러보고 확인하는 건 사실상 불가능에 가깝다. 화면 하나를 고쳤는데 그게 다른 화면 크기에서도 잘 보이는지, 회전했을 때도 문제없는지를 매번 손으로 확인한다고 생각해보면 왜 이 작업을 자동화해야 하는지 감이 온다.
그래서 필요한 게 "코드로 사용자의 행동을 흉내 내고, 그 결과를 코드로 검증하는" UI 테스트 자동화다. 안드로이드에서는 이 역할을 Espresso가 맡는다.
Espresso는 안드로이드 스튜디오에 기본으로 포함된 UI 테스트 자동화 프레임워크다. 별도로 무거운 설정을 하지 않아도 바로 쓸 수 있다는 점이 특징이다.
Espresso가 하는 일을 한 문장으로 요약하면, 실제 사용자가 화면에서 하는 행동(버튼 클릭, 텍스트 입력, 스크롤)을 코드로 그대로 재현하고, 그 결과 화면이 기대한 대로 바뀌었는지를 코드로 검증하는 것이다. UI가 조금이라도 바뀔 때마다 사람이 다시 손으로 눌러보고 확인할 필요 없이, 테스트 코드를 한 번 돌리면 같은 확인을 자동으로 반복해준다.
여기서 눈여겨볼 부분은 "간결한 문법"이다. Espresso는 뒤에서 다룰 onView, perform, check 세 개의 함수 조합만 익히면 대부분의 UI 동작을 검증할 수 있도록 설계되어 있다. 문법이 단순한 편이라 진입 장벽이 낮은 편이다.
Espresso 테스트 코드는 일반 코드와 다른 폴더에 들어간다. app/src/androidTest/java 경로다. 여기 코드는 실제 기기나 에뮬레이터 위에서 앱을 띄워서 실행하는 계측 테스트이기 때문에, JVM에서 도는 일반 단위 테스트가 들어가는 app/src/test 폴더와는 완전히 다른 자리다.
UI 테스트를 하려면 먼저 테스트 대상이 되는 Activity를 화면에 띄워야 한다. 이 역할을 해주는 것이 ActivityScenarioRule이다.
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@get:Rule이 붙은 이 코드는 JUnit의 Rule 메커니즘을 이용한다. 테스트 메서드가 시작되기 전에 지정한 Activity(여기서는 MainActivity)를 자동으로 실행하고, 테스트 메서드가 끝나면 그 Activity를 자동으로 정리한다. 왜 테스트 메서드마다 이 과정을 반복할까? 한 테스트에서 건드린 화면 상태가 다음 테스트로 넘어가면, 테스트끼리 서로 영향을 주고받게 되어 결과를 신뢰할 수 없게 되기 때문이다. 테스트 메서드마다 항상 깨끗한 상태의 Activity에서 시작하게 만들어주는 것이 이 Rule의 핵심 역할이다.
Espresso 코드는 거의 항상 아래와 같은 3단 구조를 따른다.
onView(withId(R.id.text_view))
.check(matches(withText("Hello World!")))
이 구조를 세 조각으로 나눠보면 이렇게 읽을 수 있다.
onView(withId(...)): 화면에서 테스트하고 싶은 UI 요소를 찾는다. withId는 그 요소를 어떤 기준으로 찾을지 정하는 매칭 조건(Matcher)이다..perform(...): 찾은 요소에 어떤 행동을 수행시킨다. 클릭, 텍스트 입력, 스크롤 같은 동작이 여기 들어간다..check(...): 찾은 요소가 기대한 상태와 일치하는지 검증한다. matches는 실제 값과 기대 값을 비교하는 조건이다.즉 "화면에서 뷰를 찾고 → 그 뷰에 행동을 시키고 → 결과를 검증한다"는 세 단계가 그대로 함수 이름에 드러나 있는 구조다.
여기서 한 가지 흥미로운 설계 원칙이 있다. Espresso는 테스트 코드가 Activity나 View 객체에 직접 접근하지 못하게 만들어져 있다. findViewById로 뷰 객체를 직접 가져와서 상태를 확인하는 대신, 반드시 onView라는 창구를 통해서만 뷰에 접근하도록 강제한다. 왜 이렇게 불편하게 만들었을까? 만약 테스트 코드가 View 객체를 직접 붙잡고 있으면, UI 스레드가 그 View를 다시 그리는 도중에 테스트 코드가 동시에 그 View를 건드리는 상황이 생길 수 있다. 이러면 테스트가 어떤 날은 성공하고 어떤 날은 실패하는 불안정한 결과를 낸다. Espresso는 onView 뒤에서 UI 스레드와 테스트 스레드 사이의 동기화를 대신 관리해줌으로써 이런 불안정성을 줄인다. 즉 문법이 다소 제한적으로 보이는 이유는 "테스트를 안정적으로 만들기 위한 설계"라는 목적이 있기 때문이다.
뷰를 찾는 방법부터 보자. 가장 흔한 방식은 ID로 찾는 것이다.
// ID로 찾기
onView(withId(R.id.button))
// Text로 찾기
onView(withText("Hello World"))
withId는 XML 레이아웃에 지정한 ID를 기준으로 뷰를 찾고, withText는 화면에 표시된 텍스트를 기준으로 뷰를 찾는다. ID가 없는 뷰이거나, 텍스트만으로 구분이 되는 상황이라면 withText가 더 편할 때가 있다.
뷰를 클릭하는 코드는 이렇다.
onView(withId(R.id.button))
.perform(click())
텍스트를 입력하는 코드는 이렇다.
onView(withId(R.id.edit_text))
.check(matches(isDisplayed()))
.perform(typeText("hi"))
여기서 check(matches(isDisplayed()))가 먼저 붙어 있는 걸 눈여겨볼 만하다. 입력 동작을 수행하기 전에 "이 입력창이 실제로 화면에 보이는 상태인가"를 먼저 확인한 것이다. Espresso의 perform은 실제 사용자처럼 동작하기 때문에, 화면에 보이지 않는 요소에는 입력이나 클릭을 수행할 수 없다. 이 점은 뒤에서 다룰 헷갈리는 포인트와도 이어진다.
화면에 같은 ID나 같은 텍스트를 가진 뷰가 여러 개 있으면, withId 하나만으로는 어떤 뷰를 말하는 건지 구분이 안 될 수 있다. 이럴 때는 allOf로 여러 조건을 동시에 걸어서 매칭 범위를 좁힌다.
onView(allOf(withId(R.id.text_view), withText("text")))
이 코드는 "ID가 text_view이면서 동시에 텍스트가 text인 뷰"만 정확히 골라낸다. 조건을 하나만 걸었을 때 여러 개가 걸려서 에러가 나는 상황을 이렇게 조건을 더해서 해결하는 것이다.
스크롤이 필요한 경우도 있다. 화면 아래쪽에 있어서 지금 당장은 안 보이는 요소를 확인하고 싶을 때다.
// when: 사용자가 스크롤하면
onView(withId(R.id.scroll_view))
.perform(swipeUp())
// then: 화면에 버튼이 표시된다
onView(withId(R.id.button))
.check(matches(isDisplayed()))
먼저 스크롤 동작을 수행해서 화면을 아래로 넘기고, 그 다음에 원하는 뷰가 화면에 나타났는지를 검증하는 순서다. 실제 사용자가 스크롤한 다음에 버튼을 발견하는 흐름을 그대로 코드로 옮긴 것이라고 보면 된다.
Espresso를 처음 만지면 문법 자체보다 아래와 같은 지점에서 막히는 경우가 많다. 하나씩 짚어보겠다.
test 폴더와 androidTest 폴더를 헷갈린다. Espresso 테스트는 반드시 app/src/androidTest/java에 넣어야 한다. app/src/test는 Mockito 같은 걸로 순수 로직만 검증하는 JVM 단위 테스트 자리이고, androidTest는 실제 기기·에뮬레이터에서 도는 계측 테스트 자리다. 폴더를 잘못 넣으면 테스트가 아예 인식되지 않거나 엉뚱하게 동작한다.
에뮬레이터나 기기가 켜져 있어야 한다는 걸 모른다. Espresso 테스트는 계측 테스트라서 JVM 단위 테스트처럼 실행 버튼만 누른다고 바로 도는 게 아니다. AVD가 떠 있거나 실제 기기가 연결되어 있어야 실행된다. 이걸 모르고 있으면 "왜 테스트가 시작조차 안 되지?"에서 한참을 헤매게 된다.
NoMatchingViewException이 뜬다. onView(withId(...))로 찾으려는 뷰가 화면에 없을 때 나는 에러다. 이 에러를 보면 대부분 "ID를 잘못 썼나?"부터 의심하는데, 실제로는 ID가 맞는데도 그 시점에 그 뷰가 아직 화면에 그려지지 않았거나, 다른 화면에 있거나, 스크롤을 해야 보이는 위치에 있는 경우가 더 흔하다. ID 철자보다 "지금 이 시점에 이 뷰가 실제로 화면에 떠 있는가"를 먼저 의심해보는 게 낫다.
AmbiguousViewMatcherException이 뜬다. 같은 ID나 같은 텍스트를 가진 뷰가 화면에 여러 개 있을 때 발생한다. 앞서 본 allOf로 조건을 좁혀야 하는데, 이걸 모르면 왜 에러가 나는지조차 파악하기 어렵다.
Espresso도 "실제 사용자처럼" 동작한다는 점을 놓친다. 화면 밖에 있어서 실제 사용자 눈에도 안 보이는 뷰는 Espresso도 건드릴 수 없다. 실제 사람이 안 보이는 버튼을 못 누르는 것과 똑같은 원리다. 그래서 리스트 아래쪽에 있는 항목을 클릭하려면 scrollTo()나 뒤에서 다룰 RecyclerViewActions 없이는 바로 클릭할 수 없다.
테스트가 됐다 안 됐다 한다(flaky test). 단독으로 실행하면 성공하는데 다른 테스트와 같이 돌리면 실패하는 현상이다. 이 경우 대부분 코드 로직 문제가 아니라, 화면 전환 애니메이션이 채 끝나기도 전에 Espresso가 다음 동작을 시도해서 생기는 타이밍 문제다. 이 원인은 다음 섹션에서 좀 더 다룬다.
지금까지 본 방식은 화면에 뷰가 하나씩 고정되어 있을 때는 잘 맞는다. 그런데 RecyclerView처럼 항목이 스크롤되면서 화면에 나타났다 사라졌다 하는 리스트는 기본 Espresso만으로는 다루기 어렵다. 화면 밖으로 나간 항목은 아예 그려지지 않은 상태라서 onView로 바로 찾을 수 없기 때문이다.
이럴 때 필요한 게 espresso-contrib 의존성이다.
androidTestImplementation 'androidx.test.espresso:espresso-contrib:$espressoVersion'
이 의존성을 추가하면 RecyclerViewActions라는 도구를 쓸 수 있게 된다. 특정 위치까지 스크롤하는 scrollToPosition()이나, 특정 위치의 항목에 클릭 같은 동작을 바로 수행하는 actionOnItemAtPosition() 같은 함수들이 여기 포함되어 있다. 이 함수들을 쓰면 "몇 번째 항목까지 스크롤한 다음 클릭한다"는 동작을 한 번에 처리할 수 있다.
여기서 하나 짚고 넘어갈 함정이 있다. 예전 방식의 리스트인 ListView에는 onData()라는 함수로 항목에 접근하는 방법이 있는데, 이 onData()는 AdapterView 계열에서만 동작하고 RecyclerView에서는 동작하지 않는다. RecyclerView를 테스트하는데 onData()를 찾아서 쓰려고 하면 계속 막히게 되니, RecyclerView는 처음부터 RecyclerViewActions를 쓴다고 기억해두는 게 낫다.
버전 번호는 계속 바뀌기 때문에 본문에 특정 숫자를 못 박기보다는, 프로젝트의 build.gradle에서 현재 쓰고 있는 Espresso 버전에 맞춰 넣는다고 이해하면 된다.
앞서 잠깐 언급한 flaky test 문제를 좀 더 자세히 보자. 안드로이드는 화면이 전환되거나 버튼을 누를 때 기본적으로 애니메이션 효과가 들어간다. 사람 눈에는 자연스러운 이 효과가, Espresso 입장에서는 문제가 될 수 있다. 애니메이션이 진행 중인 동안에는 뷰가 아직 최종 위치나 최종 상태에 도달하지 않은 상태이기 때문에, 그 타이밍에 perform이나 check를 시도하면 뷰를 못 찾거나 엉뚱한 상태로 판단해서 테스트가 실패할 수 있다.
이 문제를 줄이는 가장 흔한 방법은 애니메이션을 아예 꺼버리는 것이다.
android {
testOptions {
animationsDisabled = true
}
}
build.gradle에 이 설정을 추가하면 테스트를 실행할 때 애니메이션이 비활성화된다. 다만 이 옵션에는 알려진 한계가 있다. 커맨드라인에서 테스트를 실행할 때는 확실히 적용되지만, 안드로이드 스튜디오에서 직접 실행 버튼을 눌러서 돌릴 때는 이 설정이 제대로 먹히지 않을 수 있다는 점이 알려져 있다.
이 옵션만으로 해결이 안 될 때 쓸 수 있는 대안은, 테스트를 돌리는 기기나 에뮬레이터의 설정 앱에서 직접 애니메이션을 끄는 것이다. 개발자 옵션으로 들어가서 "창 애니메이션 배율", "전환 애니메이션 배율", "Animator 시간 배율" 세 항목을 모두 0배로 맞춰두면, testOptions 설정과 무관하게 기기 자체가 애니메이션 없이 동작하기 때문에 테스트가 훨씬 안정적으로 돈다. flaky test 때문에 계속 막혀서 원인을 찾기 어려울 때는 코드보다 이 설정부터 의심해보는 게 시간을 아끼는 방법이다.
테스트 코드는 손으로 반복하던 확인 작업을 자동화해서, 장애를 빨리 발견하고 리팩터링을 겁내지 않게 해주는 도구다. 그중에서도 UI 테스트는 실제 화면 동작까지 검증해야 하기 때문에 손이 많이 가는 영역인데, Espresso는 이 과정을 onView로 뷰를 찾고, perform으로 동작을 수행하고, check로 결과를 검증하는 단순한 3단 구조로 정리해준다. 이 구조가 View에 직접 접근하지 못하게 제한되어 있는 이유는, 테스트를 안정적으로 만들기 위한 설계라는 점을 기억해두면 문법을 그냥 외우는 것보다 훨씬 이해하기 쉬워진다.
실제로 코드를 짜다 보면 문법보다 androidTest 폴더 위치, 에뮬레이터 실행 여부, NoMatchingViewException 같은 주변 함정에서 더 많이 막힌다. 이런 함정들은 대부분 "Espresso도 실제 사용자처럼 동작한다"는 원칙 하나로 설명이 된다. 화면에 없는 뷰는 못 찾고, 안 보이는 뷰는 못 누르고, 애니메이션이 끝나기 전에는 화면이 아직 바뀌지 않은 상태라는 것만 기억해도 에러 메시지를 마주쳤을 때 원인을 훨씬 빠르게 좁혀나갈 수 있다.