안드로이드 UI 테스트: Espresso 프레임워크 활용하기 🧪

안드로이드 앱 테스트의 비밀 무기를 함께 알아보자!
안녕? 오늘은 안드로이드 개발자라면 꼭 알아야 할 UI 테스트 프레임워크인 Espresso에 대해 함께 알아볼 거야. 앱 개발에서 테스트는 선택이 아닌 필수라는 거 알지? 특히 사용자 인터페이스(UI) 테스트는 앱의 품질을 보장하는 핵심이라고 할 수 있어. 🚀
혹시 테스트 코드 없이 앱을 개발하고 있다면, 지금이 바로 Espresso를 배울 최적의 타이밍이야! 이 글을 통해 너의 개발 스킬셋을 한층 업그레이드할 수 있을 거야. 재능넷에서도 안드로이드 개발 관련 재능을 거래할 때 테스트 코드 작성 능력은 큰 플러스 요소가 된다는 점, 참고해둬! 😉
📱 Espresso가 뭐길래?
Espresso는 구글이 안드로이드 앱의 UI 테스트를 위해 개발한 테스팅 프레임워크야. 이름부터 뭔가 느낌이 오지? 카페인처럼 테스트에 활력을 불어넣는다는 의미가 담겨있어. ☕
Espresso의 가장 큰 특징은 실제 사용자처럼 UI를 조작하고 결과를 검증할 수 있다는 거야. 버튼을 클릭하고, 텍스트를 입력하고, 화면을 스크롤하는 등의 사용자 동작을 코드로 표현할 수 있지.
2025년 현재, Espresso는 버전 3.6까지 발전했고 Jetpack Compose 테스트까지 지원하고 있어. 안드로이드 개발 생태계에서 표준 UI 테스트 도구로 자리잡았다고 볼 수 있지! 🏆
🛠 Espresso의 핵심 구성요소
Espresso는 크게 세 가지 핵심 구성요소로 이루어져 있어:
- ViewMatchers: 화면에서 특정 뷰를 찾기 위한 도구
- ViewActions: 찾은 뷰에 대해 클릭, 입력 등의 동작을 수행
- ViewAssertions: 뷰의 상태를 검증하는 도구
이 세 가지 구성요소를 조합해서 테스트 코드를 작성하게 돼. 마치 레고 블록처럼 조립해서 복잡한 UI 테스트도 쉽게 만들 수 있어. 🧩
Espresso 코드의 기본 구조:
onView(ViewMatcher) // 뷰 찾기
.perform(ViewAction) // 동작 수행
.check(ViewAssertion) // 결과 검증
이런 구조로 코드를 작성하면 가독성이 좋고 직관적인 테스트 코드를 만들 수 있어. 마치 사람이 앱을 사용하는 과정을 그대로 코드로 옮겨놓은 것 같지? 😎
🔧 Espresso 설정하기
자, 이제 실제로 Espresso를 프로젝트에 적용하는 방법을 알아보자. 2025년 기준 최신 설정 방법을 소개할게! 🔄
1. 의존성 추가하기
먼저 app 수준의 build.gradle 파일에 다음 의존성을 추가해야 해:
dependencies {
// Espresso 핵심 라이브러리
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.0'
// 추가 기능을 위한 라이브러리들
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.6.0'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.6.0'
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.6.0'
androidTestImplementation 'androidx.test.espresso:espresso-web:3.6.0'
// 안드로이드 테스트 라이브러리
androidTestImplementation 'androidx.test:runner:1.6.0'
androidTestImplementation 'androidx.test:rules:1.6.0'
androidTestImplementation 'androidx.test.ext:junit:1.2.0'
}
androidTestImplementation은 테스트 코드가 실제 안드로이드 기기나 에뮬레이터에서 실행될 때 사용되는 의존성이야. 단위 테스트를 위한 testImplementation과는 다르다는 점 기억해둬! 📝
2. 테스트 러너 설정하기
AndroidManifest.xml 파일의 application 태그 안에 테스트 러너를 지정해줘야 해:
<application>
<!-- 다른 설정들 -->
<uses-library android:name="android.test.runner" />
<!-- 다른 설정들 -->
</application>
<instrumentation
android:name="androidx.test.runner.AndroidJUnitRunner"
android:targetPackage="com.example.yourapp" />
그리고 app 수준의 build.gradle 파일에도 테스트 러너를 지정해줘야 해:
android {
defaultConfig {
// 다른 설정들
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
}
3. 애뮬레이터 설정 최적화
Espresso 테스트를 원활하게 실행하기 위해 애뮬레이터 설정을 최적화하는 것이 좋아:
- 애니메이션 끄기 (개발자 옵션에서 창 애니메이션, 전환 애니메이션, 애니메이션 지속 시간 비율 설정 끄기)
- 백그라운드 프로세스 최소화
- 충분한 메모리 할당
이렇게 설정하면 테스트가 더 안정적으로 실행될 거야. 특히 애니메이션은 테스트 실패의 주요 원인이 될 수 있으니 꼭 꺼두자! ⚙️
🎯 첫 번째 Espresso 테스트 작성하기
이론은 충분히 알았으니, 이제 실제로 간단한 Espresso 테스트를 작성해보자! 로그인 화면을 테스트하는 예제를 만들어볼게. 🚪
먼저 테스트할 간단한 로그인 화면이 있다고 가정해보자:
이제 이 화면을 테스트하는 Espresso 코드를 작성해보자:
@RunWith(AndroidJUnit4.class)
public class LoginActivityTest {
// 테스트할 액티비티를 실행하기 위한 룰
@Rule
public ActivityScenarioRule activityRule =
new ActivityScenarioRule<>(LoginActivity.class);
@Test
public void loginWithValidCredentials_shouldNavigateToMainActivity() {
// 1. 이메일 입력 필드에 텍스트 입력
onView(withId(R.id.email_edit_text))
.perform(typeText("test@example.com"), closeSoftKeyboard());
// 2. 비밀번호 입력 필드에 텍스트 입력
onView(withId(R.id.password_edit_text))
.perform(typeText("password123"), closeSoftKeyboard());
// 3. 로그인 버튼 클릭
onView(withId(R.id.login_button))
.perform(click());
// 4. 메인 액티비티로 이동했는지 확인 (툴바의 제목으로 확인)
onView(withId(R.id.toolbar_title))
.check(matches(withText("메인 화면")));
}
@Test
public void loginWithEmptyEmail_shouldShowError() {
// 1. 이메일은 비워두고 비밀번호만 입력
onView(withId(R.id.password_edit_text))
.perform(typeText("password123"), closeSoftKeyboard());
// 2. 로그인 버튼 클릭
onView(withId(R.id.login_button))
.perform(click());
// 3. 이메일 필드에 에러 메시지가 표시되는지 확인
onView(withId(R.id.email_edit_text))
.check(matches(hasErrorText("이메일을 입력해주세요")));
}
}
위 코드를 보면 두 가지 테스트 케이스를 작성했어:
- 유효한 로그인 테스트: 올바른 이메일과 비밀번호를 입력하고 로그인 버튼을 클릭했을 때 메인 화면으로 이동하는지 확인
- 이메일 누락 테스트: 이메일을 입력하지 않고 로그인 버튼을 클릭했을 때 에러 메시지가 표시되는지 확인
이런 식으로 사용자의 다양한 행동 패턴을 테스트 케이스로 작성할 수 있어. 실제 사용자가 앱을 사용하는 시나리오를 생각하면서 테스트를 작성하는 게 좋아! 🧠
🔍 ViewMatcher 자세히 알아보기
Espresso에서 ViewMatcher는 테스트하려는 뷰를 찾기 위한 도구야. 마치 CSS 선택자나 XPath처럼 화면에서 특정 요소를 찾는 역할을 해. 다양한 방법으로 뷰를 찾을 수 있어서 정말 편리해! 🔎
자주 사용하는 ViewMatcher들
- withId(R.id.view_id): 리소스 ID로 뷰 찾기
- withText("텍스트"): 정확한 텍스트로 뷰 찾기
- withContentDescription("설명"): 콘텐츠 설명으로 뷰 찾기
- withHint("힌트"): 힌트 텍스트로 뷰 찾기
- isDisplayed(): 화면에 보이는 뷰 찾기
- isEnabled(): 활성화된 뷰 찾기
- isChecked(): 체크된 뷰 찾기 (체크박스, 라디오 버튼 등)
- isSelected(): 선택된 뷰 찾기
- hasFocus(): 포커스를 가진 뷰 찾기
- withParent(Matcher): 특정 부모를 가진 뷰 찾기
ViewMatcher 조합하기
여러 매처를 조합해서 더 정확하게 뷰를 찾을 수도 있어:
// ID와 텍스트를 모두 만족하는 뷰 찾기
onView(allOf(withId(R.id.text_view), withText("Hello World")))
.perform(click());
// ID가 R.id.container인 뷰 안에 있는 텍스트가 "제출"인 버튼 찾기
onView(allOf(withText("제출"), isDescendantOfA(withId(R.id.container))))
.perform(click());
// 텍스트가 "에러"가 아니면서 표시된 뷰 찾기
onView(allOf(not(withText("에러")), isDisplayed()))
.perform(click());
RecyclerView 아이템 찾기
리스트 형태의 UI를 테스트할 때는 특별한 매처가 필요해:
// RecyclerView의 3번째 아이템 찾기
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollToPosition(2)); // 0부터 시작하므로 2는 3번째 아이템
// RecyclerView에서 특정 텍스트를 가진 아이템 찾기
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollTo(
hasDescendant(withText("원하는 텍스트"))
));
// 찾은 아이템의 특정 뷰(예: 버튼) 클릭하기
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.actionOnItem(
hasDescendant(withText("원하는 텍스트")),
MyViewAction.clickChildViewWithId(R.id.item_button)
));
이런 식으로 복잡한 UI에서도 원하는 요소를 정확하게 찾아낼 수 있어. 마치 보물찾기 같지만, 훨씬 체계적이고 확실한 방법이지! 🗺️
👆 ViewAction으로 사용자 동작 시뮬레이션하기
ViewMatcher로 뷰를 찾았다면, 이제 ViewAction을 사용해 실제 사용자처럼 동작을 수행할 차례야. Espresso는 다양한 사용자 동작을 시뮬레이션할 수 있어서 정말 강력해! 🦾
기본적인 ViewAction들
- click(): 뷰 클릭하기
- doubleClick(): 뷰 더블 클릭하기
- longClick(): 뷰 길게 클릭하기
- typeText("텍스트"): 텍스트 입력하기
- replaceText("텍스트"): 기존 텍스트 대체하기
- clearText(): 텍스트 지우기
- closeSoftKeyboard(): 키보드 닫기
- scrollTo(): 뷰가 보이도록 스크롤하기
- swipeLeft(), swipeRight(), swipeUp(), swipeDown(): 스와이프 동작
- pressBack(): 뒤로가기 버튼 누르기
여러 동작 연결하기
한 번에 여러 동작을 연결해서 수행할 수도 있어:
// 텍스트 입력 후 키보드 닫기
onView(withId(R.id.email_edit_text))
.perform(typeText("test@example.com"), closeSoftKeyboard());
// 스크롤 후 클릭하기
onView(withId(R.id.long_form))
.perform(scrollTo(), click());
// 텍스트 지우고 새로 입력하기
onView(withId(R.id.username_edit_text))
.perform(clearText(), typeText("newUsername"), closeSoftKeyboard());
복잡한 제스처 만들기
더 복잡한 제스처도 만들 수 있어:
// 특정 좌표 클릭하기
onView(withId(R.id.custom_view))
.perform(clickXY(100, 200));
// 드래그 앤 드롭
onView(withId(R.id.draggable_item))
.perform(dragAndDrop(withId(R.id.drop_target)));
// 핀치 줌인/줌아웃
onView(withId(R.id.map_view))
.perform(pinchIn());
물론 위의 복잡한 제스처들은 기본 Espresso에서 바로 제공하지 않는 것들도 있어. 하지만 CustomViewAction을 만들어서 구현할 수 있지! 👨💻
커스텀 ViewAction 만들기
특정 좌표를 클릭하는 커스텀 액션을 만들어보자:
public static ViewAction clickXY(final int x, final int y) {
return new ViewAction() {
@Override
public Matcher getConstraints() {
return isDisplayed();
}
@Override
public String getDescription() {
return "Click on specific coordinates";
}
@Override
public void perform(UiController uiController, View view) {
// 뷰의 중심에서 상대적인 좌표 계산
int[] location = new int[2];
view.getLocationOnScreen(location);
// 클릭 동작 수행
float[] coordinates = new float[] { x + location[0], y + location[1] };
MotionEvent motionEvent = MotionEvent.obtain(
android.os.SystemClock.uptimeMillis(),
android.os.SystemClock.uptimeMillis(),
MotionEvent.ACTION_DOWN,
coordinates[0],
coordinates[1],
0
);
uiController.injectMotionEvent(motionEvent);
motionEvent.recycle();
// 잠시 대기
uiController.loopMainThreadForAtLeast(100);
// 손가락 떼기 동작
motionEvent = MotionEvent.obtain(
android.os.SystemClock.uptimeMillis(),
android.os.SystemClock.uptimeMillis(),
MotionEvent.ACTION_UP,
coordinates[0],
coordinates[1],
0
);
uiController.injectMotionEvent(motionEvent);
motionEvent.recycle();
// UI 스레드가 처리할 시간 주기
uiController.loopMainThreadForAtLeast(100);
}
};
}
이렇게 만든 커스텀 액션은 다음과 같이 사용할 수 있어:
onView(withId(R.id.my_custom_view))
.perform(clickXY(150, 200));
이런 식으로 Espresso로 할 수 없는 동작은 거의 없다고 봐도 좋아. 실제 사용자의 모든 제스처를 프로그래밍 방식으로 시뮬레이션할 수 있지! 🕹️
✅ ViewAssertion으로 결과 검증하기
이제 뷰를 찾고 동작을 수행했으니, 마지막으로 ViewAssertion을 사용해 결과를 검증할 차례야. 이 단계가 테스트의 핵심이라고 할 수 있어. 예상한 결과가 실제로 나타났는지 확인하는 거지! 📋
기본적인 ViewAssertion들
- matches(Matcher): 뷰가 특정 조건과 일치하는지 확인
- doesNotExist(): 뷰가 존재하지 않는지 확인
- selectedDescendantsMatch(Matcher, Matcher): 자식 뷰들이 조건과 일치하는지 확인
자주 사용하는 검증 패턴
// 텍스트 확인하기
onView(withId(R.id.result_text))
.check(matches(withText("성공!")));
// 뷰가 보이는지 확인하기
onView(withId(R.id.progress_bar))
.check(matches(isDisplayed()));
// 뷰가 보이지 않는지 확인하기
onView(withId(R.id.error_message))
.check(matches(not(isDisplayed())));
// 뷰가 활성화되었는지 확인하기
onView(withId(R.id.submit_button))
.check(matches(isEnabled()));
// 체크박스가 체크되었는지 확인하기
onView(withId(R.id.terms_checkbox))
.check(matches(isChecked()));
// 에러 메시지 확인하기
onView(withId(R.id.password_edit_text))
.check(matches(hasErrorText("비밀번호는 8자 이상이어야 합니다")));
// 뷰의 배경색 확인하기
onView(withId(R.id.status_view))
.check(matches(hasBackgroundColor(Color.GREEN)));
커스텀 ViewAssertion 만들기
기본 제공되는 검증 방법으로 부족하다면, 커스텀 ViewAssertion을 만들 수도 있어:
public static ViewAssertion hasBackgroundColor(final int expectedColor) {
return new ViewAssertion() {
@Override
public void check(View view, NoMatchingViewException noViewFoundException) {
if (noViewFoundException != null) {
throw noViewFoundException;
}
// 뷰의 배경을 가져오기
ColorDrawable background = (ColorDrawable) view.getBackground();
int actualColor = background.getColor();
// 색상 비교
if (actualColor != expectedColor) {
throw new AssertionError("Expected background color: " +
Integer.toHexString(expectedColor) +
" but was: " + Integer.toHexString(actualColor));
}
}
};
}
이런 식으로 원하는 어떤 속성이든 검증할 수 있는 커스텀 검증 로직을 만들 수 있어. 앱의 특성에 맞는 검증 로직을 개발하면 테스트가 더 강력해질 거야! 💪
🧩 Espresso의 고급 기능들
기본적인 Espresso 사용법을 알았으니, 이제 더 고급 기능들을 살펴볼까? 복잡한 앱을 테스트할 때 정말 유용한 기능들이 많아! 🚀
1. Espresso-Intents로 인텐트 테스트하기
다른 앱이나 시스템 컴포넌트와의 상호작용을 테스트해야 할 때 Espresso-Intents를 사용할 수 있어:
@Rule
public IntentsTestRule intentsTestRule =
new IntentsTestRule<>(MainActivity.class);
@Test
public void clickShareButton_shouldCreateShareIntent() {
// 인텐트 스텁 설정
intending(hasAction(Intent.ACTION_SEND))
.respondWith(new ActivityResult(Activity.RESULT_OK, null));
// 공유 버튼 클릭
onView(withId(R.id.share_button))
.perform(click());
// 인텐트가 올바르게 발생했는지 확인
intended(allOf(
hasAction(Intent.ACTION_SEND),
hasType("text/plain"),
hasExtra(Intent.EXTRA_TEXT, "공유할 텍스트")
));
}
2. Espresso-Web으로 WebView 테스트하기
앱에 WebView가 포함되어 있다면 Espresso-Web을 사용해 웹 콘텐츠도 테스트할 수 있어:
@Test
public void webViewTest() {
// WebView 인스턴스 가져오기
onWebView(withId(R.id.web_view))
// 자바스크립트 활성화 대기
.forceJavascriptEnabled()
// 특정 요소 찾기
.withElement(findElement(Locator.ID, "login-button"))
// 요소 클릭하기
.perform(webClick())
// 결과 확인하기
.withElement(findElement(Locator.ID, "welcome-message"))
.check(webMatches(getText(), containsString("환영합니다")));
}
3. IdlingResource로 비동기 작업 처리하기
비동기 작업(네트워크 요청, 데이터베이스 쿼리 등)이 완료될 때까지 기다려야 할 때 IdlingResource를 사용할 수 있어:
// 커스텀 IdlingResource 구현
public class NetworkIdlingResource implements IdlingResource {
private ResourceCallback resourceCallback;
private boolean isIdle = true;
@Override
public String getName() {
return NetworkIdlingResource.class.getName();
}
@Override
public boolean isIdleNow() {
return isIdle;
}
@Override
public void registerIdleTransitionCallback(ResourceCallback callback) {
this.resourceCallback = callback;
}
public void setIdle(boolean idle) {
isIdle = idle;
if (idle && resourceCallback != null) {
resourceCallback.onTransitionToIdle();
}
}
}
// 테스트 코드에서 사용하기
@Test
public void testNetworkRequest() {
// IdlingResource 인스턴스 생성
NetworkIdlingResource idlingResource = new NetworkIdlingResource();
// Espresso에 등록
IdlingRegistry.getInstance().register(idlingResource);
try {
// 네트워크 요청 시작 전에 busy 상태로 설정
idlingResource.setIdle(false);
// 네트워크 요청 시작
onView(withId(R.id.fetch_data_button))
.perform(click());
// 네트워크 요청이 완료되면 idle 상태로 설정 (실제 코드에서는 콜백에서 처리)
// 이 부분은 앱 코드에서 처리해야 함
// networkCallback = () -> idlingResource.setIdle(true);
// 결과 확인 (Espresso는 idle 상태가 될 때까지 자동으로 대기)
onView(withId(R.id.result_text))
.check(matches(withText("데이터 로드 완료!")));
} finally {
// 테스트 종료 후 등록 해제
IdlingRegistry.getInstance().unregister(idlingResource);
}
}
4. Espresso-Accessibility로 접근성 테스트하기
앱의 접근성을 테스트하려면 Espresso-Accessibility를 사용할 수 있어:
@Test
public void checkAccessibility() {
// 특정 뷰의 접근성 검사
onView(withId(R.id.login_button))
.check(accessibilityAssertion());
// 전체 화면의 접근성 검사
onView(isRoot())
.check(accessibilityAssertion());
}
이런 고급 기능들을 활용하면 더 복잡한 앱도 철저하게 테스트할 수 있어. 특히 비동기 작업이 많은 현대 앱에서는 IdlingResource가 정말 중요하지! ⏱️
📱 Jetpack Compose 테스트하기
2025년 현재, 많은 안드로이드 앱들이 Jetpack Compose를 사용하고 있지? Espresso도 Compose와 함께 사용할 수 있도록 발전했어! 🎨
Compose 테스트 설정하기
먼저 Compose 테스트를 위한 의존성을 추가해야 해:
dependencies {
// Compose 테스트 라이브러리
androidTestImplementation "androidx.compose.ui:ui-test:1.6.0"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.6.0"
// 디버그 빌드용 도구
debugImplementation "androidx.compose.ui:ui-test-manifest:1.6.0"
}
기본적인 Compose 테스트 작성하기
@RunWith(AndroidJUnit4::class)
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Before
fun setUp() {
// 테스트할 컴포저블 설정
composeTestRule.setContent {
MyAppTheme {
LoginScreen(
onLoginSuccess = {},
onNavigateToSignup = {}
)
}
}
}
@Test
fun loginButton_initiallyDisabled() {
// 로그인 버튼이 초기에는 비활성화되어 있는지 확인
composeTestRule.onNodeWithText("로그인")
.assertIsDisplayed()
.assertIsNotEnabled()
}
@Test
fun enterValidCredentials_enablesLoginButton() {
// 이메일 입력
composeTestRule.onNodeWithTag("email_field")
.performTextInput("test@example.com")
// 비밀번호 입력
composeTestRule.onNodeWithTag("password_field")
.performTextInput("password123")
// 로그인 버튼이 활성화되었는지 확인
composeTestRule.onNodeWithText("로그인")
.assertIsEnabled()
}
@Test
fun clickForgotPassword_showsDialog() {
// 비밀번호 찾기 링크 클릭
composeTestRule.onNodeWithText("비밀번호를 잊으셨나요?")
.performClick()
// 다이얼로그가 표시되는지 확인
composeTestRule.onNodeWithText("비밀번호 재설정")
.assertIsDisplayed()
}
}
Compose 테스트의 주요 메서드들
- onNodeWithText(): 텍스트로 노드 찾기
- onNodeWithTag(): 테스트 태그로 노드 찾기
- onNodeWithContentDescription(): 콘텐츠 설명으로 노드 찾기
- performClick(): 클릭 동작 수행
- performTextInput(): 텍스트 입력
- performScrollTo(): 스크롤하여 노드 보이게 하기
- assertIsDisplayed(): 노드가 표시되는지 확인
- assertIsEnabled(): 노드가 활성화되었는지 확인
- assertTextEquals(): 텍스트가 일치하는지 확인
- assertTextContains(): 텍스트가 포함되는지 확인
Compose 테스트는 기존 Espresso 테스트와 비슷하지만, Compose의 선언적 UI 특성에 맞게 최적화되어 있어. 특히 상태 변화에 따른 UI 업데이트를 테스트하기가 더 쉬워졌어! 🎯
Compose와 기존 View 시스템 함께 테스트하기
하이브리드 앱(Compose + 기존 View 시스템)을 테스트할 때는 두 테스트 API를 함께 사용할 수 있어:
@RunWith(AndroidJUnit4::class)
class HybridAppTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun testHybridUI() {
// 기존 View 시스템 테스트
Espresso.onView(withId(R.id.legacy_button))
.perform(click())
// Compose UI 테스트
ComposeTestRule.onNodeWithText("Compose 버튼")
.performClick()
// 결과 확인 (기존 View)
Espresso.onView(withId(R.id.result_text))
.check(matches(withText("테스트 성공!")))
}
}
이렇게 Compose로 전환 중인 앱도 문제없이 테스트할 수 있어. 재능넷에서도 Compose를 활용한 개발 재능이 인기를 끌고 있다고 하니, 이런 테스트 기술을 익혀두면 좋을 거야! 🌟
🧪 테스트 모범 사례와 패턴
이제 Espresso의 기본 사용법과 고급 기능들을 알아봤으니, 효과적인 테스트를 작성하기 위한 모범 사례들을 알아보자! 🏆
1. 페이지 객체 패턴 (Page Object Pattern) 적용하기
테스트 코드를 더 구조화하고 재사용성을 높이기 위해 페이지 객체 패턴을 사용하는 것이 좋아:
// 로그인 화면을 위한 페이지 객체
public class LoginPage {
// 화면 요소들에 대한 액션 메서드
public LoginPage typeEmail(String email) {
onView(withId(R.id.email_edit_text))
.perform(typeText(email), closeSoftKeyboard());
return this;
}
public LoginPage typePassword(String password) {
onView(withId(R.id.password_edit_text))
.perform(typeText(password), closeSoftKeyboard());
return this;
}
public MainPage clickLoginButton() {
onView(withId(R.id.login_button))
.perform(click());
return new MainPage();
}
// 검증 메서드
public LoginPage checkEmailError(String errorMessage) {
onView(withId(R.id.email_edit_text))
.check(matches(hasErrorText(errorMessage)));
return this;
}
}
// 테스트 코드에서 사용
@Test
public void validLogin_navigatesToMainPage() {
LoginPage loginPage = new LoginPage();
MainPage mainPage = loginPage
.typeEmail("test@example.com")
.typePassword("password123")
.clickLoginButton();
mainPage.checkTitle("메인 화면");
}
이런 식으로 페이지 객체 패턴을 적용하면 테스트 코드가 더 읽기 쉽고 유지보수하기 좋아져. 특히 큰 프로젝트에서 정말 효과적이야! 📚
2. 로봇 패턴 (Robot Pattern) 활용하기
코틀린에서는 로봇 패턴이라는 페이지 객체 패턴의 변형을 많이 사용해:
// 로그인 화면을 위한 로봇
class LoginRobot {
fun typeEmail(email: String) = apply {
onView(withId(R.id.email_edit_text))
.perform(typeText(email), closeSoftKeyboard())
}
fun typePassword(password: String) = apply {
onView(withId(R.id.password_edit_text))
.perform(typeText(password), closeSoftKeyboard())
}
fun clickLogin() = apply {
onView(withId(R.id.login_button))
.perform(click())
}
fun verifyEmailError(errorMessage: String) = apply {
onView(withId(R.id.email_edit_text))
.check(matches(hasErrorText(errorMessage)))
}
// DSL 스타일 사용을 위한 infix 함수
infix fun and(function: LoginRobot.() -> Unit): LoginRobot {
function()
return this
}
}
// 테스트에서 사용
@Test
fun validLogin_navigatesToMainPage() {
LoginRobot()
.typeEmail("test@example.com")
.and { typePassword("password123") }
.and { clickLogin() }
// 메인 화면 검증
MainRobot().verifyTitle("메인 화면")
}
3. 테스트 데이터 관리하기
테스트 데이터를 효과적으로 관리하는 것도 중요해:
// 테스트 데이터 클래스
public class TestData {
public static final User VALID_USER = new User(
"test@example.com",
"password123",
"Test User"
);
public static final User INVALID_USER = new User(
"invalid-email",
"pwd",
"Invalid User"
);
}
// 테스트에서 사용
@Test
public void validLogin_navigatesToMainPage() {
User user = TestData.VALID_USER;
new LoginPage()
.typeEmail(user.getEmail())
.typePassword(user.getPassword())
.clickLoginButton()
.checkTitle("메인 화면");
}
4. 테스트 태그 활용하기
테스트 목적으로 뷰에 태그를 추가하면 테스트가 더 안정적이고 유지보수하기 쉬워져:
<Button
android:id="@+id/login_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="로그인"
android:contentDescription="로그인 버튼"
android:testTag="login_button" />
// Kotlin/Java 코드에서 태그 설정
loginButton.tag = "login_button"
// 테스트 코드에서 태그로 뷰 찾기
onView(withTagValue(is("login_button")))
.perform(click())
Compose에서는 더 쉽게 테스트 태그를 지정할 수 있어:
Button(
onClick = { /* 클릭 처리 */ },
modifier = Modifier.testTag("login_button")
) {
Text("로그인")
}
// 테스트에서 사용
composeTestRule.onNodeWithTag("login_button")
.performClick()
5. 테스트 가능한 코드 작성하기
앱 코드 자체를 테스트하기 쉽게 작성하는 것도 중요해:
- 의존성 주입 패턴 사용하기
- 비즈니스 로직과 UI 로직 분리하기 (MVVM, MVI 등)
- 테스트 모드 지원하기 (예: 네트워크 요청 모킹)
- 접근성 지원하기 (테스트에도 도움됨)
- ID나 태그를 일관되게 사용하기
이런 모범 사례들을 적용하면 테스트 코드의 품질이 크게 향상될 거야. 테스트 코드도 결국 코드니까, 깔끔하고 유지보수하기 좋게 작성하는 게 중요해! 🧹
🐞 일반적인 문제와 해결 방법
Espresso 테스트를 작성하다 보면 몇 가지 일반적인 문제에 부딪힐 수 있어. 미리 알아두면 시간을 많이 절약할 수 있을 거야! 🕒
1. 비동기 작업으로 인한 테스트 실패
문제: 네트워크 요청이나 데이터베이스 작업 같은 비동기 작업이 완료되기 전에 테스트가 다음 단계로 넘어가서 실패함
해결책: IdlingResource 사용하기
// OkHttp를 위한 IdlingResource 예시
public class OkHttpIdlingResource implements IdlingResource {
private final String name;
private final OkHttpClient client;
private ResourceCallback callback;
public OkHttpIdlingResource(String name, OkHttpClient client) {
this.name = name;
this.client = client;
}
@Override
public String getName() {
return name;
}
@Override
public boolean isIdleNow() {
boolean idle = client.dispatcher().runningCallsCount() == 0;
if (idle && callback != null) {
callback.onTransitionToIdle();
}
return idle;
}
@Override
public void registerIdleTransitionCallback(ResourceCallback callback) {
this.callback = callback;
}
}
2. 뷰를 찾을 수 없는 문제
문제: NoMatchingViewException
예외가 발생하며 테스트가 실패함
해결책:
- 뷰가 화면에 보이는지 확인 (스크롤이 필요할 수 있음)
- ID나 텍스트가 정확한지 확인
- 뷰가 다른 프래그먼트나 액티비티에 있는지 확인
- 애니메이션이 끝날 때까지 기다리기
// 스크롤이 필요한 경우
onView(withId(R.id.hidden_button))
.perform(scrollTo(), click());
// 특정 시간 대기가 필요한 경우 (최후의 수단으로만 사용)
SystemClock.sleep(1000); // 1초 대기
3. 앱바/툴바 관련 문제
문제: 앱바나 툴바의 메뉴 아이템을 찾을 수 없음
해결책: 특별한 매처 사용하기
// 오버플로우 메뉴 열기
openActionBarOverflowOrOptionsMenu(getInstrumentation().getTargetContext());
// 메뉴 아이템 클릭하기
onView(withText("설정"))
.perform(click());
// 홈 버튼(뒤로가기) 클릭하기
onView(withContentDescription("Navigate up"))
.perform(click());
4. 소프트 키보드 관련 문제
문제: 소프트 키보드가 뷰를 가려서 클릭할 수 없음
해결책: 키보드 닫기
// 텍스트 입력 후 키보드 닫기
onView(withId(R.id.email_edit_text))
.perform(typeText("test@example.com"), closeSoftKeyboard());
// 또는 전역적으로 키보드 닫기
Espresso.closeSoftKeyboard();
5. RecyclerView 관련 문제
문제: RecyclerView의 특정 아이템을 찾을 수 없음
해결책: RecyclerViewActions 사용하기
// 특정 위치의 아이템으로 스크롤
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollToPosition(10));
// 특정 텍스트를 가진 아이템 찾기
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollTo(
hasDescendant(withText("원하는 텍스트"))
));
// 특정 아이템의 버튼 클릭하기
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.actionOnItem(
hasDescendant(withText("원하는 텍스트")),
clickChildViewWithId(R.id.item_button)
));
6. 다이얼로그 관련 문제
문제: 다이얼로그의 버튼이나 내용을 찾을 수 없음
해결책: 특별한 매처 사용하기
// AlertDialog의 버튼 클릭하기
onView(withText("확인"))
.inRoot(isDialog())
.perform(click());
// 커스텀 다이얼로그의 요소 찾기
onView(withId(R.id.dialog_title))
.inRoot(isDialog())
.check(matches(withText("알림")));
이런 일반적인 문제들을 알아두면 테스트 작성 시간을 크게 단축할 수 있어. 특히 비동기 작업 처리는 Espresso 테스트에서 가장 중요한 부분 중 하나니까 꼭 기억해둬! 🧠
📊 테스트 보고서와 CI/CD 통합
테스트를 작성했다면 이제 테스트 결과를 보고서로 만들고, CI/CD 파이프라인에 통합하는 방법을 알아보자! 자동화된 테스트는 지속적 통합/배포 시스템의 핵심이니까. 🔄
1. 테스트 보고서 생성하기
안드로이드 테스트 결과를 보기 좋은 보고서로 만들려면 다음과 같은 도구들을 사용할 수 있어:
// app/build.gradle 파일에 추가
android {
// 다른 설정들...
testOptions {
unitTests.all {
// JUnit 보고서 생성
testLogging {
events "passed", "skipped", "failed"
exceptionFormat "full"
}
// HTML 보고서 생성
reports {
html.enabled = true
junitXml.enabled = true
}
}
// 안드로이드 테스트 보고서
reportDir = "$project.buildDir/reports/androidTests"
resultsDir = "$project.buildDir/reports/androidTests/results"
}
}
또한 써드파티 플러그인을 사용해 더 멋진 보고서를 만들 수도 있어:
// 프로젝트 수준 build.gradle
buildscript {
dependencies {
// 다른 의존성들...
classpath "io.qameta.allure:allure-gradle:2.11.2"
}
}
// app/build.gradle
plugins {
id 'io.qameta.allure'
}
allure {
version = '2.21.0'
autoconfigure = true
aspectjweaver = true
allureJavaVersion = '2.21.0'
useJUnit5 {
version = '2.21.0'
}
}
2. 스크린샷과 동영상 캡처하기
테스트 실패 시 스크린샷이나 동영상을 캡처하면 디버깅에 큰 도움이 돼:
// 테스트 룰 설정
@Rule
public ScreenshotTestRule screenshotTestRule = new ScreenshotTestRule();
// 스크린샷 테스트 룰 구현
public class ScreenshotTestRule implements TestRule {
@Override
public Statement apply(final Statement base, final Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
try {
base.evaluate();
} catch (Throwable throwable) {
// 테스트 실패 시 스크린샷 캡처
captureScreenshot(description.getMethodName());
throw throwable;
}
}
};
}
private void captureScreenshot(String name) {
// 스크린샷 캡처 로직
try {
File path = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + "/screenshots/");
if (!path.exists()) {
path.mkdirs();
}
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
device.takeScreenshot(new File(path, name + ".png"));
} catch (Exception e) {
Log.e("ScreenshotRule", "Failed to capture screenshot", e);
}
}
}
3. CI/CD 파이프라인에 통합하기
GitHub Actions, Jenkins, CircleCI 등의 CI/CD 시스템에 Espresso 테스트를 통합할 수 있어. 여기서는 GitHub Actions 예시를 살펴보자:
# .github/workflows/android-ci.yml
name: Android CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Run instrumented tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
arch: x86_64
profile: pixel_5
script: ./gradlew connectedDebugAndroidTest
- name: Upload test reports
uses: actions/upload-artifact@v3
if: always()
with:
name: test-reports
path: app/build/reports/
4. Firebase Test Lab 활용하기
다양한 기기에서 테스트를 실행하려면 Firebase Test Lab을 활용하는 것이 좋아:
# GitHub Actions에서 Firebase Test Lab 사용 예시
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v1
with:
service_account_key: ${{ secrets.GCP_SA_KEY }}
project_id: ${{ secrets.GCP_PROJECT_ID }}
- name: Build debug APK and test APK
run: ./gradlew assembleDebug assembleDebugAndroidTest
- name: Run tests on Firebase Test Lab
run: |
gcloud firebase test android run \
--type instrumentation \
--app app/build/outputs/apk/debug/app-debug.apk \
--test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=Pixel4,version=30,locale=en,orientation=portrait \
--device model=Samsung_Galaxy_S10,version=29,locale=en,orientation=portrait \
--results-bucket gs://${{ secrets.FIREBASE_TEST_LAB_BUCKET }} \
--results-dir=espresso-tests/$GITHUB_SHA
- name: Download test results
run: |
mkdir -p firebase-test-results
gsutil -m cp -r gs://${{ secrets.FIREBASE_TEST_LAB_BUCKET }}/espresso-tests/$GITHUB_SHA/* firebase-test-results/
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: firebase-test-results
path: firebase-test-results/
이렇게 CI/CD 시스템에 테스트를 통합하면 코드 변경사항이 생길 때마다 자동으로 테스트가 실행되어 품질을 유지할 수 있어. 특히 여러 개발자가 함께 일하는 프로젝트에서는 필수적인 부분이지! 🤝
🚀 실전 프로젝트: 쇼핑 앱 테스트하기
이제 배운 내용을 종합해서 실제 쇼핑 앱을 테스트하는 예제를 만들어보자! 이 예제를 통해 Espresso의 다양한 기능을 실전에서 어떻게 활용하는지 배울 수 있을 거야. 🛒
1. 페이지 객체 정의하기
먼저 각 화면에 대한 페이지 객체를 정의해보자:
// 상품 목록 페이지
public class ProductListPage {
public ProductListPage() {
// 페이지가 로드되었는지 확인
onView(withId(R.id.product_list_title))
.check(matches(withText("상품 목록")));
}
public ProductListPage addProductToCart(String productName) {
// 특정 상품 찾아서 장바구니에 담기
onView(withId(R.id.product_recycler_view))
.perform(RecyclerViewActions.scrollTo(
hasDescendant(withText(productName))
));
onView(allOf(
withText("담기"),
isDescendantOfA(hasSibling(withText(productName)))
)).perform(click());
return this;
}
public CartPage navigateToCart() {
// 장바구니 아이콘 클릭
onView(withId(R.id.cart_icon))
.perform(click());
return new CartPage();
}
}
// 장바구니 페이지
public class CartPage {
public CartPage() {
// 페이지가 로드되었는지 확인
onView(withId(R.id.cart_title))
.check(matches(withText("장바구니")));
}
public CartPage verifyProductInCart(String productName) {
// 상품이 장바구니에 있는지 확인
onView(allOf(
withText(productName),
isDescendantOfA(withId(R.id.cart_recycler_view))
)).check(matches(isDisplayed()));
return this;
}
public CartPage removeProductFromCart(String productName) {
// 상품 삭제 버튼 클릭
onView(allOf(
withText("삭제"),
isDescendantOfA(hasSibling(withText(productName)))
)).perform(click());
return this;
}
public CartPage verifyTotalAmount(String expectedAmount) {
// 총액 확인
onView(withId(R.id.total_amount))
.check(matches(withText(expectedAmount)));
return this;
}
public CheckoutPage proceedToCheckout() {
// 결제하기 버튼 클릭
onView(withId(R.id.checkout_button))
.perform(click());
return new CheckoutPage();
}
}
// 결제 페이지
public class CheckoutPage {
public CheckoutPage() {
// 페이지가 로드되었는지 확인
onView(withId(R.id.checkout_title))
.check(matches(withText("결제 정보")));
}
public CheckoutPage enterCardNumber(String cardNumber) {
// 카드 번호 입력
onView(withId(R.id.card_number_edit_text))
.perform(typeText(cardNumber), closeSoftKeyboard());
return this;
}
public CheckoutPage enterExpiryDate(String expiryDate) {
// 만료일 입력
onView(withId(R.id.expiry_date_edit_text))
.perform(typeText(expiryDate), closeSoftKeyboard());
return this;
}
public CheckoutPage enterCVC(String cvc) {
// CVC 입력
onView(withId(R.id.cvc_edit_text))
.perform(typeText(cvc), closeSoftKeyboard());
return this;
}
public CheckoutPage enterShippingAddress(String address) {
// 배송 주소 입력
onView(withId(R.id.shipping_address_edit_text))
.perform(typeText(address), closeSoftKeyboard());
return this;
}
public OrderConfirmationPage completeOrder() {
// 주문 완료 버튼 클릭
onView(withId(R.id.complete_order_button))
.perform(click());
return new OrderConfirmationPage();
}
}
2. 테스트 케이스 작성하기
이제 페이지 객체를 사용해 다양한 테스트 케이스를 작성해보자:
@RunWith(AndroidJUnit4.class)
public class ShoppingFlowTest {
@Rule
public ActivityScenarioRule activityRule =
new ActivityScenarioRule<>(MainActivity.class);
@Test
public void completeShoppingFlow_shouldSucceed() {
// 1. 상품 목록에서 상품 선택
ProductListPage productListPage = new ProductListPage();
productListPage
.addProductToCart("스마트폰")
.addProductToCart("노트북");
// 2. 장바구니로 이동
CartPage cartPage = productListPage.navigateToCart();
// 3. 장바구니 확인
cartPage
.verifyProductInCart("스마트폰")
.verifyProductInCart("노트북")
.verifyTotalAmount("₩3,000,000");
// 4. 결제 페이지로 이동
CheckoutPage checkoutPage = cartPage.proceedToCheckout();
// 5. 결제 정보 입력
OrderConfirmationPage confirmationPage = checkoutPage
.enterCardNumber("1234 5678 9012 3456")
.enterExpiryDate("12/25")
.enterCVC("123")
.enterShippingAddress("서울시 강남구 테헤란로 123")
.completeOrder();
// 6. 주문 확인
confirmationPage
.verifyOrderNumber()
.verifyOrderAmount("₩3,000,000");
}
@Test
public void addAndRemoveProductFromCart_shouldUpdateTotal() {
// 1. 상품 목록에서 상품 선택
ProductListPage productListPage = new ProductListPage();
productListPage
.addProductToCart("스마트폰")
.addProductToCart("노트북")
.addProductToCart("헤드폰");
// 2. 장바구니로 이동
CartPage cartPage = productListPage.navigateToCart();
// 3. 장바구니 확인
cartPage
.verifyProductInCart("스마트폰")
.verifyProductInCart("노트북")
.verifyProductInCart("헤드폰")
.verifyTotalAmount("₩3,300,000");
// 4. 상품 제거
cartPage
.removeProductFromCart("헤드폰")
.verifyTotalAmount("₩3,000,000");
}
@Test
public void emptyCartCheckout_shouldShowError() {
// 1. 상품 목록에서 장바구니로 바로 이동 (상품 추가 없이)
ProductListPage productListPage = new ProductListPage();
CartPage cartPage = productListPage.navigateToCart();
// 2. 결제하기 버튼 클릭
cartPage.proceedToCheckout();
// 3. 에러 메시지 확인
onView(withText("장바구니가 비어 있습니다"))
.check(matches(isDisplayed()));
}
@Test
public void invalidPaymentInfo_shouldShowError() {
// 1. 상품 추가 및 장바구니로 이동
ProductListPage productListPage = new ProductListPage();
CartPage cartPage = productListPage
.addProductToCart("스마트폰")
.navigateToCart();
// 2. 결제 페이지로 이동
CheckoutPage checkoutPage = cartPage.proceedToCheckout();
// 3. 불완전한 결제 정보 입력
checkoutPage
.enterCardNumber("1234") // 불완전한 카드 번호
.completeOrder();
// 4. 에러 메시지 확인
onView(withId(R.id.card_number_edit_text))
.check(matches(hasErrorText("유효한 카드 번호를 입력해주세요")));
}
}
3. 테스트 데이터 관리하기
테스트 데이터를 별도로 관리하면 테스트 코드가 더 깔끔해져:
- 지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
재능넷은 최신 AI 기술과 법률에 기반하여 자사의 지적 재산권을 적극적으로 보호하며,
무단 사용 및 침해 행위에 대해 법적 대응을 할 권리를 보유합니다.
© 2025 재능넷 | All rights reserved.
댓글 0개