모바일 UI 테스팅: XCUITest와 Espresso 실전 전략

모바일 개발

XCUITestEspressoUI 테스팅iOS 테스트Android 테스트

이 글은 누구를 위한 것인가

  • UI 자동화 테스트가 50% 이상 실패하는 플레이키(flaky) 문제를 겪는 팀
  • XCUITest/Espresso 기초를 넘어 안정적인 테스트를 작성하고 싶은 개발자
  • CI/CD에 UI 테스트를 통합하고 싶은 엔지니어

들어가며

UI 테스트는 "느리고 불안정하다"는 인식이 있다. 하지만 Page Object 패턴과 올바른 대기 전략으로 안정적인 테스트 스위트를 구축할 수 있다.

이 글은 bluefoxdev.kr의 모바일 테스팅 전략 을 참고하여 작성했습니다.


1. 테스트 전략 설계

[모바일 테스트 피라미드]

Unit 테스트 (70%):
  빠름 (<1초), 대부분 실행
  비즈니스 로직, ViewModel, Utility

통합 테스트 (20%):
  중간 속도 (1-10초)
  Repository, Network, DB

UI 테스트 (10%):
  느림 (10초-3분), 선택적 실행
  크리티컬 유저 플로우만

[UI 테스트 대상 선택 기준]
  O: 주문 완료 플로우
  O: 로그인/회원가입
  O: 결제 프로세스
  X: 각종 탭 이동
  X: 단순 텍스트 표시

[플레이키 테스트 원인과 해결]
  타이밍 이슈: waitForExistence 사용, sleep 금지
  네트워크 의존: API 목킹 (Mockingbird, WireMock)
  디바이스 상태: 테스트 전 앱 초기화
  애니메이션: 테스트 모드에서 비활성화
  순서 의존: 각 테스트 독립적으로 작성

[접근성 식별자 전략]
  accessibilityIdentifier: "login_email_field"
  네이밍: "화면명_컴포넌트명_역할"
  UI 레이블 변경에 영향 받지 않음

2. XCUITest 구현 (iOS)

// Page Object 패턴
import XCTest

class LoginPage {
    let app: XCUIApplication
    
    init(app: XCUIApplication) {
        self.app = app
    }
    
    var emailField: XCUIElement { app.textFields["login_email_field"] }
    var passwordField: XCUIElement { app.secureTextFields["login_password_field"] }
    var loginButton: XCUIElement { app.buttons["login_submit_button"] }
    var errorMessage: XCUIElement { app.staticTexts["login_error_message"] }
    
    @discardableResult
    func enterEmail(_ email: String) -> Self {
        emailField.tap()
        emailField.typeText(email)
        return self
    }
    
    @discardableResult
    func enterPassword(_ password: String) -> Self {
        passwordField.tap()
        passwordField.typeText(password)
        return self
    }
    
    func tapLogin() -> HomePage {
        loginButton.tap()
        return HomePage(app: app)
    }
    
    func tapLoginExpectingError() -> LoginPage {
        loginButton.tap()
        return self
    }
    
    func waitForPage() {
        XCTAssertTrue(emailField.waitForExistence(timeout: 5))
    }
}

class HomePage {
    let app: XCUIApplication
    
    init(app: XCUIApplication) {
        self.app = app
    }
    
    var welcomeMessage: XCUIElement { app.staticTexts["home_welcome_message"] }
    
    func waitForPage() {
        XCTAssertTrue(welcomeMessage.waitForExistence(timeout: 10))
    }
}

// 테스트 베이스 클래스
class BaseUITest: XCTestCase {
    var app: XCUIApplication!
    
    override func setUpWithError() throws {
        continueAfterFailure = false
        
        app = XCUIApplication()
        app.launchArguments = ["--uitesting"]  // 테스트 모드 플래그
        app.launchEnvironment = [
            "API_BASE_URL": "http://localhost:3000",  // 목 서버
            "DISABLE_ANIMATIONS": "1",
        ]
        app.launch()
    }
    
