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

콘텐츠 대표 이미지 - 안드로이드 UI 테스트: Espresso 프레임워크 활용하기 🧪

 

 

안드로이드 앱 테스트의 비밀 무기를 함께 알아보자!

Android App UI 화면 Espresso 테스트 코드 테스트 실행 결과 확인 Espresso로 앱 품질 보장하기

안녕? 오늘은 안드로이드 개발자라면 꼭 알아야 할 UI 테스트 프레임워크인 Espresso에 대해 함께 알아볼 거야. 앱 개발에서 테스트는 선택이 아닌 필수라는 거 알지? 특히 사용자 인터페이스(UI) 테스트는 앱의 품질을 보장하는 핵심이라고 할 수 있어. 🚀

혹시 테스트 코드 없이 앱을 개발하고 있다면, 지금이 바로 Espresso를 배울 최적의 타이밍이야! 이 글을 통해 너의 개발 스킬셋을 한층 업그레이드할 수 있을 거야. 재능넷에서도 안드로이드 개발 관련 재능을 거래할 때 테스트 코드 작성 능력은 큰 플러스 요소가 된다는 점, 참고해둬! 😉

📱 Espresso가 뭐길래?

Espresso는 구글이 안드로이드 앱의 UI 테스트를 위해 개발한 테스팅 프레임워크야. 이름부터 뭔가 느낌이 오지? 카페인처럼 테스트에 활력을 불어넣는다는 의미가 담겨있어. ☕

Espresso의 가장 큰 특징은 실제 사용자처럼 UI를 조작하고 결과를 검증할 수 있다는 거야. 버튼을 클릭하고, 텍스트를 입력하고, 화면을 스크롤하는 등의 사용자 동작을 코드로 표현할 수 있지.

2025년 현재, Espresso는 버전 3.6까지 발전했고 Jetpack Compose 테스트까지 지원하고 있어. 안드로이드 개발 생태계에서 표준 UI 테스트 도구로 자리잡았다고 볼 수 있지! 🏆

🛠 Espresso의 핵심 구성요소

Espresso는 크게 세 가지 핵심 구성요소로 이루어져 있어:

  1. ViewMatchers: 화면에서 특정 뷰를 찾기 위한 도구
  2. ViewActions: 찾은 뷰에 대해 클릭, 입력 등의 동작을 수행
  3. 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 테스트를 원활하게 실행하기 위해 애뮬레이터 설정을 최적화하는 것이 좋아:

  1. 애니메이션 끄기 (개발자 옵션에서 창 애니메이션, 전환 애니메이션, 애니메이션 지속 시간 비율 설정 끄기)
  2. 백그라운드 프로세스 최소화
  3. 충분한 메모리 할당

이렇게 설정하면 테스트가 더 안정적으로 실행될 거야. 특히 애니메이션은 테스트 실패의 주요 원인이 될 수 있으니 꼭 꺼두자! ⚙️

🎯 첫 번째 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("이메일을 입력해주세요")));
    }
}

위 코드를 보면 두 가지 테스트 케이스를 작성했어:

  1. 유효한 로그인 테스트: 올바른 이메일과 비밀번호를 입력하고 로그인 버튼을 클릭했을 때 메인 화면으로 이동하는지 확인
  2. 이메일 누락 테스트: 이메일을 입력하지 않고 로그인 버튼을 클릭했을 때 에러 메시지가 표시되는지 확인

이런 식으로 사용자의 다양한 행동 패턴을 테스트 케이스로 작성할 수 있어. 실제 사용자가 앱을 사용하는 시나리오를 생각하면서 테스트를 작성하는 게 좋아! 🧠

🔍 ViewMatcher 자세히 알아보기

Espresso에서 ViewMatcher는 테스트하려는 뷰를 찾기 위한 도구야. 마치 CSS 선택자나 XPath처럼 화면에서 특정 요소를 찾는 역할을 해. 다양한 방법으로 뷰를 찾을 수 있어서 정말 편리해! 🔎

자주 사용하는 ViewMatcher들

  1. withId(R.id.view_id): 리소스 ID로 뷰 찾기
  2. withText("텍스트"): 정확한 텍스트로 뷰 찾기
  3. withContentDescription("설명"): 콘텐츠 설명으로 뷰 찾기
  4. withHint("힌트"): 힌트 텍스트로 뷰 찾기
  5. isDisplayed(): 화면에 보이는 뷰 찾기
  6. isEnabled(): 활성화된 뷰 찾기
  7. isChecked(): 체크된 뷰 찾기 (체크박스, 라디오 버튼 등)
  8. isSelected(): 선택된 뷰 찾기
  9. hasFocus(): 포커스를 가진 뷰 찾기
  10. 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들

  1. click(): 뷰 클릭하기
  2. doubleClick(): 뷰 더블 클릭하기
  3. longClick(): 뷰 길게 클릭하기
  4. typeText("텍스트"): 텍스트 입력하기
  5. replaceText("텍스트"): 기존 텍스트 대체하기
  6. clearText(): 텍스트 지우기
  7. closeSoftKeyboard(): 키보드 닫기
  8. scrollTo(): 뷰가 보이도록 스크롤하기
  9. swipeLeft(), swipeRight(), swipeUp(), swipeDown(): 스와이프 동작
  10. 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들

  1. matches(Matcher): 뷰가 특정 조건과 일치하는지 확인
  2. doesNotExist(): 뷰가 존재하지 않는지 확인
  3. 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 테스트의 주요 메서드들

  1. onNodeWithText(): 텍스트로 노드 찾기
  2. onNodeWithTag(): 테스트 태그로 노드 찾기
  3. onNodeWithContentDescription(): 콘텐츠 설명으로 노드 찾기
  4. performClick(): 클릭 동작 수행
  5. performTextInput(): 텍스트 입력
  6. performScrollTo(): 스크롤하여 노드 보이게 하기
  7. assertIsDisplayed(): 노드가 표시되는지 확인
  8. assertIsEnabled(): 노드가 활성화되었는지 확인
  9. assertTextEquals(): 텍스트가 일치하는지 확인
  10. 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. 테스트 가능한 코드 작성하기

앱 코드 자체를 테스트하기 쉽게 작성하는 것도 중요해:

  1. 의존성 주입 패턴 사용하기
  2. 비즈니스 로직과 UI 로직 분리하기 (MVVM, MVI 등)
  3. 테스트 모드 지원하기 (예: 네트워크 요청 모킹)
  4. 접근성 지원하기 (테스트에도 도움됨)
  5. 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 예외가 발생하며 테스트가 실패함

해결책:

  1. 뷰가 화면에 보이는지 확인 (스크롤이 필요할 수 있음)
  2. ID나 텍스트가 정확한지 확인
  3. 뷰가 다른 프래그먼트나 액티비티에 있는지 확인
  4. 애니메이션이 끝날 때까지 기다리기
// 스크롤이 필요한 경우
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,000,000 담기 노트북 ₩2,000,000 담기 헤드폰 ₩300,000 담기 장바구니 스마트폰 ₩1,000,000 삭제 노트북 ₩2,000,000 삭제 총액: ₩3,000,000 결제하기 결제 정보 카드 번호 만료일 CVC 배송 주소 주문 완료 쇼핑 앱 테스트 흐름

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. 테스트 데이터 관리하기

테스트 데이터를 별도로 관리하면 테스트 코드가 더 깔끔해져: