์ปดํฌ์ฆ ์ฑ ํ ์คํธํ๊ธฐ: ViewModel ๋จ์ ํ ์คํธ + Compose UI ํ ์คํธ
To-do ์ฑ์ ์ฌ๊ธฐ๊น์ง ๋๊ณ ์์ผ๋ฉด ์ด์ ๊ธฐ๋ฅ์ ๋ค ์๋ค. ViewModel๋ ์๊ณ , Room๋ ๋ถ์๊ณ , ๋คํธ์ํฌ๋ ๋๊ณ , ํ๋ฉด๋ ํฐ/ํ๋ธ๋ฆฟ ๋ค ๋์ํ๋ค.
๊ทธ๋ฐ๋ฐ ์๊ฐํด๋ณด์. ์ด ์ฑ์ ๋ค์ ์ฃผ์ ๋ค์ ๊ณ ์น๋ฉด ์ด๋ป๊ฒ ๋ ๊น? addTodo ๋ก์ง์ ์ด์ง ๋ฐ๊ฟจ๋๋ฐ ๋ค๋ฅธ ๋ฐ์ ๋ญ๊ฐ ๊นจ์ก๋์ง ์ด๋ป๊ฒ ์์? ๋งค๋ฒ ์ฑ ์ผ์ ๋ฒํผ ๋๋ฌ๋ณด๊ณ ํ์ธํ ๊ฑฐ์ผ?
๊ทธ๊ฒ ์ซ์ด์ ํ ์คํธ๋ฅผ ์ง ๋ค. ์ค๋์ ๋ ๊ฐ์ง๋ง ๋ณธ๋ค. ViewModel ๋ก์ง์ ์ฝ๋๋ก ํ ์คํธํ๋ ๋ฒ, ๊ทธ๋ฆฌ๊ณ ํ๋ฉด์ด ์ ๋๋ก ๊ทธ๋ ค์ง๊ณ ํด๋ฆญ์ด ๋๋์ง ํ ์คํธํ๋ ๋ฒ.
ํ ์คํธ๋ฅผ ์ ๋ ์ข ๋ฅ๋ก ๋๋๋๊ฐ
์ฑ์์ ํ ์คํธํ ๊ฒ ํฌ๊ฒ ๋ ๊ตฐ๋ฐ๋ค.
- "ํ ์ผ์ ์ถ๊ฐํ๋ฉด ๋ชฉ๋ก์ 1๊ฐ๊ฐ ๋์ด๋๋ค" โ ์ด๊ฑด ์์ํ ๋ก์ง. ํ๋ฉด ์์ด๋ ํ์ธ ๊ฐ๋ฅ. โ ViewModel ๋จ์ ํ ์คํธ
- "ํ๋ฉด์ '์ถ๊ฐ' ๋ฒํผ์ด ๋ณด์ด๊ณ , ๋๋ฅด๋ฉด ์ ๋ ฅํ ํ ์คํธ๊ฐ ๋ชฉ๋ก์ ๋ํ๋๋ค" โ ์ด๊ฑด ์ค์ ๋ก ๊ทธ๋ ค์ง ํ๋ฉด์ด ํ์ํจ. โ Compose UI ํ ์คํธ
์ด ๋์ ๋์๊ฐ๋ ๋ฐฉ์๋ถํฐ ๋ค๋ฅด๋ค. ๋จ์ ํ ์คํธ๋ ๊ทธ๋ฅ JVM์์ ๋๋ค. UI ํ ์คํธ๋ (๊ฐ์์ด๋ ์ค์ ๋ ) ์๋๋ก์ด๋ ๊ธฐ๊ธฐ๊ฐ ์์ด์ผ ๋๋ค. ๊ทธ๋์ ํด๋๋ ๋ค๋ฅด๋ค.
app/src/test/โ ViewModel ํ ์คํธ, JVM์์ ๋น ๋ฅด๊ฒ ๋app/src/androidTest/โ Compose UI ํ ์คํธ, ๊ธฐ๊ธฐ/์๋ฎฌ๋ ์ดํฐ ํ์
1. ViewModel ํ ์คํธ๋ถํฐ: ์ฒ์ ๋๋ฆฌ๋ฉด ๋ฐ๋ก ํฐ์ง๋ค
TodoViewModel์ addTodo("๋นจ๋ํ๊ธฐ")๋ฅผ ํธ์ถํ๊ณ uiState.value.todoList์ 1๊ฐ๊ฐ ๋ค์ด์๋์ง ํ์ธํ๋ ํ
์คํธ๋ฅผ ์ง๋ณธ๋ค๊ณ ํ์. ์ ์ผ ๋จ์ํ๊ฒ ์ง๋ฉด ์ด๋ ๊ฒ ๋ ๊ฑฐ๋ค.
@Test
fun `ํ ์ผ์ ์ถ๊ฐํ๋ฉด ๋ชฉ๋ก์ด ๋์ด๋๋ค`() {
val repository = FakeTodoRepository()
val viewModel = TodoViewModel(repository)
viewModel.addTodo("๋นจ๋ํ๊ธฐ")
assertEquals(1, viewModel.uiState.value.todoList.size)
}๋๋ ค๋ณด๋ฉด ๋ฐ๋ก ์ด๋ฐ ์๋ฌ๊ฐ ๋ฌ๋ค.
IllegalStateException: Module with the Main dispatcher had failed to initialize.์ ์ด๊ฒ ํฐ์ง๊น. TodoViewModel ์์์ uiState๋ stateIn(viewModelScope, ...)๋ก ๋ง๋ค์๋ค. viewModelScope๋ ๋ด๋ถ์ ์ผ๋ก Dispatchers.Main์ ์ฐ๋๋ฐ, ํ
์คํธ๋ ์๋๋ก์ด๋ ๊ธฐ๊ธฐ ์๊ฐ ์๋๋ผ ๊ทธ๋ฅ JVM์์ ๋๋ค. JVM์๋ ์๋๋ก์ด๋์ "๋ฉ์ธ ์ค๋ ๋"๋ผ๋ ๊ฒ ์์ผ๋๊น, Dispatchers.Main์ ์ฐ๋ ค๋ ์๊ฐ "๊ทธ๋ฐ ๊ฑฐ ์๋ค"๋ฉด์ ํฐ์ง๋ ๊ฑฐ๋ค.
๊ณ ์น๊ธฐ: ํ ์คํธ์ฉ Main ๋์คํจ์ฒ๋ฅผ ๋ผ์๋ฃ๊ธฐ
์ด๋ด ๋ ์ฐ๋ผ๊ณ ์ฝ๋ฃจํด ์ชฝ์์ kotlinx-coroutines-test ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ ๊ณตํ๋ค. Dispatchers.setMain()์ผ๋ก ํ
์คํธ ์ ์ฉ ๋์คํจ์ฒ๋ฅผ Main ์๋ฆฌ์ ๋ผ์๋ฃ์ผ๋ฉด ๋๋ค.
// build.gradle.kts (app) - testImplementation
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
testImplementation("junit:junit:4.13.2")@OptIn(ExperimentalCoroutinesApi::class)
class TodoViewModelTest {
private val testDispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `ํ ์ผ์ ์ถ๊ฐํ๋ฉด ๋ชฉ๋ก์ด ๋์ด๋๋ค`() = runTest {
val repository = FakeTodoRepository()
val viewModel = TodoViewModel(repository)
viewModel.addTodo("๋นจ๋ํ๊ธฐ")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.todoList.size)
}
}ํ๋์ฉ ๋ณด์.
Dispatchers.setMain(testDispatcher): "Main ๋์คํจ์ฒ ๋ฌ๋ผ๊ณ ํ๋ฉด ์ด๊ฑธ ์ค"๋ผ๊ณ ํ ์คํธ ์์ ์ ์ ๋ฑ๋กํด๋๋ ๊ฑฐ๋ค.@Before์ ๋๋ฉด ํ ์คํธ ํ๋ํ๋ ๋ ๋๋ง๋ค ๋งค๋ฒ ๋ค์ ์ค์ ๋จ.runTest { ... }: ์ฝ๋ฃจํด์ ์ฐ๋ ํ ์คํธ ๋ณธ๋ฌธ์ ๊ฐ์ธ๋ ํจ์. ์์์ ์ฐ๋ ๊ฐ์ ์๊ฐ์ ์ ์ดํ ์ ์๊ฒ ํด์ค๋ค.testDispatcher.scheduler.advanceUntilIdle():addTodo๊ฐ ๋ด๋ถ์ ์ผ๋ก ์ฝ๋ฃจํด์ ์คํํด์ DB์ ์ ์ฅํ๊ณ Flow๊ฐ ๊ฐฑ์ ๋๊ธฐ๊น์ง๋ ์๊ฐ์ด ๊ฑธ๋ฆฐ๋ค. ์ด ํ ์ค์ด "๋๊ธฐ ์ค์ธ ์ฝ๋ฃจํด ์์ ์ ๋ค ๋๋ผ ๋๊น์ง ๊ฐ์ ์๊ฐ์ ๋๋ ค์ค"๋ผ๋ ๋ป์ด๋ค. ์ด๊ฒ ์์ผ๋ฉดuiState๊ฐ ์์ง ์ ๋ฐ์ดํธ๋๊ธฐ ์ ์assertEquals๊ฐ ๋จผ์ ์คํ๋ผ์ ํ ์คํธ๊ฐ ์คํจํ ์ ์๋ค.
์ง์ง Repository ๋์ Fake๋ฅผ ์ฐ๋ ์ด์
์ ์ฝ๋์์ FakeTodoRepository()๋ฅผ ์ผ๋ค. ์ค์ TodoRepository๋ Room DB๋ Retrofit API๋ฅผ ๋ค๊ณ ์๋๋ฐ, ๋จ์ ํ
์คํธ์์ ์ง์ง DB๋ฅผ ๋ถ์ด๋ฉด ๋๋ ค์ง๊ณ , ๋คํธ์ํฌ๋ฅผ ๋ถ์ด๋ฉด ํ
์คํธ๊ฐ ์ธํฐ๋ท ์ํ์ ๋ฐ๋ผ ์คํจํ๊ธฐ๋ ํ๋ค. ๊ทธ๋์ ํ
์คํธ์์๋ ๋ฉ๋ชจ๋ฆฌ์ ๋ฆฌ์คํธ ํ๋๋ง ๋ค๊ณ ํ๋ด๋ง ๋ด๋ ๊ฐ์ง Repository๋ฅผ ๋ง๋ค์ด ์ด๋ค.
class FakeTodoRepository : TodoRepository {
private val todos = MutableStateFlow<List<Todo>>(emptyList())
override val todoList: Flow<List<Todo>> = todos
override suspend fun addTodo(title: String) {
todos.value = todos.value + Todo(id = todos.value.size + 1, title = title)
}
override suspend fun toggleDone(todo: Todo) {
todos.value = todos.value.map {
if (it.id == todo.id) it.copy(isDone = !it.isDone) else it
}
}
override suspend fun deleteTodo(todo: Todo) {
todos.value = todos.value.filter { it.id != todo.id }
}
}์ด๊ฒ ๋๋ ค๋ฉด TodoRepository๊ฐ ํด๋์ค๊ฐ ์๋๋ผ ์ธํฐํ์ด์ค์ฌ์ผ ํ๋ค. ์ง๊ธ๊น์ง๋ ํด๋์ค๋ก ์ผ๋ค๋ฉด, ์ด ์์ ์์ ์ธํฐํ์ด์ค๋ก ๋ฝ์๋ด๊ณ ์ค์ ๊ตฌํ(TodoRepositoryImpl)๊ณผ ๊ฐ์ง ๊ตฌํ(FakeTodoRepository)์ผ๋ก ๋๋๋ ์์
์ด ํ ๋ฒ ํ์ํ๋ค. ํ
์คํธํ๊ธฐ ์ข์ ๊ตฌ์กฐ๋ก ๋ฐ๋๋ ๊ณผ์ ์ด๋ผ๊ณ ๋ณด๋ฉด ๋๋ค.
2. Compose UI ํ ์คํธ: ํ๋ฉด์ด ๊ทธ๋ ค์ง๋์ง ์ง์ ํ์ธ
์ด๋ฒ์ TodoListScreen์ "์ถ๊ฐ" ๋ฒํผ์ ๋๋ฅด๋ฉด ์
๋ ฅํ ํ
์คํธ๊ฐ ๋ชฉ๋ก์ ๋ํ๋๋์ง ํ์ธํด๋ณด์. ์ด๊ฑด app/src/androidTest/์ ๋๋ค.
// build.gradle.kts (app) - androidTestImplementation
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.12.1")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.12.1")class TodoListScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun ํ ์ผ์_์ถ๊ฐํ๋ฉด_๋ชฉ๋ก์_๋ํ๋๋ค() {
var todoList = listOf<Todo>()
composeTestRule.setContent {
var list by remember { mutableStateOf(listOf<Todo>()) }
TodoListScreen(
todoList = list,
onAdd = { title ->
list = list + Todo(id = list.size + 1, title = title)
},
onToggleDone = {},
onDelete = {},
onTodoClick = {}
)
}
composeTestRule.onNodeWithText("ํ ์ผ์ ์
๋ ฅํ์ธ์").performTextInput("์ฐ์ ์ฌ๊ธฐ")
composeTestRule.onNodeWithText("์ถ๊ฐ").performClick()
composeTestRule.onNodeWithText("์ฐ์ ์ฌ๊ธฐ").assertExists()
}
}์ฌ๊ธฐ์ ์ฒ์ ๋ณด๋ ๊ฒ composeTestRule์ด๋ค.
createComposeRule(): ํ ์คํธ ์์์ ์ปดํฌ์ฆ ํ๋ฉด์ ๊ทธ๋ฆด ์ ์๊ฒ ํด์ฃผ๋ ๊ท์น.@get:Rule์ ๋ถ์ด๋ฉด ํ ์คํธ๋ง๋ค ์๋์ผ๋ก ์ค๋น๋๊ณ ์ ๋ฆฌ๋๋ค.composeTestRule.setContent { ... }:MainActivity์setContent๋ ๋๊ฐ์ ์ญํ ์ธ๋ฐ, ํ ์คํธ์ฉ ๊ฐ์ง ํ๋ฉด์ ๊ทธ๋ฆฌ๋ ๊ฑฐ๋ค.onNodeWithText("์ถ๊ฐ"): ํ๋ฉด์์ "์ถ๊ฐ"๋ผ๋ ํ ์คํธ๋ฅผ ๊ฐ์ง ๋ ธ๋๋ฅผ ์ฐพ๋๋ค. ๋ฒํผ์ด๋ ํ ์คํธ๋ ์๊ด์์ด, ํ๋ฉด์ ๋ณด์ด๋ ๊ธ์๋ก ์ฐพ๋๋ค..performClick()/.performTextInput(...): ์ฐพ์ ๋ ธ๋๋ฅผ ํด๋ฆญํ๊ฑฐ๋ ํ ์คํธ๋ฅผ ์ ๋ ฅํ๋ "์ฌ๋์ด ํ๋ ํ๋"์ ์ฝ๋๋ก ํ๋ด๋ธ๋ค..assertExists(): ๊ทธ ๋ ธ๋๊ฐ ํ๋ฉด์ ์ค์ ๋ก ์กด์ฌํ๋์ง ํ์ธํ๋ค. ์์ผ๋ฉด ํ ์คํธ ์คํจ.
์ด ํ
์คํธ๊ฐ ์ข์ ์ ์, ViewModel์ด๋ Repository ์์ด TodoListScreen๋ง ๋ ๋ผ์ ํ
์คํธํ๋ค๋ ๊ฑฐ๋ค. 12๋ฒ ๊ธ์์ "์ํ ํธ์ด์คํ
"์ผ๋ก TodoListScreen์ stateless๋ก ๋ง๋ค์ด๋ ๊ฒ ์ฌ๊ธฐ์ ๊ทธ๋๋ก ๋ณด์๋ฐ๋๋ค. ํ๋ผ๋ฏธํฐ๋ก ๊ฐ์ ๋ฐ๊ณ ์ฝ๋ฐฑ์ผ๋ก ์ด๋ฒคํธ๋ฅผ ๋ด๋ณด๋ด๋ ๊ตฌ์กฐ๋ผ์, ๊ฐ์ง ์ํ(remember { mutableStateOf(...) })๋ง ํ๋ ๋ง๋ค์ด์ฃผ๋ฉด ViewModel ์์ด๋ ํ๋ฉด๋ง ๋
๋ฆฝ์ ์ผ๋ก ํ
์คํธํ ์ ์๋ค.
์ฝ์ง ์ฌ๋ก: onNodeWithText๊ฐ ์๊พธ ๋ชป ์ฐพ๋๋ค๊ณ ํ๋ค
AssertionError: Failed to find a node for text: ์ฐ์ ์ฌ๊ธฐํํ ์์ธ ๋ ๊ฐ์ง.
- ํ์ด๋ฐ:
performClick()์งํ ๋ฐ๋กassertExists()๋ฅผ ๋ถ๋ฅด๋ฉด, ์์ง ๋ฆฌ์ปดํฌ์ง์ ์ด ๋๋๊ธฐ ์ ์ผ ์ ์๋ค. ๋ณดํต์composeTestRule์ด ์๋์ผ๋ก ๊ธฐ๋ค๋ ค์ฃผ์ง๋ง(idling resource), ์ฝ๋ฃจํด์ ์ง์ ๋๋ฆฌ๋ ์ฝ๋๊ฐ ์์ฌ ์์ผ๋ฉด ๊ฐํน ์ ๋ง์ ๋๊ฐ ์๋ค. ๊ทธ๋ด ๋composeTestRule.waitForIdle()์ ์ค๊ฐ์ ๋ฃ์ด๋ณธ๋ค. - ํ
์คํธ๊ฐ ์๋ ค์ ์ ๋ณด์: ๊ธด ํ
์คํธ๋ ํ๋ฉด์
...๋ก ์๋ ค์ ์ค์ ๋ ธ๋ ํ ์คํธ๊ฐ ๋ค๋ฅผ ์ ์๋ค.onNodeWithText("์ฐ์ ", substring = true)์ฒ๋ผ ๋ถ๋ถ ์ผ์น๋ก ์ฐพ์ผ๋ฉด ํด๊ฒฐ๋๋ค.
์ ๋ฆฌ
- ViewModel ํ
์คํธ๋
app/src/test/์, Compose UI ํ ์คํธ๋app/src/androidTest/์ ๋๋ค. ์ ์๋ JVM์์, ํ์๋ ๊ธฐ๊ธฐ/์๋ฎฌ๋ ์ดํฐ์์ ๋๋ค. - ViewModel์ด
viewModelScope(=Dispatchers.Main)๋ฅผ ์ฐ๋ฉด ํ ์คํธ์์ ๋ฐ๋ก ํฐ์ง๋๊น,kotlinx-coroutines-test์Dispatchers.setMain()+runTest+advanceUntilIdle()๋ก ๊ฐ์ง Main ๋์คํจ์ฒ๋ฅผ ๋ผ์๋ฃ์ด์ผ ํ๋ค. - ์ง์ง DB/๋คํธ์ํฌ๋ฅผ ๋ Repository ๋์ , ์ธํฐํ์ด์ค๋ฅผ ๋ฝ์ ๋ฉ๋ชจ๋ฆฌ๋ก ํ๋ด ๋ด๋ Fake ๊ตฌํ์ ํ ์คํธ์์ ์ด๋ค.
- Compose UI ํ
์คํธ๋
createComposeRule()+onNodeWithText+performClick/performTextInput์ผ๋ก ํ๋ฉด์ ์ฌ๋์ฒ๋ผ ์กฐ์ํ๊ณ ํ์ธํ๋ค. - stateless ์ปดํฌ์ ๋ธ๋ก ์ง๋ ํ๋ฉด์ ViewModel ์์ด๋ UI ํ ์คํธ๊ฐ ์ฝ๋ค. 8๋ฒ ๊ธ์ ์ํ ํธ์ด์คํ ์ด ์ฌ๊ธฐ์ ๋ค์ ์ฐ์ธ๋ค.
๋ค์ ๊ธ์์๋ ์ด ์ฑ์ ์ค์ ๋ก Play ์คํ ์ด์ ์ฌ๋ฆฌ๋ ๊ณผ์ ์ ๋ณธ๋ค. ์๋ช ํค๋ ์ ๋ ์ข ๋ฅ๊ฐ ์๋์ง, AAB๋ ๋ญ์ง, Play Console์ ์ฒ์ ๋ฑ๋กํ ๋ ๋ญ ์ค๋นํด์ผ ํ๋์ง ์ ๋ฆฌํ๋ค.
