๐ ๏ธ ViewModel ํ ์คํธ ์๋ํ + @Before @After ์ด๋ ธํ ์ด์ ์ ๊ฑฐ ๋ฆฌํํ ๋ง ๊ณผ์ ๐ก
GDSC Konkuk ์๋๋ก์ด๋ ์คํฐ๋ 5์ฃผ์ฐจ ๊ณผ์ ๋ ์์จ ๊ณผ์ ์ด๋ค.
๊ฐ์ธ๋ง๋ค ํ๊ณ ์ถ์๋ ๊ฒ, ์ถ๊ฐํ๊ณ ์ถ์ ๊ฒ, ๋ฆฌํํ ๋ง ํ๊ณ ์ถ์ ๊ฒ๋ค์ ๋ํด ์์ ๋กญ๊ฒ ์ ์ฉํด๋ณด๊ณ ์ ๋ฆฌํ๋ฉด ๋๊ธฐ ๋๋ฌธ์ ๋๋ 5์ฃผ์ฐจ ๊ณผ์ ๋ก ๋ทฐ๋ชจ๋ธ์ Unit ํ ์คํธ ์ ์ฉ ๋ฐ GitAction์ ํตํ ์๋ ํ ์คํธ๋ฅผ ๊ณผ์ ๋ก ์งํํ๊ธฐ๋ ํ๋ค.
ํด๋น ๊ณผ์ ์์ ํ ์คํธ ์ฝ๋์ ์ง์ ์ ์ธ ์ฐ๊ด์ด ์๋ ๋ก์ง๊ณผ ๊ฐ์ฒด ๊ด๋ฆฌ์ ๋ํ ์ฑ ์์ ๋ถ๋ฆฌํ ์ ์๋ ๋ฐฉ๋ฒ์ ๋ํด ๊ณ ๋ฏผํด๋ณด์๊ณ ๋ด๊ฐ ์ ํํ ๋ฐฉ๋ฒ์ ์ถ๊ฐ์ ์ผ๋ก ์ ์ด๋ณด์๋ค.
์ ๊ฐ ์ ํํ ๋ฐฉ๋ฒ์ ์ ๋ต์ด ์๋๋ฉฐ ์ธ์ ๋ ํผ๋๋ฐฑ ์ฃผ์๋ฉด ๊ฐ์ฌํ๊ฒ ์ต๋๋ค.
viewModel ์์์ ์ฌ์ฉํ๋ ๋ก์ง์ ๋๋ถ๋ถ Flow๋ฅผ ์ฌ์ฉํด ์ํ๋ฅผ ๋ณด๊ดํ๊ธฐ ๋๋ฌธ์ ์๋์ ๊ฐ์ด kotlinx-coroutines-test ์์กด์ฑ์ ์ถ๊ฐํด์ค์ผ ํ๋ค. Kotest ๋ฑ ์ฝํ๋ฆฐ์ผ๋ก ์์ฑ๋ ํ ์คํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ ์์ง๋ง ํ๋ก์ ํธ ์์ฑ ์ ๊ธฐ๋ณธ์ผ๋ก ์ถ๊ฐ๋์ด์๋ Junit์ ์ฌ์ฉํด ํ ์คํธ๋ฅผ ์งํํด๋ณด์.
์ถ๊ฐ์ ์ผ๋ก collect ์ฝ๋ฃจํด์ ๋ง๋๋ ํธ๋ฆฌํ API์ Flow๋ฅผ ํ ์คํธํ๋ ๊ธฐํ ํธ์ ๊ธฐ๋ฅ์ ์ ๊ณตํ๋ ์๋ํํฐ Turbine ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ ์ถ๊ฐํด์ฃผ์.
testImplementation("junit:junit:$junitVerison")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutineTestVersion")
testImplementation("app.cash.turbine:turbine:$turbineVersion")
ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํ ๋ ๊ฐ ํ ์คํธ๋ ์ค์ ๊ฐ์ฒด์ ์์กดํ๋ฉด ์๋๋ค ๋ฐ๋ผ์ ์ค์ ๊ฐ์ฒด๋ฅผ ๋์ ํ ์คํดํธ๋งจ ๊ฐ์ ์กด์ฌ๊ฐ ํ์ํ๋ฐ ์ด๋ฅผ ๋๋ธ์ด๋ผ๊ณ ๋ถ๋ฅธ๋ค.
๋ฐ๋ผ์ ๋ทฐ๋ชจ๋ธ์์ ์ฌ์ฉํ๋ ๋ ํฌ์งํ ๋ฆฌ๋ค์ ๋ํ Fake ๊ฐ์ฒด๋ฅผ ๋ง๋ค์ด์ผ ํ๋๋ฐ ๋๋ถ๋ถ Flow๋ฅผ ๋ฐํํ๊ธฐ ๋๋ฌธ์ ์ด๋ป๊ฒ Fake ๊ฐ์ฒด๋ฅผ ๋ง๋ค์ง ๊ณ ๋ฏผํ๊ณ ๊ณต์ ๋ฌธ์๋ฅผ ์ดํด ๋ณด์๋ค.
๊ณต์ ๋ฌธ์ ๋ด์ฉ ์ค ํ ์คํธ ์ค Flow ์์งํ๊ธฐ ํํธ์์ ๋ค๋ฃจ๋ ์์ ์ฝ๋๋ฅผ ๋ณด๊ณ ๋ต์ ์ป์ ์ ์์๋ค.
StateFlow ์ SharedFlow ๋ชจ๋ Flow ์ธํฐํ์ด์ค๋ฅผ ์์ํ๊ธฐ ๋๋ฌธ์ Fake ๊ฐ์ฒด ๋ด๋ถ์์ Shared, State Flow๋ฅผ ์ฌ์ฉํด ๋ฐ์ดํฐ๋ฅผ ๋ณด๊ดํ๊ณ Flow๋ก ํ์ ์บ์คํ ๋ฐํํด์ฃผ๋ฉด ๋๋ค.
class FakeTodoRepository : TodoRepository {
// ๋ด๋ถ์ ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ๋ณด๊ด
private val todos = MutableStateFlow(listOf<TodoItem>())
// ๋ฐํํ ๋ Flow๋ก ํ์
์ผ๋ก ๋ฐํ
override fun getTodos(): Flow<List<TodoItem>> = todos
override suspend fun setTodo(todoItem: TodoItem) {
todos.emit(todos.value.map { item -> if (item.id == todoItem.id) todoItem else item })
}
// ...
}
๊ทธ๋ฆฌ๊ณ ํ ์คํธ๋ฅผ ํ๋ ์ชฝ์์๋ ํด๋น ์ฝ๋๋ฅผ ์์งํด์ค์ผ ํ๋๋ฐ ๊ณต์๋ฌธ์์์ ์์ฑ๋ ์ฝ๋๋ ์๋์ ๊ฐ๋ค.
// Create an empty collector for the StateFlow
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.score.collect()
}
์ฃผ์: ์ด๋ฌํ ์ต์ ์ผ๋ก ๋ง๋ StateFlow๋ฅผ ํ ์คํธํ ๋๋ ํ ์คํธ ์ค์ ์์ง๊ธฐ๊ฐ ํ๋ ์ด์ ์์ด์ผ ํฉ๋๋ค. ๊ทธ๋ ์ง ์์ผ๋ฉด stateIn ์ฐ์ฐ์๋ ๊ธฐ๋ณธ ํ๋ฆ ์์ง์ ์์ํ์ง ์๊ณ StateFlow์ ๊ฐ์ ์ ๋ฐ์ดํธ๋์ง ์์ต๋๋ค.
ํ ์คํธ ๊ณผ์ ์์ suspend ํจ์ ์คํํ๊ธฐ ์ํด์๋ ์๋์ ๊ฐ์ runTest ์ฝ๋๋ฅผ ์คํํด์ค์ผ ํ๋ค.
์ด๋ ์ปจํ ์คํธ๋ฅผ EmptyCoroutineContext ๋ก ๋ฐ๋ ๋ถ๋ถ์ด ๋๋ฌธ์ dispatcher๋ฅผ ์ด๋ป๊ฒ ์ ํด์ค์ผ ํ ์ง ๊ณ ๋ฏผ์ด์์ง๋ง ์ผ๋จ ๊ณต์๋ฌธ์ ์ฝ๋๋ฅผ ๋ณด๋ฉฐ ๋ทฐ๋ชจ๋ธ ํ ์คํธ ์ฝ๋๋ฅผ ๋ง์ ์์ฑํด๋ณด์
public fun runTest(
context: CoroutineContext = EmptyCoroutineContext,
timeout: Duration = DEFAULT_TIMEOUT,
testBody: suspend TestScope.() -> Unit
): TestResult {
check(context[RunningInRunTest] == null) {
"Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details."
}
return TestScope(context + RunningInRunTest).runTest(timeout, testBody)
}
// ...
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `๋๋ค ์ฌ์ง์ ๋๋ ์ ๋ ๋๋คํ๊ฒ ์ฌ์ง URL์ด ๋ฐ์์ ์ง๋์ง`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
editViewModel.userPhoto.collect()
}
// When
editViewModel.setRandomPhoto()
// Then
assertEquals(FakePhotoRepository.RANDOM_URL, editViewModel.userPhoto.value)
}
// ...
EditViewModel ์์ ๋๋คํ ์ฌ์ง์ ๊ฐ์ ธ์ค๊ฒํ๋ ๋ถ๋ถ ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํด ์คํ์์ผ๋ณด๋ ์๋์ ๊ฐ์ ์๋ฌ๊ฐ ๋ฐ์ํ๋ค
Exception in thread "Test worker" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
๋ณด์ํ๋ ํ ์คํธ๊ฐ ์คํ๋๋ ์ฝ๋ฃจํด ์ปจํ ์คํธ ๋ฉ์ธ์ผ๋ก ์ง์ ํด์ฃผ์ง ์์์ ๋ชจ๋ ์ด๊ธฐํ์ ์คํจํ๋ค๋ ๊ฒ์ผ๋ก ํ๋จ๋๋ค.
์ค์ ๋ก EditViewModel ์์ ์ฌ์ฉํ๊ณ ์๋ userPhoto: StateFlow<String?> ๊ฐ์ฒด์ ์ ์ธ๋ถ๋ถ์ ๋ณด๋ฉด viewModelScope ๋ฅผ ์ฌ์ฉํด์ stateFlow๋ฅผ ๋ง๋๋๋ฐ ๋ทฐ๋ชจ๋ธ ์ค์ฝํ๋ ๊ธฐ๋ณธ์ ์ผ๋ก Dispatcher.Main.immediate ๋์คํ์ฒ๋ฅผ ์ฌ์ฉํ๊ธฐ ๋๋ฌธ์ ํ ์คํธ๊ฐ ์๋๋ ๊ฒ์ผ๋ก ์ดํด๋๋ค.
// EditViewModel.kt
val userPhoto = userRepository.userPhotoUrlFlow.stateIn(
scope = viewModelScope, // Dispatcher.Main.immediate ์ฌ์ฉ
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT),
initialValue = null,
)
์๋ฌ๋ฅผ ํด๊ฒฐํ๋ ์ฌ๋ฌ ๋ฐฉ๋ฒ์ค ๋ง์ด ์ฑํํ๊ณ ์๋ ๋ฐฉ๋ฒ์ ์ฐพ์ ์ฌ์ฉํด๋ณด์๋ค. ์๋์ ๊ฐ์ด TestWatcher() ํด๋์ค๋ฅผ ์์ํ ํด๋์ค๋ฅผ ๋ง๋ ๋ค.
์ด ํด๋์ค๋ ํ
์คํธ์ ์์๋ถ๋ถ๊ณผ ์ข
๋ฃ ์์ ์์ ๊ฐ๊ฐ starting, finished ์ฝ๋ฐฑ์ด ์คํ๋๊ฒ ํ ์ ์๊ธฐ ๋๋ฌธ์ ์ด ์์์ Dispatchers.setMain(testDispatcher)
, Dispatchers.resetMain()
ํจ์๋ฅผ ํธ์ถํด์ค๋ค.
๊ทธ๋ฆฌ๊ณ ํ
์คํธ ํด๋์ค ์์ MainDispatcherRule ๊ฐ์ฒด๋ฅผ ์์ฑํ๊ณ @get:Rule
์ด๋
ธํ
์ด์
์ ๋ถ์ฌ์ฃผ๊ณ ํ
์คํธ๋ฅผ ์ฌ์คํํ๋ฉด ์๋ฌ๊ฐ ๋ฐ์ํ์ง ์๊ฒ ๋๋ค.
// ํ
์คํธ ๋ฃฐ
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
// ํ
์คํธ ์ฝ๋
class EditViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
// ...
}
- @Before, @After ์ด๋ ธํ ์ด์ ์ฝ๋์ ๊ฐ์ ์ญํ ์ ํ์ง๋ง ๋ก์ง์ด ๋ถ์ฐ๋จ.
- ํ ์คํธ์ ์ง์ ์ ์ธ ์ฐ๊ด์ด ์๋ ๋ก์ง๊ณผ ๊ฐ์ฒด๋ค์ด ๊ณต๊ฐ๋จ.
์ด์ ์ ๋ง๋ค์๋ MainDispatcherRule ํด๋์ค์์ ์ฌ์ฉ๋ Dispatcher.resetMain() ํจ์์ ๊ตฌํ๋ถ๋ฅผ ๋ณด์์ ๋ ์๋์ ๊ฐ์ ์ฃผ์์ด ์์๋ค.
(...) and so should be used in tear down (@After) methods.
์ฆ, TestWatcher ํด๋์ค๋ ํ ์คํธ ์ ํ ๋์์ ์ค์ ํด์ค ์ ์๊ณ , ์ด๋ ํ ์คํธ ํด๋์ค ์์์ ์ฌ์ฉํ๋ @Before @After ์ด๋ ธํ ์ด์ ์ผ๋ก๋ ๊ฐ์ ์ญํ ์ ํ ์ ์๋ค.
๊ธฐ์กด ํ ์คํธ ์ฝ๋์์ ๊ฐ ํ ์คํธ๋ ๋ค๋ฅธ ํ ์คํธ๋ก๋ถํฐ ๋ ๋ฆฝ์ ์ด์ด์ผ ํ๊ธฐ ๋๋ฌธ์ @Before ์ด๋ ธํ ์ด์ ์ ๋ถ์ธ setUp() ํจ์๋ฅผ ์์ฑํด ๊ฐ์ฒด๋ค์ ์ด๊ธฐํํด์ค์ผ ํ๋ค.
ํ์ง๋ง ๊ฐ์ฒด๋ค์ด ์ ์ฐจ ๋ง์์ง๋ค๋ฉด ํ ์คํธ ํด๋์ค์์ ์ด๊ธฐํ, ๋ฉ๋ชจ๋ฆฌ ํด์ ๋ฑ ํ ์คํธ์ ์ง์ ์ ์ธ ๊ด๋ จ์ด ์๋ ์ฝ๋๋ค์ ์์ด ๋ง์์ง ์ ์๊ธฐ ๋๋ฌธ์ ํด๋น ์ฝ๋๋ค์ TestWatcher ํด๋์ค์ ์ญํ ์์ํ๊ธฐ๋ก ํ๋ค.
// ๊ธฐ์กด ํ
์คํธ ์ฝ๋
class EditViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var fakeUserRepository: UserRepository
private lateinit var fakePhotoRepository: PhotoRepository // ํ
์คํธ์ ์ฌ์ฉ๋์ง ์์ ๊ฐ์ฒด
private lateinit var setRandomPhotoUseCase: SetRandomPhotoUseCase // ํ
์คํธ์ ์ฌ์ฉ๋์ง ์์ ๊ฐ์ฒด
private lateinit var editViewModel: EditViewModel
// ํ
์คํธ์ ์ง์ ์ ์ธ ์ฐ๊ด์ด ์๋ ์ฌ์ ๋ก์ง
@Before
fun setUp() {
fakeUserRepository = FakeUserRepository()
fakePhotoRepository = FakePhotoRepository()
setRandomPhotoUseCase = SetRandomPhotoUseCase(fakePhotoRepository, fakeUserRepository)
editViewModel = EditViewModel(
fakeUserRepository,
setRandomPhotoUseCase,
)
}
@Test
fun test1() {
// ...
}
}
-
์ฑ ์ ๋ถ๋ฆฌ: ์ง์ ์ ์ธ ํ ์คํธ ์ด์ธ์ ์ฑ ์์ ํ ์คํธ ์ฝ๋์์ ๋ถ๋ฆฌ
๊ฐ์ฒด์ ์์ฑ์ด๋ ๋ฉ๋ชจ๋ฆฌ ๊ด๋ฆฌ ๊ฐ์ ์ฝ๋๋ค์ด ๋ง์์ ธ ํ ์คํธ ํด๋์ค๊ฐ ๋๋ ํ๋๋ ๊ฒ์ ๋ฐฉ์งํ ์ ์์
-
์บก์ํ: ์ค์ ๋ก ์ฌ์ฉ๋์ง ์๊ณ ์์ฑ์๋ง ํ์ํ fake ๊ฐ์ฒด๋ฅผ ๊ฐ์ถค
fakePhotoRepository, setRandomPhotoUseCase ๊ฐ์ด ๋ทฐ๋ชจ๋ธ ์์ฑ์์ ํ์ํ์ง๋ง ์ง์ ์ ๊ทผํ ํ์๊ฐ ์๋ ๊ฐ์ฒด๋ค์ private ํ๊ฒ ๊ฐ์ถ ์ ์๋ค.
// ์ฑ
์ ๋ถ๋ฆฌ ๋ฆฌํํ ๋ง ํ
์คํธ ์ฝ๋
class EditViewModelTest {
@get:Rule
val editViewModelTestRule = EditViewModelTestRule()
@Test
fun test1() = with(editViewModelTestRule){
// ...
}
}
// ํ
์คํธ ๋ฃฐ
// MainDispatcherRule์ ์์ํด์ ๊ธฐ์กด ๋์(setMain)์ ์ ์ง
class EditViewModelTestRule : MainDispatcherRule() {
lateinit var editViewModel: EditViewModel // ํ
์คํธ์ฝ๋์ ๊ณต๊ฐ ์ํฌ ๊ฐ์ฒด
lateinit var fakeUserRepository: FakeUserRepository // ํ
์คํธ์ฝ๋์ ๊ณต๊ฐ ์ํฌ ๊ฐ์ฒด
override fun starting(description: Description) {
super.starting(description)
val fakePhotoRepository = FakePhotoRepository()
fakeUserRepository = FakeUserRepository()
val setRandomPhotoUseCase = SetRandomPhotoUseCase(fakePhotoRepository, fakeUserRepository)
editViewModel = EditViewModel(
fakeUserRepository,
setRandomPhotoUseCase,
)
}
}
Cucumber ๋ฑ ์ํธํํฐ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํ๋ฉด UI ํ ์คํธ ์๋ํ์ BDD(Back-End Driven Development)๊ฐ ๊ฐ๋ฅํด์ง๋ค. BDD๋ ์๋ฒ๋ก๋ถํฐ UI๋ฅผ ํ์์ ์ ๊ณต๋ฐ๊ธฐ ๋๋ฌธ์ ์ฑ์ ์ ๋ฐ์ดํธ, ๋ฐฐํฌํ์ง ์์๋ ๋ณ๊ฒฝ์ฌํญ์ ์ ์ฉ์ํฌ ์ ์๋ ์ฅ์ ์ด ์๋ค.
ํ์ง๋ง ์ด๋ฒ ์คํฐ๋ ํ๋ก์ ํธ์์๋ ๊ฐ๋จํ๊ฒ GitAction์ ํตํด PR ๋จ์๋ก ํ ์คํธ ์๋ํํ๋๋ก ํ๊ฒ ๋ค.
๋ฐฉ๋ฒ์ ๊ฐ๋จํ๋ฐ ๋ ํฌ์งํ ๋ฆฌ root ์๋์ .github/workflows/ci.yml ํ์ผ์ ๋ง๋ค์ด์ฃผ๋ฉด ๋๋ค.
๊ฒผ์๋ ์ด์๋ค์ ์๋์ ๊ฐ๋ค.
-
jdk ๋ฒ์ ํธํ x, 11 -> 17๋ก ์ ๊ทธ๋ ์ด๋ํ๋๋ ๋ฌธ์ ํด๊ฒฐ
-
local.properties ํ์ผ ์ฝ๊ธฐ ์คํจ
build.gradle ์์ api access token ์ ๋ก์ปฌ ํ๋กํผํฐ์์ ๊ฐ์ ธ์ค๊ณ ์์๊ธฐ ๋๋ฌธ์ git secret ์ ์ถ๊ฐํด์คฌ๋ค.
์ถ๊ฐ์ ์ผ๋ก apk ๋ฅผ ๋ง๋ค์ด github์ ์ ๋ก๋ํ๊ฑฐ๋ slack, discord ์ ๋ด์ ๋ง๋ค๊ณ api๋ฅผ ์์ฒญํ๋ฉด ์๋์ ๊ฐ์ด ๋ก์ปฌ์์ ํ์ธํ ์ ์๋ ํ ์คํธ ๊ฒฐ๊ณผ๋ฅผ ๋ณด๋ผ ์ ์๋ค.
name: gdsc_test_ci
on:
pull_request:
branches: [ "main" ]
workflow_dispatch:
inputs:
tags:
description: 'Test scenario tags'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'zulu'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Create Local Properties
run: echo '${{ secrets.LOCAL_PROPERTIES }}' > ./local.properties
- name: Start gradlew test
run: ./gradlew test