JetpackCompose의 튜토리얼 codelabs을 하면서 자동 테스트와 ViewModel의 단위 테스트 작성을 해보게 되었다.
내가 해본 테스트라곤 학부생 토이프로젝트 수준이었기 때문에 코드를 작성해보고 테스트로 변수 부분을 바꿔가며 에뮬레이터나 기기에 연결해 직접 부딪혀 LogCat에서 잡고 수정하는 것이었다.
자동 테스트란?
소프트웨어의 테스트는 소프트웨어가 예상대로 작동하는지 확인하는 구조화된 방법이다. 자동 테스트는 개발자가 작성한 코드의 또 다른 부분이 올바르게 작동하는지 확인하는 코드이다.
테스트는 앱 개발 프로세스에서 중요한 부분으로, 앱 테스트를 일관되게 실행하여 앱을 공개적으로 출시하기 전에 앱의 기능 동작과 사용성을 확인할 수 있다.
변경사항이 도입될 때 테스트를 통해 기존 코드를 지속적으로 확인할 수도 있다.
수동 테스트는 대부분 계속 사용할 수 있지만, Android의 테스트가 자동화되는 경우도 많다. Android 개발 및 Android 앱 테스트에 점차 익숙해지면 앱 코드와 함께 테스트도 주기적으로 작성하는 것이 좋다. 앱에 새 기능을 만들 때마다 테스트를 만들면 추후 앱이 성장할 때의 워크로드가 줄어든다. 또한, 앱을 수동으로 테스트하느라 많은 시간을 소비할 필요 없이 앱이 적절하게 작동하는지 확인할 수 있는 편리한 방법을 제공한다.
자동테스트는 모든 소프트웨어 개발에서 필수적이며 Android 개발도 예외는 아니다.
자동 테스트가 중요한 이유
처음에는 앱에 테스트가 그다지 필요하지 않은 것처럼 보이나, 테스트는 크기와 복잡성이 다양한 모든 앱에 필요하다.
코드베이스를 확장하려면 새 코드를 추가할 때 기존 기능을 테스트해야 하는데, 이는 기존 테스트가 존재할 때나 가능하다. 앱의 규모가 커지면 수동 테스트를 할 때 자동 테스트보다 훨씬 많은 노력이 필요하고, 프로덕션 버전의 앱 작업을 시작했을 때, 사용자층이 넓은 경우 테스트를 진행하는 건 더욱 중요하다. 예를 들어 다양한 버전의 Android를 실행하는 여러 유형의 기기를 고려한다던지...
결국 자동 테스트를 통해 수동 테스트보다 훨씬 빠르게 대부분의 사용 시나리오를 고려할 수 있는 지점에 다다르게 된다. 새 코드를 출시하기 전에 테스트를 실행하면, 예상치 못한 동작이 있는 상태로 앱이 출시되지 않도록 기존 코드를 변경할 수 있다.
자동테스트는 소프트웨어를 통해 실행된 테스트이고, 이와 달리 수동 테스트는 기기와 직접 상호작용하는 테스터가 존재한다. 자동 테스트와 수동 테스트는 제품 사용자에게 쾌적한 환경을 제공하는데 중요한 역할을 한다. 그러나 자동 테스트가 더 정확하고 팀의 생산성을 최적화할 수 있다. 테스트를 실행하는데 사람이 필요하지 않으며 수동 테스트보다 훨씬 빠르게 실행할 수 있기 때문이다.
자동 테스트 유형
1. 로컬 테스트
소수의 코드를 직접 테스트하여 제대로 작동하는지 확인하는 유형. 함수, 클래스, 속성을 테트스할 수 있고, 워크스테이션에서 실행된다. 이 테스트는 개발자의 컴퓨터에서 실행되며, 컴퓨터 리소스의 오버헤드도 매우 낮기에 제한된 리소스에서 빠르게 실행가능하다.
예를 들어 다음과 같은 코드가 존재한다.
해당 코드는 팁계산기에서 가져온 팁을 계산하는 함수로 기존에는 private 상태였으나, @VisibleForTesting 주석을 추가하고 internal로 바꾼다.
@VisibleForTesting
internal fun calculateTip(amount: Double, tipPercent: Double = 15.0, roundUp: Boolean): String {
val tip = tipPercent / 100 * amount
return NumberFormat.getCurrencyInstance(Locale.US).format(if (roundUp) ceil(tip) else tip)
}
이렇게 하면 메서드가 공개되지만 테스트 목적으로만 공개된다고 사용자에게 표시할 수 있다.
프로젝트에 기본적으로 만들어져 있는 androidTest와 test 단위 폴더에서 자동 테스트와 계측 테스트를 진행할 수 있다.
앞서 말했듯 로컬 테스트는 앱에서 소량의 코드를 테스트하는데 사용된다. 팁 계산기의 기본 기능은 팁을 계산하는 것이므로 팁 계산 로직이 올바르게 작동하는지 확인 차 로컬 테스트를 가동한다.
이를 위해 저 calculateTip 함수를 직접 호출하여 함수의 반환 값이 함수에 전달한 값 기준으로 예상값과 일치하는지 확인한다.
자동 테스트 작성에 관해 알아야 할 몇 가지 사항이 존재하는데, 이 개념 목록은 로컬 테스트와 계측 테스트에 적용되며 처음엔 복잡하지만 테스트를 익히며 파악해나가야 한다.
- 메서드 형태로 자동 테스트를 작성한다.
- 메서드에 @Test 주석을 달아야 한다. 저 Test 주석을 다는 것으로 컴파일러에 테스트 메서드임을 알릴 수 있다.
- 이름에 테스트의 테스트 내용과 예상 결과가 명확하게 설명되어야 한다.
- 테스트 메서드는 일반 앱 메서드와 같은 로직을 사용하지 않는다. 항목이 구현되는 방식에 관심이 없으며, 지정된 입력의 예상 출력을 엄격히 확인한다. 즉, 테스트 메서드는 앱의 UI 또는 로직이 올바르게 작동하는지 어설션하는 일련의 명령만 실행하므로 일반적인 앱 코드와는 다르게 생겼다.
- 테스트는 일반적으로 특정 조건이 충족되었는지 확인하는데 사용되는 어설션으로 끝난다. 어설션은 이름에 assert가 있는 메서드 호출의 형태로 제공되며, 이 어설션 문은 대부분의 테스트에서 사용되나 실제 앱 코드에선 거의 사용되지 않는다.
작성한 로컬 테스트 하나를 확인해보자.
청구 금액 10달러의 팁 20% 계산을 테스트하는 메서드이다. 예상 결과는 2달러가 나와야 한다.
package com.example.tipcalculator.ui.test
import com.example.tipcalculator.calculateTip
import junit.framework.TestCase.assertEquals
import org.junit.Test
import java.text.NumberFormat
import java.util.Locale
class TipCalculatorTests {
@Test
fun calculateTip_20PercentNoRoundup() {
val amount = 10.00
val tipPercent = 20.00
val expectedTip = NumberFormat.getCurrencyInstance(Locale.US).format(2)
val actualTip = calculateTip(amount, tipPercent, false)
assertEquals(expectedTip, actualTip)
}
}
이름을 보면 "팁계산_20퍼센트반올림없이"로 한 눈에 저게 무엇을 테스트하는 메서드인지 알 수 있게 명시되어있다.
Test 주석이 달려 있으며, 메서드 형태이다.
- calculateTip() 함수에는 금액, 팁 퍼센티지, 결과 반올림 여부가 필요하고, 이에 맞게 변수를 선언해준다.
- expectedTip은 나중에 calculateTip 메서드의 결과와 비교되는 예상결과 변수이다.
- actualTip은 테스트로 지정한 변수들을 calculateTip에 알맞게 집어넣고 값을 반환한다.
- assertEquals 메서드에서 두 값이 같은지를 확인하고 같다면 테스트를 통과시킨다.
이후 테스트를 실행하여 결과를 확인하면 로컬 테스트는 끝난다.
2. 계측 테스트
Android 개발의 경우 계측 테스트는 UI 테스트이다. 계측 테스트를 사용하면 Android API 와 플랫폼 API 및 서비스에 종속된 앱 일부를 테스트할 수 있다.
로컬 테스트와 달리 UI 테스트는 앱이나 앱의 일부를 실행하고 사용자 상호작용을 시뮬레이션하여 앱이 적절하게 반응했는지 확인한다. 이 과정 전반에 걸쳐 UI 테스트는 실제 기기, 에뮬레이터에서 실행한다. 테스트코드를 실제로 일반 Android 앱처럼 APK로 빌드하고, 이 APK가 기기, 에뮬레이터에 설치된다. 이후, 앱을 실행하여 테스트를 진행한다.
계측 테스트는 로컬테스트와 달리 androidTest 폴더에서 진행된다.
계측 테스트 코드는 로컬 테스트 코드와 매우 다르게 생겼다.
이 테스트는 앱과 UI의 실제 인스턴스를 테스트하는 것으로, 기존에 팁 계산기 앱 코드를 작성할 때 MainActivity.kt 파일의 onCreate() 메서드에서 콘텐츠를 설정하는 방식과 유사하게 UI 컨텐츠를 설정해야 한다.
Compose로 빌드된 앱의 경우, 모든 계측 테스트를 작성하기 전에 이 작업을 실행해야 한다.
1. createComposeRule() 메서드의 결과로 설정된 composeTestRule 변수를 만들고 Rule 주석을 추가한다.
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
class TipUITests {
@get:Rule
val composeTestRule = createComposeRule()
}
2. calculate_20_percent_tip() 메서드를 만들고 @Test 주석을 추가한다.
여기서 메서드 이름은 자신이 테스트하는 항목에 따라 자유롭게 설정한다.
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
import org.junit.Test
class TipUiTests {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun calculate_20_percent_tip() {
}
}
3. 메서드 본문에 composeTestRule.setContent() 함수를 호출한다. 이로써 UI 컨텐츠가 설정된다.
4. 메서드 람다 본문에 자신이 작성해둔 MainActivity.kt의 함수를 호출한다.
import androidx.compose.ui.test.junit4.createComposeRule
import com.example.tipcalculator.TipTimeLayout
import com.example.tipcalculator.ui.theme.TipCalculatorTheme
import org.junit.Rule
import org.junit.Test
class TipUiTests {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun calculate_20_percent_tip() {
composeTestRule.setContent {
TipCalculatorTheme {
TipTimeLayout()
}
}
}
}
5. UI 구성요소는 composeTestRule을 통해 "노드"로 액세스할 수 있다.
일반적으로 onNodeWithText() 메서드를 통해 특정 텍스트가 포함된 노드에 액세스한다.
- onNodeWithText() 메서드를 사용해 청구 금액에 관한 TextField 컴포저블에 액세스할 수 있다.
- performTextInput() 메서드를 사용해 입력하려는 텍스트를 전달하여 TextField 컴포저블을 채울 수 있다.
import androidx.compose.ui.test.junit4.createComposeRule
import com.example.tipcalculator.TipTimeLayout
import com.example.tipcalculator.ui.theme.TipCalculatorTheme
import org.junit.Rule
import org.junit.Test
class TipUiTests {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun calculate_20_percent_tip() {
composeTestRule.setContent {
TipCalculatorTheme {
TipTimeLayout()
}
}
composeTestRule.onNodeWithText("Bill Amount").performTextInput("10")
}
}
이와 동일한 방법으로 나머지 값을 채워주면 된다.
6. 값을 채우고 난 후, Text 컴포저블이 어설션과 함께 올바른 팁을 표시하는지 확인해야한다.
Compose를 사용한 계측 테스트에서 어설션은 UI 구성요소에서 직접 호출이 가능하다. 여러 어설션이 사용 가능하지만 이번에는 assertExists()를 사용한다.
package com.example.tipcalculator.ui.test
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import com.example.tipcalculator.TipTimeLayout
import com.example.tipcalculator.ui.theme.TipCalculatorTheme
import org.junit.Rule
import org.junit.Test
import java.text.NumberFormat
import java.util.Locale
class TipUiTests {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun calculate_20_percent_tip() {
composeTestRule.setContent {
TipCalculatorTheme {
TipTimeLayout()
}
}
composeTestRule.onNodeWithText("Bill Amount").performTextInput("10")
composeTestRule.onNodeWithText("Tip Percentage").performTextInput("20")
val exceptedTip = NumberFormat.getCurrencyInstance(Locale.US).format(2)
composeTestRule.onNodeWithText("Tip Amount: $exceptedTip").assertExists("No node with this text was found.")
}
}
팁 금액을 표시하는 Text 컴포저블은 다음과 같이 표시 되어야 한다.
"Tip Amount: $2.00"
$ 표시는 Locale.US에서 표시 설정한 것에 따라 다르게 바뀌며, assertExists()는 이 텍스트가 있는 노드가 존재한다는 어설션을 만든 것이다.
'개발 > AOS' 카테고리의 다른 글
Compose Navigation 경로 정의 및 NavHostController (0) | 2024.05.14 |
---|---|
애플리케이션 테스트 02. ViewModel의 단위 테스트 작성 (1) | 2024.05.09 |
앱 아키텍처 알아보기 (0) | 2024.05.08 |
rememberSaveable이란 (0) | 2024.05.08 |
AnimatedVisibility()와 MutableTransitionState() (0) | 2024.05.07 |
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!