    override func tearDownWithError() throws {
        // 실패 시 스크린샷 저장
        if testRun?.failureCount ?? 0 > 0 {
            let screenshot = app.screenshot()
            let attachment = XCTAttachment(screenshot: screenshot)
            attachment.lifetime = .keepAlways
            add(attachment)
        }
        app.terminate()
    }
}

// 실제 테스트
class LoginUITests: BaseUITest {
    
    func testSuccessfulLogin() {
        let loginPage = LoginPage(app: app)
        loginPage.waitForPage()
        
        let homePage = loginPage
            .enterEmail("test@example.com")
            .enterPassword("password123")
            .tapLogin()
        
        homePage.waitForPage()
        XCTAssertTrue(homePage.welcomeMessage.exists)
    }
    
    func testInvalidCredentials() {
        let loginPage = LoginPage(app: app)
        loginPage.waitForPage()
        
        loginPage
            .enterEmail("wrong@example.com")
            .enterPassword("wrongpass")
            .tapLoginExpectingError()
        
        XCTAssertTrue(loginPage.errorMessage.waitForExistence(timeout: 5))
        XCTAssertEqual(loginPage.errorMessage.label, "이메일 또는 비밀번호가 올바르지 않습니다")
    }
    
    func testCheckoutFlow() {
        // 로그인 선행
        let loginPage = LoginPage(app: app)
        loginPage.waitForPage()
        loginPage.enterEmail("test@example.com").enterPassword("password123").tapLogin()
        
        // 상품 선택
        let productCell = app.cells["product_cell_1"].firstMatch
        XCTAssertTrue(productCell.waitForExistence(timeout: 5))
        productCell.tap()
        
        // 장바구니 추가
        app.buttons["add_to_cart_button"].tap()
        
        // 결제
        app.buttons["checkout_button"].tap()
        XCTAssertTrue(app.staticTexts["order_confirmed_title"].waitForExistence(timeout: 10))
    }
}
// Android Espresso
import androidx.test.espresso.Espresso.*
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.*
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.rules.ActivityScenarioRule
import org.hamcrest.Matchers.not

class LoginPageObject {
    fun enterEmail(email: String): LoginPageObject {
        onView(withId(R.id.emailInput)).perform(typeText(email), closeSoftKeyboard())
        return this
    }
    
    fun enterPassword(password: String): LoginPageObject {
        onView(withId(R.id.passwordInput)).perform(typeText(password), closeSoftKeyboard())
        return this
    }
    
    fun clickLogin(): HomePageObject {
        onView(withId(R.id.loginButton)).perform(click())
        return HomePageObject()
    }
    
    fun verifyErrorShown(message: String) {
        onView(withId(R.id.errorMessage))
            .check(matches(isDisplayed()))
            .check(matches(withText(message)))
    }
}

class LoginInstrumentedTest {
    @get:Rule
    val activityRule = ActivityScenarioRule(LoginActivity::class.java)
    
    @Test
    fun testSuccessfulLogin() {
        LoginPageObject()
            .enterEmail("test@example.com")
            .enterPassword("password123")
            .clickLogin()
        
        // 홈 화면 확인
        onView(withId(R.id.homeWelcomeText)).check(matches(isDisplayed()))
    }
    
    @Test
    fun testInvalidCredentials() {
        LoginPageObject()
            .enterEmail("wrong@example.com")
            .enterPassword("wrong")
            .clickLogin()
            .verifyErrorShown("이메일 또는 비밀번호가 올바르지 않습니다")
    }
}

마무리

UI 테스트의 핵심: 크리티컬 플로우만 커버하고, Page Object로 유지보수성을 높이고, 네트워크는 목킹하라. sleep()은 플레이키 테스트의 주범이다 — 항상 waitForExistence(timeout:) (XCUITest) 또는 IdlingResource (Espresso)를 사용하라. CI에서 3회 재시도해도 실패하면 플레이키로 분류하고 격리하라.