이 글은 누구를 위한 것인가
- 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회 재시도해도 실패하면 플레이키로 분류하고 격리하라.