Robolectric은 Android 코드를 JVM에서 Unit test하는데 사용하는 테스팅 프레임워크입니다. 에뮬레이터나, 디바이스에서 직접 테스트하지 않기 때문에 속도가 빠릅니다. (하지만, 많이 빠르지 않기 때문에 실제 디바이스에서 테스트하는 나을 수도 있습니다.)
Android Studio의 Unit test도 JVM에서 테스트를 수행하지만, Android SDK는 아무 동작도 하지 않는 stub, android.jar를 사용합니다. 이 stub은 아무 동작도 하지 않고, null이나 0을 리턴합니다. 그렇기 때문에 필요한 것은 Mockito 등으로 Mock을 만들어서 테스트를 해야 합니다.
반면에, Robolectric은 Shadow라는 개념이 있습니다. Shadow는 Mock은 아니지만 Mock과 유사하게, 어떤 의도된 동작을 수행하는 테스트 더블입니다. Robolectric는 Android SDK의 클래스의 Shadow를 구현하였습니다. 이 Shadow들을 사용하여 테스트 코드를 작성하면 디바이스에 동작하는 것처럼 결과를 출력할 수 있습니다. 그렇기 때문에 Mock을 불필요하게 많이 작성하지 않아도 됩니다. 또한, Custom shadow가 필요하다면 직접 만들 수도 있습니다.
Android Studio에서 Robolectric을 사용하여 Unit test를 작성하는 방법에 대해서 알아보겠습니다.
이 글의 코드는 모두 Kotlin으로 작성되었습니다. 샘플은 GitHub에서 확인할 수 있습니다.
프로젝트 Setup
app의 gradle에 다음과 같이 의존성과, testOptions
를 추가합니다.
android {
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
dependencies {
testImplementation 'org.robolectric:robolectric:4.3.1'
}
Android Studio 3.3 미만의 버전에는 gradle.properties
에 다음을 추가합니다.
android.enableUnitTestBinaryResources=true
프로젝트를 보시면, src/
아래는 다음과 같습니다.
여기서 test/
가 JVM에서 동작하는 Unit test입니다.
├── androidTest
├── main
└── test
└── java
└── com
└── codechacha
└── robolectric
└── ExampleUnitTest.kt
ExampleUnitTest.kt
파일에서 다음과 같이 annotation을 설정합니다.
@RunWith(RobolectricTestRunner::class)
class ExampleUnitTest {
...
}
Android는 Robolectric test 코드가 Unit이 아닌, Instrumentation test에서도 동작할 수 있도록 노력하고 있습니다.
그래서 아래와 같이 AndroidJUnit4::class
를 사용해도 동작합니다.
@RunWith(AndroidJUnit4::class)
class ExampleUnitTest {
...
}
이제 프로젝트에서 Robolectric으로 Unit test를 작성할 수 있습니다.
JDK 설정
Robolectric는 SDK API 29에 대해서 Java 9를 요구합니다.
[File] => [Project Structures]
의 SDK Location
에서 JDK를 Java9로 변경해줘야 합니다.
SDK API 29 미만의 경우 Java 9으로 설정하지 않아도 컴파일됩니다.
앱 구현
테스트 코드를 작성하기 전에 먼저 간단한 앱을 구현해야 합니다.
다음과 같이 구현하였습니다.
MainActivity.kt
class MainActivity : AppCompatActivity() {
companion object {
const val TAG = "MainActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
Log.d(TAG, "onCreate")
mainButton.setOnClickListener {
mainTextView.text = "Hello world!"
}
}
override fun onResume() {
Log.d(TAG, "onResume")
super.onResume()
}
override fun onPause() {
Log.d(TAG, "onPause")
super.onPause()
}
}
res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<Button
android:id="@+id/mainButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"/>
<TextView
android:id="@+id/mainTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Not set"/>
<TextView
android:id="@+id/helloTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hello"/>
</LinearLayout>
res/values/strings.xml
<resources>
<string name="app_name">Robolectric</string>
<string name="hello">Hello</string>
</resources>
res/values-ko/strings.xml
<resources>
<string name="hello">안녕하세요</string>
</resources>
테스트 코드 작성 (로그 출력)
이제 위에서 구현한 앱을 테스트하는 코드를 작성할 수 있습니다.
먼저 ExampleUnitTest.kt
에 다음과 같이 입력해주세요.
class ExampleUnitTest {
@Before
fun setup() {
ShadowLog.stream = System.out;
}
}
위의 코드는 Log.d()
와 같이, 디바이스에서 로그를 출력하는 코드를 JVM에서도 동작하게 만드는 것입니다.
이렇게 설정하지 않으면 Log.d()
는 로그를 출력하지 않습니다.
TextView 테스트
다음 코드는 Activity의 TextView의 초기값을 확인하는 코드입니다.
@Test
fun textView_text_is_right() {
val activity = Robolectric.setupActivity(
MainActivity::class.java) // 1
assertEquals("Not set", activity.mainTextView.text) // 2
}
Robolectric.setupActivity()
는 Activity를 초기화합니다. 내부적으로 onCreate(), onResume() 등이 호출됩니다.
실제 Activity 코드가 동작하지 않고, Robolectric에서 만든 Shadow Activity 코드가 동작합니다. 2. Activity의 TextView를 가져오고 text의 초기값을 확인합니다.
Shadow는 실제 코드와 유사하게 동작하는 것 같습니다.
하지만, 내부적으로 Shadow가 어떻게 구현되었는지 모르기 때문에 에뮬레이터나 디바이스에서도 테스트 결과가 동일할까? 라는 생각이 계속 드네요.
Button 테스트
앱의 MainActivity.kt
에서 다음과 같이 버튼이 눌리면 text를 변경하도록 구현했었습니다.
mainButton.setOnClickListener {
mainTextView.text = "Hello world!"
}
다음 코드는 Button이 눌렸을 때 text가 변경되었는지 확인하는 테스트입니다.
@Test
fun textView_when_click_button() {
val activity = Robolectric.setupActivity(MainActivity::class.java)
assertEquals("Not set", activity.mainTextView.text)
activity.mainButton.performClick() // 1
assertEquals("Hello world!", activity.mainTextView.text) // 2
}
- Button 클릭
- text가 변경되었는지 확인
ActivityController
위에서 Activity를 만들 때 Robolectric.setupActivity()
를 사용했었습니다.
이 코드는 Activity를 만들어주지만, Lifecycle을 마음대로 변경할 수 없습니다.
다음 코드는 ActivityController
를 이용하여 Lifecycle을 직접 변경하는 코드입니다.
@Test
fun textView_text_is_right2() {
val controller: ActivityController<MainActivity> =
Robolectric.buildActivity(MainActivity::class.java) // 1
val activity = controller // 2
.create()
.start()
.resume()
.visible()
.get()
assertEquals("Not set", activity.mainTextView.text) // 3
activity.mainButton.performClick()
assertEquals("Hello world!", activity.mainTextView.text)
controller // 4
.pause()
.stop()
.destroy()
}
- ActivityController 생성
- ActivityController의 Lifecycle을 변경하고,
get()
으로 Activity 리턴 - text 확인
- Activity의 Lifecycle을 destroy로 변경
Lifecycle이 변경될 때마다, Activity의 onResume()
, onPause()
등에 넣은 로그가 출력됩니다.
Activity 재실행 테스트
Activity가 다시 실행되는 케이스를 테스트하고 싶을 수 있습니다.
다음과 같이 Activity Lifecycle을 변경하고 테스트 코드를 작성하면 됩니다.
@Test
fun recreatesActivity() {
val bundle = Bundle()
var controller: ActivityController<MainActivity> =
Robolectric.buildActivity(MainActivity::class.java)
controller
.create()
.start()
.resume()
.visible()
.get()
// Destroy the original activity
controller
.saveInstanceState(bundle)
.pause()
.stop()
.destroy()
// Bring up a new activity
controller = Robolectric.buildActivity(MainActivity::class.java)
.create(bundle)
.start()
.restoreInstanceState(bundle)
.resume()
.visible()
val activity = controller.get()
// ... add assertions ...
}
다국어 테스트
다양한 Locale에 대해서 테스트할 수 있습니다.
다음 코드처럼 @Config
annotation에 qualifiers
로 locale을 설정할 수 있습니다.
@Test
@Config(qualifiers = "en")
fun localizedEnglishHello() {
val activity = Robolectric.setupActivity(MainActivity::class.java)
assertEquals(activity.helloTextView.text.toString(), "Hello")
}
@Test
@Config(qualifiers = "ko")
fun localizedKoreanHello() {
val activity = Robolectric.setupActivity(MainActivity::class.java)
assertEquals(activity.helloTextView.text.toString(), "안녕하세요")
}
참고
Related Posts
- Android 14 - 사진/동영상 파일, 일부 접근 권한 소개
- Android - adb push, pull로 파일 복사, 다운로드
- Android 14 - 암시적 인텐트 변경사항 및 문제 해결
- Jetpack Compose - Row와 Column
- Android 13, AOSP 오픈소스 다운로드 및 빌드
- Android 13 - 세분화된 미디어 파일 권한
- Android 13에서 Notification 권한 요청, 알림 띄우기
- Android 13에서 'Access blocked: ComponentInfo' 에러 해결
- 에러 해결: android gradle plugin requires java 11 to run. you are currently using java 1.8.
- 안드로이드 - 코루틴과 Retrofit으로 비동기 통신 예제
- 안드로이드 - 코루틴으로 URL 이미지 불러오기
- Android - 진동, Vibrator, VibrationEffect 예제
- Some problems were found with the configuration of task 에러 수정
- Query method parameters should either be a type that can be converted into a database column or a List
- 우분투에서 Android 12 오픈소스 다운로드 및 빌드
- Android - ViewModel을 생성하는 방법
- Android - Transformations.map(), switchMap() 차이점
- Android - Transformations.distinctUntilChanged() 소개
- Android - TabLayout 구현 방법 (+ ViewPager2)
- Android - 휴대폰 전화번호 가져오는 방법
- Android 12 - Splash Screens 알아보기
- Android 12 - Incremental Install (Play as you Download) 소개
- Android - adb 명령어로 bugreport 로그 파일 추출
- Android - adb 명령어로 App 데이터 삭제
- Android - adb 명령어로 앱 비활성화, 활성화
- Android - adb 명령어로 특정 패키지의 PID 찾기
- Android - adb 명령어로 퍼미션 Grant 또는 Revoke
- Android - adb 명령어로 apk 설치, 삭제
- Android - adb 명령어로 특정 패키지의 프로세스 종료
- Android - adb 명령어로 screen capture 저장
- Android - adb 명령어로 System 앱 삭제, 설치
- Android - adb 명령어로 settings value 확인, 변경
- Android 12 - IntentFilter의 exported 명시적 선언
- Android - adb 명령어로 공장초기화(Factory reset)
- Android - adb logcat 명령어로 로그 출력