TDD?
- Test Driven Development
- sw의 개발 전에, 테스트 사례로 제시되는 요구사항에 의존하는 개발 프로세스
- 가장 쉽고 빠르고, 신뢰 가능한 방법은 Red, Green, Refactor로 구성하는 방법!
테스트 라이브러리
- Truth — value를 asserting하기 위한 라이브러리
- Mockito — mock데이터를 위한 라이브러리
- JUnit4 — java 기본 유닛 테스트 프레임워크
- JUnit5 — Java 단위 테스트 프레임워크
→ 알아서 추가하시오
예제
@AndroidEntryPoint
class ContactsFragment : Fragment() {
// Let's pretend this is a class responsible to retrieve data from API
@Inject
lateinit var megaApi: MegaApi
private var _binding: FragmentContactsBinding? = null
private val binding get() = _binding!!
private var mAdapter: ContactsAdapter? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentContactsBinding.inflate(layoutInflater)
binding.recyclerView.apply {
addItemDecoration(SimpleDividerItemDecoration(requireContext()))
layoutManager = LinearLayoutManager(requireContext())
itemAnimator = DefaultItemAnimator()
}
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
fetchContacts()
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
private fun fetchContacts() = lifecycleScope.launch(Dispatchers.Main) {
binding.loadingLayout.isVisible = true
val contacts = try {
withContext(Dispatchers.IO) {
megaApi.getContacts()
}
} catch (e: Exception) {
showErrorLayout()
return
}
updateContactsUI(contacts)
}
private fun updateContactsUI(contacts: List<Contact>?) {
when {
contacts.isNullOrEmpty() -> showEmptyLayout()
else -> {
if (mAdapter == null) {
mAdapter = ContactsAdapter(
requireActivity(),
this,
contacts
)
}
binding.recyclerView.adapter = mAdapter
binding.recyclerView.isVisible = true
binding.loadingLayout.isVisible = false
binding.emptyLayout.isVisible = false
binding.errorLayout.isVisible = false
}
}
}
private fun showEmptyLayout() {
binding.emptyLayout.isVisible = true
binding.recyclerView.isVisible = false
binding.errorLayout.isVisible = false
binding.loadingLayout.isVisible = false
}
private fun showErrorLayout() {
binding.errorLayout.isVisible = true
binding.recyclerView.isVisible = false
binding.emptyLayout.isVisible = false
binding.loadingLayout.isVisible = false
}
}
- 이걸로 compose 적용 + tdd 적용할 예정
필요 클래스
Contact.kt
— contacts list의 각 contact에 대한 데이터 클래스
ContactsViewModel.kt
— 뷰에 대한 뷰 모델
ContactsRoute.kt
— Jetpack Compose의 경로, 화면, 보기
ContactsRepository.kt
— 데이터를 검색하는 저장소
UIState.kt
ContactsScreen
— 뷰 상태를 유지할 UI 상태
테스트 클래스
ContactsViewModelTest.kt
— ContactsViewModel
의 테스트 클래스
ContactsScreenTest.kt
— ContactsScreen
의 테스트 클래스
1단계. viewModel의 테스트 클래스 생성하기
아래와 같이 테스트 클래스를 먼저 생성해준다
class ContactsViewModelTest {}
그 다음, 테스트의 명세를 작성해준다
fun `ViewModel이 처음 로드될 때 loading indicator의 가시 상태 가 true 인지 테스트합니다.`() {}
fun ` 연락처 가져오기가 성공적으로 완료 되면 loading indicator 가시 상태가 false 인지 테스트합니다.` () {}
fun ` 연락처 가져오기가 오류를 반환할 때 loading indicator 가시 상태가 false 인지 테스트합니다 ` () {}
fun ` 연락처 가져오기 성공 시 연락처 데이터가 업데이트되어야 하는지 테스트 ` () {}
fun ` 연락처 가져오기 에서 오류가 발생 하면 오류 레이아웃 가시성이 true 인지 테스트` () {}
2단계. AAA(Arrange, Action, Assert)
각 테스트를 3개의 섹션으로 나누기
@Test
fun ` ViewModel이 처음 로드될 때 로드 표시기 가시성 상태 가 true 인지 테스트합니다.` () {
// Arrange
// Act
// Assert
}
ui상태 클래스 안만들었으면 지금 만들기
data class UIState(
val isLoading: Boolean = false,
val isError: Boolean = false,
val contacts: List<Contact> = emptyList()
)
Repository도 만들기
class ContactsRepository @Inject constructor(
private val megaApi: MegaApiAndroid
) {
suspend fun getContacts(): List<Contact> = withContext(Dispatchers.IO) {
megaApi.getContacts()
}
}
연락처 데이터 받을 데이터 클래스(모델) 도 만들기
data class Contact(
val name: String,
val email: String,
val phone: String
)
2.1단계 - Assert
- 최종 목표
2.2단계 — Act
- 기본적으로 결과를 달성하는 데 필요한 조치
- 여기에서 메서드를 호출
2.3단계 - Arrange
- 전제 조건
- 몇몇 테스트 케이스에서 viewmodel만 필요하므로 잠시 init에선 비워둠
@Test
fun `ViewModel이 처음 로드될 때 loading indicator의 가시 상태 가 true 인지 테스트합니다.`() {} // Arrange
// Act
initTestClass()
// Assert
Truth.assertThat(state.isLoading).isTrue()
}
@Test
fun ` 연락처 가져오기가 성공적으로 완료 되면 loading indicator 가시 상태가 false 인지 테스트합니다.` () {}
// Arrange
val contacts: List<Contact> = mock()
whenever(contactsRepository.getContacts()).thenReturn(contacts)
// Act
initTestClass()
// Assert
Truth.assertThat(state.isLoading).isFalse()
}
@Test
fun ` 연락처 가져오기가 오류를 반환할 때 loading indicator 가시 상태가 false 인지 테스트합니다 ` () {} // Arrange
whenever(contactsRepository.getCurrengetContactstUserContacts()).thenThrow(RuntimeException())
// Act
initTestClass()
// Assert
Truth.assertThat(state.isLoading).isFalse()
}
@Test
fun ` 연락처 가져오기 성공 시 연락처 데이터가 업데이트되어야 하는지 테스트 ` () {}
// Arrange
val contacts: List<Contact> = mock()
whenever(contactsRepository.getContacts()).thenReturn(contacts)
// Act
initTestClass()
// Assert
verify(contactsRepository).getContacts()
Truth.assertThat(state.isLoading).isFalse()
Truth.assertThat(state.contacts).isEqualTo(contacts)
}
@Test
fun ` 연락처 가져오기 에서 오류가 발생 하면 오류 레이아웃 가시성이 true 인지 테스트` () {} // Arrange
whenever(contactsRepository.getContacts()).thenThrow(RuntimeException())
// Act
initTestClass()
// Assert
Truth.assertThat(state.isError).isTrue()
}
3단계. ViewModel생성
이전 단계에서 정의한 각 테스트 케이스를 구현하고, 이를 뷰모델에 적용하자. 모든 테스트 케이스가 구현되었는지 확인하고, 결과를 추적하자.
아래는 연락처를 가져오는 테스트의 구현 부분만 비운 코드다.
@HiltViewModel
class ContactsViewModel @Inject constructor(
private val contactsRepository: ContactsRepository
) {
private val _uiState = MutableStateFlow(UIState())
val uiState = _uiState.asStateFlow()
init {
fetchContacts()
}
private fun fetchContacts() = viewModelScope.launch {
// TODO
}
이제 테스트 클래스로 돌아가서, 컴파일이 가능한지 확인하고, 모든 테스트가 실패했는지도 확인하자.
Red / Green / Refactor중 Red가 가장 중요하다.!
코드는 아래와 같이 완성되어야한다
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ContactsViewModelTest {
private val contactsRepository: ContactsRepository = mock()
private lateinit var underTest: ContactsViewModel
@BeforeAll
fun init() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@AfterAll
fun tearDown() {
Dispatchers.resetMain()
}
@BeforeEach
fun setup() {
reset(contactsRepository)
}
private fun initTestClass() {
underTest = ContactsViewModel(contactsRepository)
}
@Test
fun `ViewModel이 처음 로드될 때 loading indicator의 가시 상태 가 true 인지 테스트합니다.`() {} // Arrange
// Arrange
// Act
initTestClass()
// Assert
underTest.uiState.test {
val state = awaitItem()
Truth.assertThat(state.isLoading).isTrue()
}
}
@Test
fun ` 연락처 가져오기가 성공적으로 완료 되면 loading indicator 가시 상태가 false 인지 테스트합니다.` () {}
// Arrange
val contacts: List<Contact> = mock()
whenever(contactsRepository.getContacts()).thenReturn(contacts)
// Act
initTestClass()
// Assert
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isLoading).isFalse()
}
}
@Test
fun ` 연락처 가져오기가 오류를 반환할 때 loading indicator 가시 상태가 false 인지 테스트합니다 ` () {} // Arrange
// Arrange
whenever(contactsRepository.getContacts()).thenThrow(RuntimeException())
// Act
initTestClass()
// Assert
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isLoading).isFalse()
}
}
@Test
fun ` 연락처 가져오기 성공 시 연락처 데이터가 업데이트되어야 하는지 테스트 ` () {}
// Arrange
val contacts: List<Contact> = mock()
whenever(contactsRepository.getContacts()).thenReturn(contacts)
// Act
initTestClass()
// Assert
verify(contactsRepository).getContacts()
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isLoading).isFalse()
Truth.assertThat(state.contacts).isEqualTo(contacts)
}
}
@Test
fun ` 연락처 가져오기 에서 오류가 발생 하면 오류 레이아웃 가시성이 true 인지 테스트` () {} // Arrange
// Arrange
whenever(contactsRepository.getContacts()).thenThrow(RuntimeException())
// Act
initTestClass()
// Assert
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isError).isTrue()
}
}
}
4단계 - 테스트는 실패하고, 컴파일은 가능한지 확인(RED)
이 시점에서 컴파일은 가능해야한다. 이제 테스트를 통과하도록 만들 차례다.
- loading indicator는 viewModel이 처음 로드될 때 나타나야하므로, init ViewModel부분에서 이를 true로 설정한다.
@HiltViewModel
class ContactsViewModel @Inject constructor(
private val contactsRepository: ContactsRepository
) {
private val _uiState = MutableStateFlow(UIState())
val uiState = _uiState.asStateFlow()
init {
// test that loading indicator visibility state is true when ViewModel first load
_uiState.update { it.copy(isLoading = true) }
fetchContacts()
}
private fun fetchContacts() = viewModelScope.launch {
// TODO
}
}
}
테스트 해보기
@Test
fun `ViewModel이 처음 로드될 때 loading indicator의 가시 상태가 true 인지 테스트합니다.`() {} // Arrange
// 배열
// Act
initTestClass()
// Assert
underTest.uiState.test {
val state = waitItem()
Truth.assertThat( state.isLoading).isTrue()
}
}
- 다음으로는, API 로드가 성공적으로 끝나면 loading indicator가 사라져야 한다는것
private fun fetchContacts () = viewModelScope.launch {
runCatching {
// 저장소에서 데이터 가져오기
contactRepository.getContacts()
}.onSuccess {
// 연락처 가져오기 성공 시 로딩 표시기 가시성 상태가 false인지 테스트
_uiState.update {
it.copy(isLoading = false )
}
}
}
테스트
@Test
fun `ViewModel이 처음 로드될 때 loading indicator의 가시 상태가 true 인지 테스트합니다.`() {} // Arrange
...
}
@Test
fun ` 연락처 가져오기가 성공적으로 완료 되면 loading indicator 가시 상태가 false 인지 테스트합니다.` () {}
//
val 연락처 정렬 : List<Contact> = mock()
when(contactsRepository.getContacts()).thenReturn(contacts)
// Act
initTestClass()
// Assert
underTest.uiState.test {
waitItem()
val state = waitItem()
Truth .assertThat(state.isLoading).isFalse()
}
}
- API호출에서 오류가 있다면 로딩 상태 false 인지 확인
private fun fetchContacts () = viewModelScope.launch {
runCatching {
// 저장소에서 데이터 가져오기
contactRepository.getContacts()
}.onSuccess {
// 연락처 가져오기에 성공하면 로드 표시기 가시성 상태가 false인지 테스트
...
}.onFailure {
// 연락처 가져오기 오류가 반환될 때 로드 표시기 가시성 상태가 false인지 테스트합니다.
_uiState.update { it.copy(isLoading = false ) }
}
}
테스트
@Test
fun ` 연락처 가져오기가 오류를 반환할 때 loading indicator 가시 상태가 false 인지 테스트합니다 ` () {} // Arrange
// Arrange
whenever(contactsRepository.getContacts()).thenThrow(RuntimeException())
// Act
initTestClass()
// Assert
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isLoading).isFalse()
}
}
API가 제대로 오면 연락처 데이터 상태가 업데이트 되는지 확인
private fun fetchContacts () = viewModelScope.launch {
runCatching {
// 저장소에서 데이터 가져오기
contactRepository.getContacts()
}.onSuccess {
// 연락처 가져오기가 성공하면 로드 표시기 가시성 상태가 false인지 테스트
// 연락처 데이터를 업데이트해야 하는지 테스트 연락처 가져오기 성공 시
_uiState.update { it.copy(isLoading = false , contact = currentContacts) }
}.onFailure {
// 연락처 가져오기 오류가 반환될 때 로드 표시기 가시성 상태가 false인지 테스트
_uiState.update { it.copy(isLoading = false) }
}
테스트
@Test
fun ` 연락처 가져오기 성공 시 연락처 데이터가 업데이트되어야 하는지 테스트 ` () {}
// Arrange
val contacts: List<Contact> = mock()
whenever(contactsRepository.getContacts()).thenReturn(contacts)
// Act
initTestClass()
// Assert
verify(contactsRepository).getContacts()
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isLoading).isFalse()
Truth.assertThat(state.contacts).isEqualTo(contacts)
}
}
API호출에서 오류 발생하면 오류 레이아웃의 가시성이 true 인지 확인
@HiltViewModel
class ContactsViewModel @Inject constructor(
private val contactsRepository: ContactsRepository
) {
private val _uiState = MutableStateFlow(UIState())
val uiState = _uiState.asStateFlow()
init {
// test that loading indicator visibility state is true when ViewModel first load
_uiState.update { it.copy(isLoading = true) }
fetchContacts()
}
private fun fetchContacts() = viewModelScope.launch {
runCatching {
// 저장소에서 데이터 가져오기
contactRepository.getContacts()
}.onSuccess {
// 로딩 표시기 가시성 테스트 연락처 가져오기 성공 시 상태는 false입니다
. // 연락처 가져오기 성공 시 연락처 데이터가 업데이트되어야 하는지 테스트합니다.
_uiState.update { it.copy(isLoading = false , Contacts = currentContacts) }
}.onFailure {
// 로딩 표시기 가시성 상태가 다음과 같은지 테스트합니다. 연락처 가져오기에서 오류가 반환되면 false // 연락처 가져 오기
에서 오류가 발생하면 오류 레이아웃 가시성이 true인지 테스트합니다.
_uiState.update { it.copy(isLoading = false , isError = true ) } }
}
}
}
테스트
@Test
fun ` 연락처 가져오기 에서 오류가 발생 하면 오류 레이아웃 가시성이 true 인지 테스트` () {} // Arrange
whenever(contactsRepository.getContacts()).thenThrow(RuntimeException())
// Act
initTestClass()
// Assert
Truth.assertThat(state.isError).isTrue()
}
이제 전부 통과해야함. 하나라도 안 되면, 다음 단계로 넘어가서는 안된다!
5단계 - 리팩터링
이제 Refactoring 하면 된다~
Uploaded by N2T