개요
프로젝트가 마무리 될 즈음 당연하게 거쳐야 햘 과정이 있습니다.
그것은 바로 기능들을 검증하는것이겠지요?
전문적인 용어로는 QA(Quality Assuarance)라고 합니다.
그동안의 프로젝트에서는 QA 과정이 없거나, 아주 간단하게 진행했습니다.
왜냐면 개발과 문서화를 하기에도 시간이 벅차기 때문인데요,
하지만 좋은 프로젝트는 유저가 사용하기 이전에 서비스가 안전한지, 개발자의 의도대로 동작하는 지 검증하는것이 당연히 필요합니다.
예전에 진행했던 프로젝트에서 테스트 배포 후 받았던 피드백 내용들입니다.
기능 외적으로 개발단계에서 생각치 못한 문제들이 많이 발생하는 것을 알 수 있습니다.
예를 들면 위 화면처럼 말도 안되는 오류가 생기기도 하구요..
그래서 프로젝트 마무리 단계에서 저희는 QA를 도입해보기로 했습니다.
크게 화면으로 나누고, 검증할 내용과 선행조건을 입력했습니다.
검증해야할 기능과 화면을 적고 직접 앱을 사용하면서 진행하다가 문득, 손으로 하는것이 다른사람들에게 와닿을까..?
이렇게 검증하는것이 도움이 될까라는 궁금증이 생겼습니다.
따라서 코드로 검증을 하면 좋지 않을까..? 더 나아가 자동화도 할 수 있으면 좋을것 같다는 생각이 들었습니다.
Test Code
직무역량이나 우대사항에서 Test Code 작성 경험이라는 말을 보면 가슴이 답답해지고 막막해졌던 기억이 있습니다.
사실 취준생의 프로젝트에서 Test Code를 작성하고 도입하는것이 배보다 배꼽이 더 크다라고 생각할 수 있습니다.
저도 (살짝) 그렇게 생각하지만, 한번 작성해보면 좋지 않을까? 싶어서 QA와 Test Code를 접목해서 도입해봤습니다.
우선 제가 생각한 Test Code를 작성하면 좋은점은 다음과 같습니다.
- 작성한 코드가 의도대로 작동하는지 검증할 수 있다.
- 리팩토링 시 동일한 동작을 하는지 확인할 수 있다.
- 테스트 코드를 통해서 내가 작성한 코드의 의도와 결과를 다른 사람이 확인하기 편하다.
즉 문서화의 역할을 한다.
Test Code의 종류는 3가지가 있는데, 제가 작성한 테스트 코드는 Unit Test와 UI Test입니다.
Unit Test
Unit Test는 사전적으로 단위테스트라고도 불리며, 하나의 모듈을 기준으로 독립적으로 진행되는 가장 작은 단위의 테스트입니다.
조금 설명이 생소할 수 있는데, 모듈의 의미는 하나의 기능 or 함수로 이해하면 되겠습니다.
대표적인 예로는 로그인 과정에서 유효성 검증입니다.
제가 생각하고 작성한 Unit Test의 Flow는 "어떤 기능이 실행되면 어떤 결과가 나와야 한다! 그리고 그것을 검증한다"로 이해했고, 그렇게 작성했습니다.
UI Test
UI Test는 실제 사용자들이 사용하는 화면에 대한 테스트를 하면서 의도대로 서비스가 동작하는지 검증하는 테스트입니다.
UI Test의 경우 화면가 직접적으로 연관이 되어있기 때문에 화면(UI)이 변경될 경우 영향을 받기 때문에 유지 보수 비용이 큰 작업이긴 합니다. 하지만 반드시 필요한 작업이라고 생각합니다.
이제 프로젝트에서 내가 작성한 테스트 코드를 살펴봐야하는데요,
아래 QA를 바탕으로 테스트 코드를 살펴보겠습니다!
우선 Android Studio에서 사용할 수 있는 테스트코드 라이브러리는 여러개 있지만 저는 Junit4와 Compose UI Test를 사용했습니다.
사실 Junit4는 한글 사용이 까다로워서 5를 사용하고 싶었지만, 적용하는 과정이 너무 복잡하고 대부분의 코드가 Junit4로 구성되어 있기에 저도 버전 4로 작성했습니다.
Compose UI Test는 제가 XML이 아닌 Compose를 사용했기 때문에 사용해야했습니다.
위 사진은 Unit 테스트 코드가 있는 패키지 구조입니다.
- data : 상황별로 쓰일 Data가 있고, 검증하기 위해 사용됩니다.
- xxViewModelTest : ViewModel에서 사용될 로직을 테스트하는 코드들입니다.
object NickNameTestData {
const val DUPLICATED_NICKNAME = "짜이한"
const val NO_DUPLICATED_NICKNAME = "짜이한한한"
const val TOO_SHORT_NICKNAME = "한"
const val TOO_LONG_NICKNAME = "가나다라마바사아자카타파하라"
const val INCLUDE_SPACE_NICKNAME = "짜이한 "
const val INCLUDE_NUMBER_NICKNAME = "짜이한1"
}
회원가입에서 닉네임을 입력하는 로직을 검증하는데 사용될 데이터로, 각 오류에 해당되는 예시 데이터들입니다.
중복된 닉네임이거나, 길이가 길거나 짥거나, 공백이 있거나 숫자가 있는 등 정규식에서 사용될 데이터라고 생각하시면 됩니다.
이렇게 각 Data들은 로직을 검증하기 위해 사용될 데이터로 구성되어 있습니다.
테스트 코드는 Given - When - Then으로 구조화하는것이 보편적입니다.
- Given : 테스트 실행을 준비하는 단계로, 테스트에 필요한 데이터를 세팅하는 단계입니다.
- when : 테스트를 실행하는 단계로, 검증하고 싶은 기능을 실제로 수행하는 단계입니다.
- then : 테스트 결과를 검증하는 단계로, 예상 결과 실제 결과를 비교하는 단계입니다.
Unit Test 코드 작성
위 구조를 대입해보면 아래와 같은 코드를 작성할 수 있습니다.
@Test
fun `닉네임이_중복되었다면_UiState를_Error로_업데이트_한다`() = runTest {
// Given: 중복된 닉네임을 입력하고, 저장소에서 중복된 닉네임으로 에러를 반환하도록 설정
val nickName = DUPLICATED_NICKNAME
coEvery { userRepository.checkNickName(nickName) } returns flowOf(
ApiResponse.Error(
errorMessage = ErrorMessage.DUPLICATED_NICKNAME
)
)
viewModel.nicknameState.updateNickname(nickName)
// When: 닉네임 중복 검사를 실행
viewModel.checkNickName()
// Then: UiState가 에러 상태로 업데이트되었는지 확인
assertEquals(
NickNameUiState.Error(errorMessage = ErrorMessage.DUPLICATED_NICKNAME),
viewModel.uiState.value
)
}
코드를 구조별로 하나하나 뜯어보겠습니다.
Given
Unit Test의 검증은 실세 서버와의 통신을 동작하는 것이 아닌 제가 작성한 메서드를 검증하는것입니다.
제가 검증하고 싶은 내용이 중복된 닉네임을 중복검사했을 경우, 원하는 결과를 얻을 수 있는지입니다.
따라서 테스트에서 사용될 nickName을 중복된 닉네임으로 설정하고, coEvery를 통해 해당 함수의 반환값을 설정해줍니다.
중복된 닉네임 -> 오류를 반환해야하므로, 저는 Error 타입을 반환하게했고, 메시지는 중복된 닉네임이 발생할 경우로 설정해줍니다.
When
실제 동작을 수행하는 단계로, 저는 checkNickName()을 수행합니다.
Then
결과를 검증하는 단계로 저는 ViewModel에서 사용되는 UiState를 비교했습니다.
오류일 경우 저는 viewModel의 uiState가 errorMessage가 DUPLICATED_NICKNAME인지 검증했습니다.
결과는 PASS입니다.
제가 검증하고자 하는 값과 viewModel에서의 동작 값이 같음을 확인할 수 있었습니다.
저는 위와 같은 테스트 코드를 작성했고
위와 같은 커버리지 결과를 확인할 수 있었습니다.
UI Test 코드 작성
UI Test도 Unit Test와 동일하게 given - when - then 구조를 가져갑니다.
저는 Compose UI Test를 도입했는데요, 보편적인 구조는 다음과 같습니다.
class AScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `function_A`() {
// given : 데이터 설정, 결과값 지정
composeTestRule.setContent {
AScreen()
}
// when : 동작 수행(버튼 클릭 등)
composeTestRule.onNodeWithTag("clear_button").performClick()
// then : 결과 검증
assertEquals(
testData,
AScreen.data,
)
}
}
@get: Rule은 Junit에서 테스트 환경을 설정하는 어노테이션입니다.
저는 Compose 테스트 환경을 설정하기 위해 createComposeRule을 사용했습니다.
function에서는 composTestRule.setContent를 통해서 화면을 Compose 환경에 렌더링하고, 수행할 동작을 수행 후 결과를 검증하는 구조입니다.
이제 제가 닉네임을 입력하는 화면에서 작성한 테스트 코드를 확인해보겠습니다.
저는 x버튼을 누를 경우 텍스트 필드에 있는 텍스트 값들이 모두 지워는것을 원하고, 이를 검증해볼것입니다.
그렇다면 x 버튼을 클릭을 해야하는데요, 테스트 환경에서 x 버튼에 접근하기 위해서 x버튼을 인식할 수 있는 인식표를 넣어줘야 합니다.
기존 xml에서도 contentDescription을 입력하라고 권유되는 문구를 보셨을텐데요, 이를 이용해서 x 버튼에 이름을 달아주는 것입니다.
두가지 방식을 사용할 수 있는데요,
첫번째는 contentDecription을 통해서 지정해주는것입니다.
Image(
modifier = Modifier.size(200.dp),
painter = painterResource(id = resId),
contentDescription = "user_img",
)
이럴 경우 해당 요소를 찾기 위해서는 테스트 코드에서 아래 코드를 통해서 요소를 인식할 수 있습니다.
composeTestRule.onNodeWithContentDescription("user_img")
두번째는 Modifier에 testTag를 달아주는것입니다.
IconButton(
modifier = Modifier.testTag("clear_button"),
onClick = onClearPressed
)
이럴 경우 해당 요소를 찾기 위해서 테스트 코드에서 아래 코드를 통해서 요소를 인식할 수 있습니다.
composeTestRule.onNodeWithTag("clear_button")
작성된 테스트 코드입니다.
@Test
fun `전체_지우기_버튼을_누르면_닉네임은_지워져야한다`() {
// Given: 닉네임이 이미 설정되어 있는 상태
val nickName = NO_DUPLICATED_NICKNAME
composeTestRule.setContent {
NicknameScreen(viewModel = viewModel)
}
viewModel.nicknameState.updateNickname(nickName)
// When: 전체 지우기 버튼을 클릭했을 때
composeTestRule.onNodeWithTag("clear_button").performClick()
// Then: 닉네임이 빈 문자열로 초기화되어야 함
assertEquals(
viewModel.nicknameState.nickname,
"",
)
}
결과는 당연하게 PASS가 나왔습니다.
이렇게 버튼을 클릭 후 상호작용도 확인할 수 있고, 특정 요소가 화면에 출력되는지, 출력되는 아이템의 개수등을 확인할 수 있습니다.
출력되는 데이터 확인
@Test
fun `불러온_학습_퀴즈_데이터들은_화면에_표시된다`() = runTest {
val userId = dataStoreRepository.getUserId() ?: 0L
coEvery { savedStateHandle.get<Long>("studyId") } returns -1L
coEvery { studyRepository.getStudyQuiz(userId) } returns flowOf(
ApiResponse.Success(studyQuiz)
)
viewModel = StudyViewModel(savedStateHandle, studyRepository, dataStoreRepository)
composeTestRule.setContent {
HandleStudyUi(studyState = viewModel.studyState.value, studyViewModel = viewModel)
}
composeTestRule.onNodeWithTag("study_quiz_title")
.assertTextEquals(studyQuiz.quizTitle)
composeTestRule.onNodeWithTag("study_quiz_script")
.assertTextEquals(studyQuiz.quizScript)
composeTestRule.onNodeWithTag("study_quiz_list").onChildren()
.assertCountEquals(studyQuiz.wordList.size)
}
위 코드는 불러온 퀴즈 데이터들의 개수가 화면에 출력되는 개수가 일치하는지 확인하는 코드입니다.
onChildren()의 assetCountEquals를 통해 숫자가 일치하는지 검증할 수 있습니다.
에러 메시지 출력 확인
@Test
fun `보기를_선택하지_않고_제출할경우_에러메시지가_출력된다`() = runTest {
val userId = dataStoreRepository.getUserId() ?: 0L
coEvery { savedStateHandle.get<Long>("studyId") } returns -1L
coEvery { studyRepository.getStudyQuiz(userId) } returns flowOf(
ApiResponse.Success(studyQuiz)
)
viewModel = StudyViewModel(savedStateHandle, studyRepository, dataStoreRepository)
composeTestRule.setContent {
HandleStudyUi(studyState = viewModel.studyState.value, studyViewModel = viewModel)
}
composeTestRule.onNodeWithTag("study_submit_button").performClick()
composeTestRule.onNodeWithTag("error_snack_bar").assertIsDisplayed()
}
보기를 선택하지 않고, 제출 버튼을 누를 경우 스낵바가 화면에 출력되는지를 확인하는 코드입니다.
assertIsDisplayed를 통해 확인할 수 있습니다.
작성한 UI Test Code의 실행 결과입니다.
테스트 코드를 작성해본 경험은 처음이었는데요, TDD가 아닌 개발이 마무리 된 후 테스트 코드를 도입해서 작성하는데 더 수월하지 않았나 생각합니다.
처음부터 테스트 코드를 작성하고, 검증하면서 개발을 하면 아마 엄청 많이 바껴서 더 복잡하지 않았을까...
테스트 코드를 작성하면서 느낀점은 많은데, 내가 진행하는 규모에서 도입하는것이 과연 이득일지 아직은 고민이 되는것 같습니다.
다음 프로젝트에서는 TDD를 도입해서 커버리지 범위도 넓게 하고, 전문적으로 작성해보고 싶은 마음이 드는것 같습니다.
참고
'Skils > Android' 카테고리의 다른 글
[Android] - Interceptor를 이용한 Retrofit 에러 핸들링 (1) | 2024.10.19 |
---|---|
[Android] - Authenticator를 활용해 JwtToken 갱신하기 (2) | 2024.10.12 |
[Android] - 외부 라이브러리 없이 동영상 편집 구현하기 (0) | 2024.08.24 |
[Android] - 동영상에 대한 썸네일 리스트 반환하기 (0) | 2024.08.10 |
[Android] - 멀티 모듈 with Version Catalog [멀티 모듈 적용기(2)] (2) | 2024.07.12 |