이번 영상에서는 Instrumented Unit Test를 작성하는 방법에 대해 알아보도록 하겠습니다.
안드로이드 프레임워크가 연관되는 모듈은 JVM 위에서는 실행할 수가 없습니다. 따라서 테스트를 에뮬레이터나 실 기기 위에서 수행해야 하는데 이것을 Instrumented Unit Test라고 합니다. 여기서는 예제로 MainActivity를 생성한 뒤 Lifecycle 상태를 확인하는 Instrumented Test를 해 보겠습니다.
우선은 테스트에 필요한 디펜던시를 추가해 줍니다.
dependencies {
androidTestImplementation("androidx.test:core:1.4.0") // Test Core
androidTestImplementation("androidx.test.ext:truth:1.4.0")
androidTestImplementation("androidx.test:runner:1.4.0")
}
다음은 MainActivity 클래스로부터 테스트를 작성하는데, 안드로이드 SDK를 사용해야 하기 때문에 파일은 androidTest 디렉토리에 저장합니다. 그리고 테스트를 실행할 테스트 런너를 @RunWith로 지정해 주는데 여기서는 AndroidJUnitRunner로 지정합니다.
테스트를 하기 위해서는 액티비티를 생성해야 하는데 Test Core 디펜던시의 ActivityScenario를 사용하면 테스트용 액티비티를 생성할 수 있습니다. setUp에서 MainActivity를 생성하여 테스트를 수행한 뒤, tearDown에서 파괴하면 됩니다. 이번 테스트에서는 실행된 액티비티의 상태가 RESUMED인지 확인하는 단순한 코드를 정의하겠습니다.
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
private lateinit var activityScenario: ActivityScenario<MainActivity>
@Before
fun setUp() {
activityScenario = ActivityScenario.launch(MainActivity::class.java)
}
@After
fun tearDown() {
activityScenario.close()
}
@Test
@SmallTest
fun test_Activity_State() {
val activityState = activityScenario.state.name
assertThat(activityState).isEqualTo("RESUMED")
}
}
그런데 테스트를 작성할 때마다 ActivityScenario를 launch하고 close하는 코드를 쓰는것도 귀찮기 때문에 구글은 ActivityScenarioRule 클래스를 만들었습니다. 코드를 다음과 같이 작성하면 Junit의 Rule 규칙에 의해 매 테스트마다 ActivityScenario가 자동으로 생성되고 파괴됩니다.
class MainActivityTest {
- private lateinit var activityScenario: ActivityScenario<MainActivity>
- @Before
- fun setUp() {
- activityScenario = ActivityScenario.launch(MainActivity::class.java)
- }
- @After
- fun tearDown() {
- activityScenario.close()
- }
+ @get:Rule
+ var activityScenarioRule: ActivityScenarioRule<MainActivity> =
+ ActivityScenarioRule(MainActivity::class.java)
@Test
@SmallTest
fun test_Activity_State() {
+ val activityState = activityScenarioRule.scenario.state.name
assertThat(activityState).isEqualTo("RESUMED")
}
}
Instrumented Test는 반드시 필요한 테스트이긴 하나, 에뮬레이터나 기기 위에서 실행되어야 하기 때문에 속도가 느립니다. 이 문제를 개선하기 위해 개발된 것이 Roboletric 입니다. Roboletric은 Shadow라는 이름으로 Android SDK의 테스트 더블을 구현해 주기 때문에 안드로이드 프레임워크를 사용해야 하는 테스트를 JVM 위에서 실행할 수 있습니다.
Roboletric을 사용하기 위해 build.gradle에 다음 디펜던시를 추가해 줍니다.
android {
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
dependencies {
testImplementation("org.robolectric:robolectric:4.8.1")
}
그리고 다음과 같이 테스트를 작성하는데요, Roboelectric이 적용된 테스트는 JVM 위에서 돌아가는 Local Unit Test가 되었기 때문에 테스트 파일을 unitTest 디렉토리에 저장합니다. 테스트 런너는 RobolectricTestRunner로 지정하고 Robolectric.setupActivity로 액티비티를 생성해줍니다.
@RunWith(RobolectricTestRunner::class)
class MainActivityTest {
@Test
@SmallTest
fun test_Activity_State() {
val controller = Robolectric.setupActivity(MainActivity::class.java)
val activityState = controller.lifecycle.currentState.name
assertThat(activityState).isEqualTo("RESUMED")
}
}
그런데 중요한 점은 Roboletric은 4.0으로 버전업 되면서 Androidx 패키지에 통합이 되었습니다. 따라서 런너를 AndroidJUnit4로 지정하면 알아서 Roboletric으로 테스트를 수행하게 됩니다.

다시말해 다음과 같이 디펜던시를 추가하면 위에 보여드렸던 Instrumented Test는 Roboletric이 커버하는 SDK 이용에 대해서는 완전히 동일한 코드로 Local Unit Test에서 수행할 수 있게 됩니다.
dependencies {
testImplementation("androidx.test.ext:junit:1.1.3")
testImplementation("androidx.test:core:1.4.0")
}
Instrumented Unit Test의 기초적인 작성 방법에 대해 알아보았습니다. 그럼 이번엔 북 서치 앱의 Room 데이터베이스의 동작에 대한 계측 테스트를 만들어 보도록 하겠습니다.
데이터베이스의 조작을 주관하는 것은 BookSearchDao 클래스이니 BookSearchDaoTest라는 테스트 클래스를 작성합니다. 일반적으로 테스트의 패키지 구조는 앱 패키지 구조와 동일하게 하기 때문에 androidTest 아래의 data/db 패키지에 파일을 만들고 런너와 테스트 스케일을 지정해 주겠습니다.
테스트에서 BookSearchDatabase와 BookSearchDao 인스턴스를 사용해야 하기 때문에, 테스트 간 Isolation을 고려해서 @Before에서는 DB를 생성하고, @After에서 사용한 DB를 파기하도록 했습니다.
@RunWith(AndroidJUnit4::class)
@SmallTest
class BookSearchDaoTest {
private lateinit var database: BookSearchDatabase
private lateinit var dao: BookSearchDao
@Before
fun setUp() {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
BookSearchDatabase::class.java
).allowMainThreadQueries().build()
dao = database.bookSearchDao()
}
@After
fun tearDown() {
database.close()
}
}
DB는 inMemoryDatabaseBuilder를 사용해서 메모리에서만 생성하고 테스트가 끝나면 tearDown에서 파기하게 했습니다. 그리고 Room은 ANR을 방지하기 위해 메인스레드에서의 쿼리를 금지하고 있는데, DB에 대한 쿼리를 멀티스레드에서 수행하면 테스트 결과를 예측할 수 없기 때문에, allowMainThreadQueries를 이용해서 메인스레드에서 쿼리를 사용할 수 있도록 합니다.
다음은 insertBook 메소드를 테스트 하겠습니다. insertBook은 suspend 함수이므로 코루틴 내부에서 실행해야 합니다. 코루틴 테스트를 위해서 다음 디펜던시를 추가합니다.
dependencies {
androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2")
}
그리고 테스트를 작성합니다. 코루틴 테스트는 runTest 블록 내부에서 실행하는데요, runTest의 경고를 없애기 위해 클래스에 @ExperimentalCoroutinesApi를 붙여줍니다.
블럭 안에서 가짜 Book 인스턴스를 하나 만들고 dao를 통해 DB에 추가합니다. 그리고 getFavoriteBooks로 DB 값을 반환받습니다. 반환값은 Flow형이기 때문에 first를 붙여서 value로 변환하고 book과 내용이 일치하는지를 확인하면 됩니다.
만약 반환값이 LiveData일땐 LiveDataTestUtil을 써서 값을 추출하고, instantTaskExecutorRule을 추가해서 비동기적으로 구성된 작업이 같은 스레드에서 순서대로 실행되도록 강제해야하는 귀찮음이 있지만 Flow를 사용하면 편하게 테스트를 할 수 있습니다.
@Test
fun insert_book_to_db() = runTest {
val book = Book(
listOf("a"), "b", "c", "d", 0, "e",
0, "f", "g", "h", listOf("i"), "j"
)
dao.insertBook(book)
val favoriteBooks = dao.getFavoriteBooks().first()
assertThat(favoriteBooks).contains(book)
}
다음은 deleteBook 메소드를 테스트하기 위해 다음과 같이 테스트를 작성해 줍니다. book 인스턴스를 저장했다 삭제한 뒤 doesNotContain으로 값이 존재하지 않는지를 확인하면 되겠죠.
@Test
fun delete_book_in_db() = runTest {
val book = Book(
listOf("a"), "b", "c", "d", 0, "e",
0, "f", "g", "h", listOf("i"), "j"
)
dao.insertBook(book)
dao.deleteBook(book)
val favoriteBooks = dao.getFavoriteBooks().first()
assertThat(favoriteBooks).doesNotContain(book)
}
지금까지 MainActivityTest와 BookSearchDaoTest를 만들어 보았습니다. 이런 테스트 클래스들은 개별로 실행할 수 있지만 자동 테스트인 만큼 한번에 실행시킬 수 있다면 편하겠죠. Junit의 Suite Test 기능을 사용하면 여러 테스트를 묶어서 실행할 수 있습니다.
다음과 같이 InstrumentedTestSuite를 만들고 테스트 런너는 Suite로 지정합니다. 그리고 테스트 클래스를 실행하고 싶은 순서대로 @Suite.SuiteClasses안에 넣어주면 Suite 클래스가 테스트 묶음을 실행해주게 됩니다.
@RunWith(Suite::class)
@ExperimentalCoroutinesApi
@Suite.SuiteClasses(
MainActivityTest::class,
BookSearchDaoTest::class,
)
class InstrumentedTestSuite
이렇게 해서 Instrumented Unit Test에 대해 알아보았습니다.