Android 에서 TDD를 습관화하기

2023. 11. 30. 11:01개발/Android

TDD?

  • Test Driven Development
  • sw의 개발 전에, 테스트 사례로 제시되는 요구사항에 의존하는 개발 프로세스
  • 가장 쉽고 빠르고, 신뢰 가능한 방법은 Red, Green, Refactor로 구성하는 방법!

테스트 라이브러리

  1. Truth — value를 asserting하기 위한 라이브러리
  1. Turbine — Flow에서 받은 value를 asserting하기 위한 라이브러리
  1. Mockito — mock데이터를 위한 라이브러리
  1. JUnit4 — java 기본 유닛 테스트 프레임워크
  1. 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.ktContactsScreen— 뷰 상태를 유지할 UI 상태

테스트 클래스

ContactsViewModelTest.ktContactsViewModel 의 테스트 클래스

ContactsScreenTest.ktContactsScreen 의 테스트 클래스

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)

이 시점에서 컴파일은 가능해야한다. 이제 테스트를 통과하도록 만들 차례다.

  1. 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() 
    } 
}

  1. 다음으로는, 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() 
    } 
}

  1. 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

'개발 > Android' 카테고리의 다른 글

MenuProvider  (0) 2023.12.15
메모리 누수의 원인 10가지  (0) 2023.12.15
Useful modifier  (0) 2023.11.30
Compose Viewpager  (1) 2023.11.01
Avoiding recomposition  (1) 2023.11.01