23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
안녕하세요.
에이닷에 UI 테스트 자동화를 적용하기 위하여, Android 테스트 프레임워크로 어느 정도의 테스트 커버리지를 가질 수 있을지 검토하게 되면서
Espresso와 UiAutomator 중 어떤 프레임워크를 사용하는 게 좋을지, 어떤 기능들을 테스트할 수 있을지 등을 직접 확인하며 이 블로그 글을 작성하게 되었습니다.
이 글에서는 UI 테스트 자동화의 중요성과 함께, 특히 Compose 기반의 UI 테스트에 초점을 맞추어 다양한 테스트 기법과 예제 코드를 소개하고자 합니다.
ComposeTestRule을 이용한 UI 요소 검증부터 시작하여, 특수 케이스인 Toast 메시지와 같은 시스템 UI 요소 검증 방법에 대해서도 살펴보겠습니다.
또한, 비동기 테스트와 XML 레이아웃 테스트, 그리고 Webview 테스트와 같은 다양한 시나리오에 대한 해결책도 포함하여 보다 포괄적인 테스트 전략을 제시하고자 합니다.
UI 테스트 자동화가 어떻게 애플리케이션의 품질 향상과 개발 생산성을 도모할 수 있는지에 대해 살펴보며,
실제 코드와 함께 구체적인 예시를 제공하여 여러분께 유익한 정보를 전달하려고 합니다.
이 블로그 글을 통해 UI 테스트 자동화에 대한 기초적인 이해부터 심화된 기법까지 폭넓게 다루어, 앱 개발 과정에서의 효과적인 UI 테스트 전략을 돕기를 바랍니다.
이 글과 관련된 기존 글도 함께 공유합니다.
에이닷 Android 앱은 대부분은 Compose로 UI가 개발되었기 때문에, 테스트 프레임워크가 Compose를 얼마나 잘 지원하는지 확인하는 것이 중요했습니다.
Android에서는 UiAutomator와 Espresso 프레임워크가 제공되며, 각자의 장단점을 분석한 후 적절한 방법을 선택하는 것이 필요했습니다.
분석 결과, Compose와 Espresso의 조합이 가장 적합하다는 결론을 내렸습니다.
Espresso는 Android에서 적극적으로 가이드하고 있으며 다양한 기능을 제공하여 선택하게 되었습니다.
그러나 상황에 따라 UiAutomator와 함께 사용하는 하이브리드 접근 방식이 더 효율적일 수 있습니다.
기본적으로 Espresso를 사용하되, 필요에 따라 UiAutomator를 활용하는 방식입니다.
앱 내부의 View를 찾고 클릭, 스크롤 등의 액션을 취하는 부분은 Espresso를 활용
Espresso는 앱에서 Android의 설정 화면으로 이동할 경우 컨트롤이 안되는 이슈가 있으므로, 이 경우 UiAutomator를 사용
테스트가 실패했을 때 현재 화면을 캡쳐하는 데 UiAutomator를 활용
UITest를 위해서는 View를 찾기 위한 값이 필요합니다.
특정 텍스트를 이용하여 찾을 수도 있지만, 해당 텍스트를 비교하거나 서버에서 조회된 값일 경우 UITest에서 사용하기 어렵습니다.
따라서 testTag를 사용하여 Compose View에 ID를 부여하고, TestCase에서는 해당 tag를 이용하여 접근했습니다.
아래 코드는 "tag"을 testTag로 설정한 후, TestCode에서 해당 View가 화면에 보이는지 체크하는 예제입니다.
- Compose
Text (
text = "Hello World",
modifier = Modifier
.testTag("tag:MainScreen_Text")
)- TestCase
composeTestRule
.onNodeWithTag("tag:MainScreen_Text")
.assertIsDisplayed()UI 테스트에서 특정 요소를 찾는 것은 매우 중요합니다.
특히, 화면에 표시되는 텍스트나 특정 태그를 기준으로 요소를 찾는 것은 자주 사용되는 방법입니다.
ComposeTestRule을 사용하면 이러한 요소를 쉽게 검색하고 검증할 수 있습니다.
다음은 앱 코드에 등록된 testTag 또는 화면에 표시되는 텍스트를 기준으로 노드를 검색하는 기본 예제입니다.
이 예제는 노드를 검색한 후, 해당 노드가 화면에 제대로 표시되는지(assertIsDisplayed)를 확인합니다.
composeTestRule
.onNodeWithTag("tag:main_title_text")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("hello world")
.assertIsDisplayed()useUnmergedTree 옵션을 추가한 예제
Espresso는 UI 요소를 찾을 때 미처리된 뷰 트리(unmerged view tree)를 사용합니다. 뷰 트리는 레이아웃이 렌더링되기 전후의 상태에 따라 두 가지로 나뉩니다.
뷰 트리(View Tree):
미처리된 뷰 트리(Unmerged View Tree): 레이아웃이 렌더링되기 전의 상태로, 뷰 계층 구조가 병합되지 않은 상태입니다. 각 뷰가 자신만의 위치와 속성을 유지합니다.
병합된 뷰 트리(Merged View Tree): 레이아웃이 렌더링된 후의 상태로, 여러 뷰가 하나의 단일 레이아웃으로 병합된 상태입니다. 렌더링 엔진이 최적화된 상태로 보여지기 위해 뷰들을 병합합니다.
복잡도가 높은 화면에서는 useUnmergedTree 옵션을 사용하여 UI 요소를 찾는 것이 더 효과적일 수 있습니다. 아래 예제는 이 옵션을 사용하는 방법을 보여줍니다.
composeTestRule
.onNodeWithTag(tag:main_title_text, useUnmergedTree = true)
.assertIsDisplayed()이 옵션을 사용하면 복잡한 레이아웃에서도 UI 요소를 더 정확하게 검색할 수 있습니다.
Compose UI가 아닌 XML 레이아웃으로 구성된 UI를 테스트하는 방법도 있습니다.
이 경우 Espresso를 사용하여 뷰 요소를 찾고, 필요한 동작을 수행할 수 있습니다.
아래 예제는 특정 뷰 요소를 클릭하는 방법을 보여줍니다.
onView(withId(R.id.hide_contextual_action_bar))
.perform(click())앱 테스트에서 Toast 메시지와 같은 시스템 UI 요소를 검증하는 것은 중요합니다.
Toast 메시지는 일시적으로 사용자에게 정보를 제공하는 데 사용되며, UI 계층 외부에서 렌더링됩니다.
ComposeTestRule을 사용하여 Compose UI 계층 내의 요소를 검증할 수 있지만, Toast 메시지와 같은 시스템 레벨의 UI 요소는 직접적으로 ComposeTestRule로 접근할 수 없습니다.
이 때문에 ToastMatcher와 같은 도구를 사용하여 Toast 메시지를 검증합니다.
ToastMatcher 소개
ToastMatcher는 Espresso와 함께 사용되는 커스텀 Matcher입니다. 이 Matcher를 사용하면 시스템 UI 요소인 Toast 메시지를 식별하고 검증할 수 있습니다.
ToastMatcher를 통해 Compose 계층 외부에 있는 요소를 효과적으로 테스트할 수 있습니다.
ToastMatcher 사용 이유
시스템 UI 요소 접근: Toast 메시지는 시스템 레벨에서 생성되며, ComposeTestRule과 같은 Compose UI 테스트 도구로 직접 접근할 수 없습니다.
따라서 ToastMatcher를 사용하여 시스템 UI에서 토스트 메시지를 검증합니다.
테스트 격리: 토스트 메시지와 같은 시스템 UI 요소는 테스트에서 다루기 어려운 경우가 많습니다.
ToastMatcher를 사용하면 테스트의 격리성을 유지하면서도 토스트 메시지의 존재 여부를 검증할 수 있습니다.
예제 코드
아래는 ToastMatcher를 사용하여 특정 텍스트가 포함된 Toast 메시지가 화면에 표시되는지 검증하는 예제 코드입니다.
// ToastMatcher
class ToastMatcher(private val message: String) : TypeSafeMatcher<Root>() {
override fun describeTo(description: Description) {
description.appendText(message)
}
override fun matchesSafely(root: Root): Boolean {
val type: Int = root.windowLayoutParams.get().type
if (type == WindowManager.LayoutParams.TYPE_TOAST) {
val windowToken: IBinder = root.decorView.windowToken
val appToken: IBinder = root.decorView.getApplicationWindowToken()
if (windowToken === appToken) {
return true
}
}
return false
}
}
Espresso
.onView(ViewMatchers.withText(text))
.inRoot(ToastMatcher(text))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))Toast 외의 특수 UI 요소 검증
Toast 메시지 외에도 시스템 UI 요소나 특정 조건에서만 나타나는 UI 요소를 검증해야 하는 경우가 있습니다.
이런 특수 케이스들은 일반적인 ComposeTestRule로 처리하기 어려울 수 있습니다.
Espresso를 사용하면 WebView 테스트도 쉽게 수행할 수 있습니다.
아래 예제는 WebView 내의 요소를 찾고, 해당 요소를 클릭한 후 URL을 확인하는 방법을 보여줍니다.
Web.onWebView()
.withElement(findElement(Locator.ID, "link_2")) // similar to onView(withId(...))
.perform(webClick()) // Similar to perform(click())
// Similar to check(matches(...))
.check(webMatches(getCurrentUrl(), containsString("navigation_2.html")))
}Locator에는 다양한 옵션이 있으며, Native 코드에서 testTag를 적용하는 것처럼 유니크한 아이디를 활용하여 테스트를 수행할 수 있습니다.
public enum Locator {
CLASS_NAME,
CSS_SELECTOR,
ID,
LINK_TEXT,
NAME,
PARTIAL_LINK_TEXT,
TAG_NAME,
XPATH;
}관련 예제는 WebBasicSample 프로젝트에서 확인할 수 있습니다.
서버와의 연동 후 데이터가 화면에 채워지는 경우, 비동기적으로 대기(waiting)를 해야 할 때가 있습니다.
이 문제를 해결하기 위해 특정 버튼이 활성화될 때까지 기다린 후 클릭을 수행하는 유틸리티 함수를 사용할 수 있습니다.
fun ComposeTestRule.clickWithTag(tag: String) {
this.waitUntil(3_000L) {
this.onAllNodes(
hasTestTag(tag)
and isEnabled()
,
useUnmergedTree = true
).fetchSemanticsNodes().isNotEmpty()
}
this.onNodeWithTag(tag)
.assertIsDisplayed()
.assertIsEnabled()
.performClick()
}이 함수는 주어진 태그를 가진 요소가 활성화될 때까지 최대 3초 동안 대기한 후, 해당 요소가 화면에 표시되고 활성화되었는지 확인한 후 클릭을 수행합니다.
이를 통해 비동기적인 상황에서도 안정적인 테스트를 수행할 수 있습니다.
스트를 작성할 때, 문제가 발생했을 때 원인을 파악하는 것은 매우 중요합니다. 테스트 실패의 원인을 빠르게 찾고 수정하기 위해 디버깅은 필수적입니다.
디버깅을 효과적으로 하기 위해 현재 화면에 노출된 UI 요소들(Node)의 정보를 로그로 출력하는 방법을 사용할 수 있습니다.
이를 통해 각 요소의 상태를 확인하고, 예상과 다른 동작을 하는 부분을 쉽게 찾을 수 있습니다.
다음은 ComposeTestRule을 사용하여 화면에 노출된 요소의 정보를 로그로 출력하는 함수입니다.
이 함수는 UI 요소의 구성(config)을 로그로 출력하여 디버깅에 도움을 줍니다.
fun ComposeTestRule.printToLog(
node: SemanticsNodeInteraction?,
depth: Int = Int.MAX_VALUE,
logTag: String = DEFAULT_TC_TAG
) {
fun logMessage(message: String) {
Log.d(logTag, "[printToLog] $message")
}
fun logError(error: Throwable) {
Log.w(logTag, "Failed to printToLog\n$error")
}
runCatching {
val config = node?.fetchSemanticsNode()?.config
logMessage(config.toString())
}.onFailure {
logError(it)
}.getOrElse {
return
}
node ?: return
node.printToLog(logTag, depth)
}이 함수는 SemanticsNodeInteraction 객체를 받아서 그 구성 정보를 로그로 출력합니다.
만약 예외가 발생하면 이를 로그에 기록하여 추후 문제를 파악할 수 있도록 합니다.
테스트 중 문제가 발생했을 때, 현재 화면을 캡쳐하는 기능은 매우 유용합니다.
특히 UI 테스트에서는 예상치 못한 화면 전환이나 렌더링 문제를 시각적으로 확인할 수 있기 때문에 문제 해결에 큰 도움이 됩니다.
아래 코드는 현재 화면을 캡쳐하고 저장된 파일의 경로를 반환하는 방법을 보여줍니다.
fun screenCapture(testName: String): String {
val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
uiDevice.waitForIdle()
val path = InstrumentationRegistry.getInstrumentation().targetContext.getExternalFilesDir(null)
val file = File(path, "$testName.png")
uiDevice.takeScreenshot(file, 0.5f, 100)
uiDevice.waitForIdle()
return file.path
}이 함수는 UiDevice를 사용하여 현재 화면을 캡쳐하고, 캡쳐된 이미지를 파일로 저장합니다.
파일의 경로를 반환하여 나중에 쉽게 접근할 수 있도록 합니다.
테스트 실행 중 발생한 로그를 저장하는 기능은 테스트 실패 원인을 분석하는 데 매우 유용합니다.
로그를 파일에 저장하면 나중에 문제를 재현하거나, 문제의 원인을 보다 쉽게 파악할 수 있습니다.
다음 코드는 로그를 파일에 저장하고 해당 파일의 경로를 반환하는 방법을 보여줍니다.
fun saveLogToFile(testName: String): String {
try {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val logFile = File(context.getExternalFilesDir(null), "${testName}_log.txt")
val process = Runtime.getRuntime().exec("logcat -d")
val logcatInput = process.inputStream.bufferedReader().use { it.readText() }
logFile.writeText(logcatInput)
return logFile.path
} catch (e: IOException) {
e.printStackTrace()
return ""
}
}이 함수는 logcat 명령어를 사용하여 현재 로그를 가져와 파일에 저장합니다.
저장된 파일의 경로를 반환하여 테스트 결과를 분석할 때 활용할 수 있습니다.
JUnit에서는 여러 테스트 케이스를 그룹화하여 하나의 테스트 스위트로 실행할 수 있습니다.
이는 관련된 테스트를 함께 실행하고 관리하는 데 유용합니다. 아래 코드는 특정 테스트 케이스 클래스를 Suite로 묶어 실행하는 예제입니다.
@RunWith(Suite::class)
@Suite.SuiteClasses(
TC_001_로그인테스트::class,
TC_002_설정테스트::class
class TC_RegressionTest {
}터미널 명령어를 통해 테스트 케이스를 실행하는 방법은 CI/CD 파이프라인에서 특히 유용합니다.
이를 통해 자동화된 테스트를 쉽게 실행하고 관리할 수 있습니다.
터미널 명령어를 사용하면 별도의 IDE 없이도 테스트를 빠르게 실행할 수 있으며, 스크립트를 이용해 반복적인 테스트 작업을 자동화할 수 있습니다.
이는 특히 빌드 서버나 지속적 통합(CI) 시스템에서 테스트를 수행할 때 매우 유용합니다.
앱의 모든 테스트 케이스를 실행하려면 다음 명령어를 사용할 수 있습니다.
이 명령어는 프로젝트의 모든 테스트를 한 번에 실행하여 전체적인 테스트 커버리지를 확인하는 데 유용합니다.
모든 테스트를 실행하면 애플리케이션의 다양한 기능들이 제대로 작동하는지 종합적으로 확인할 수 있습니다.
adb shell am instrument -w com.demo.app.test/androidx.test.runner.AndroidJUnitRunner특정 테스트 클래스만 실행하고 싶을 때는 다음 명령어를 사용합니다.
특정 클래스의 테스트를 실행하는 것은 기능별로 테스트를 세분화하여 보다 빠르고 효율적으로 테스트할 수 있는 방법입니다.
예를 들어, 로그인 관련 테스트만 실행하거나, 결제 기능에 대한 테스트만 별도로 실행하여 해당 기능의 안정성을 집중적으로 점검할 수 있습니다.
adb shell am instrument -w -e class com.demo.app.test.TC_RegressionTest com.demo.app.test/androidx.test.runner.AndroidJUnitRunner테스트를 여러 디바이스에서 동시에 실행하여 다양한 환경에서의 앱 동작을 확인할 수 있습니다.
이는 앱이 다양한 기기에서 일관되게 작동하는지 검증하는 데 중요한 역할을 합니다.
각 디바이스의 고유한 특성(예: 화면 크기, OS 버전 등)으로 인해 발생할 수 있는 문제를 조기에 발견할 수 있습니다.
아래 명령어는 두 개의 디바이스에서 동일한 테스트 클래스를 동시에 실행하는 예제입니다.
adb -s R39RCAAAA shell am instrument -w -e class com.demo.app.test.TC_RegressionTest com.demo.app.test/androidx.test.runner.AndroidJUnitRunner
adb -s R39RCBBBB shell am instrument -w -e class com.demo.app.test.TC_RegressionTest com.demo.app.test/androidx.test.runner.AndroidJUnitRunner이 명령어들은 특히 대규모 테스트 환경에서 유용합니다.
여러 디바이스에서 테스트를 병렬로 실행함으로써 테스트 시간을 단축할 수 있고, 여러 플랫폼에 대한 지원을 보다 확실하게 할 수 있습니다.
이번 글에서는 Android UI 테스트를 효과적으로 수행하기 위한 다양한 방법과 팁에 대해 다루었습니다.
Compose와 Espresso의 조합을 통해 UI 테스트를 보다 효율적으로 진행할 수 있었으며,
필요에 따라 UiAutomator를 활용해 하이브리드 테스트 환경을 구축하는 방법도 살펴보았습니다.
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.