ViewModel의 단위 테스트 작성
이전에 만든 Unscramble 게임 앱에 단위 테스트를 추가하는 과정이었는데, Unscramble 앱은 사용자가 글자가 뒤섞인 영어단어를 추측하고 추측이 맞았을 때, 포인트를 얻는 간단한 방식의 단어 게임이었다.
이 앱을 기준으로 ViewModel의 단위 테스트 작성법을 알아보고자 한다.
https://github.com/google-developer-training/basic-android-kotlin-compose-training-unscramble
테스트 전략
좋은 테스트 전략의 핵심은 코드의 여러 경로와 경계를 포괄하는 것이다. 아주 기본적인 수준에서 테스트는 성공 경로, 오류 경로, 경계 사례라는 세 가지 시나리오로 분류할 수 있다.
- 성공 경로: 성공 경로 테스트(해피 패스 테스트라고도 함)는 긍정적인 흐름의 기능 테스트에 집중한다. 긍정적인 흐름은 예외나 오류 조건이 없는 흐름이다. 오류 경로 및 경계 사례 시나리오에 비해 성공 경로 시나리오는 전체 목록을 만들기 쉽다. 앱을 의도적 동작에 초점을 두기 때문이다.
ex) 사용자가 올바른 단어 입력 -> Submit 버튼 클릭 -> 점수, 단어 개수, 글자가 뒤섞인 단어의 올바른 업데이트
- 오류 경로: 오류 경로 테스트는 부정적인 흐름의 기능 테스트, 즉 앱이 오류 조건 또는 잘못된 사용자 입력에 어떻게 응답하는지 확인하는데 초점을 둔다. 가능한 오류 흐름을 모두 파악하기란 매우 어렵다. 의도된 동작이 실행되지 않을 때 발생할 수 있는 결과는 매우 많기 때문이다.
일반적으로 가능한 모든 오류 경로를 나열한 뒤, 이에 관한 테스트를 작성하여 다양한 시나리오를 발견하면서 지속적으로 단위 테스트 시행 및 개선을 해나가는 것이 좋다.
ex) 사용자가 잘못된 단어 입력 -> Submit 버튼 클릭 -> 오류 메시지 표시 -> 점수, 단어 개수의 업데이트 실패
- 경계 사례: 경계 사례는 앱의 경계 조건을 테스트하는데 초점을 둔다. 예를들어 Unscramble 앱에서의 경계란, 앱이 로드될 때의 UI 상태와 사용자가 최대 단어 수를 재생한 후의 UI 상태를 확인하는 것이다.
이러한 카테고리에 관한 테스트 시나리오를 만들면 테스트 계획을 위한 가이드라인으로 삼을 수 있다.
테스트 만들기
좋은 단위 테스트에는 일반적으로 다음 4가지 특성이 있다.
- 집중: 코드 조각과 같은 단위를 테스트하는데 중점을 둔다. 이 코드 조각은 대부분 클래스, 메서드이다. 테스트의 범위를 좁히고 동시에 여러 코드가 아닌 개별 코드의 정확성을 검증하는 것에 집중한다.
- 이해 가능: 코드를 읽을 때 간단하고 이해하기 쉬워야한다. 개발자는 테스트의 의도를 한눈에 파악할 수 있어야한다.
- 확정성: 일관된 통과 or 실패가 이뤄져야한다. 코드를 변경하지 않고 테스트를 여러 번 실행하면 테스트의 결과가 동일해야한다. 테스트는 코드를 수정하지 않았는데도 어떤 때에는 실패, 어떤 때에는 성공하는 등, 불안정해선 안된다.
- 독립형: 사람이 상호작용하거나 설정할 필요가 없이 개별적으로 실행된다.
다음 코드가 Unscramble 앱에서 성공-오류-경계. 세 가지 시나리오를 전부 작성한 코드이다.
package com.example.unscramble.ui.test
import com.example.unscramble.data.MAX_NO_OF_WORDS
import com.example.unscramble.data.SCORE_INCREASE
import com.example.unscramble.data.getUnscrambledWord
import com.example.unscramble.ui.GameViewModel
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import org.junit.Assert.assertNotEquals
import org.junit.Test
class GameViewModelTest {
private val viewModel = GameViewModel()
@Test
fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
var currentGameUiState = viewModel.uiState.value
val correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
viewModel.updateUserGuess(correctPlayerWord)
viewModel.checkUserGuess()
currentGameUiState = viewModel.uiState.value
assertFalse(currentGameUiState.isGuessedWordWrong)
assertEquals(SCORE_AFTER_FIRST_CORRECT_ANSWER, currentGameUiState.score)
}
companion object {
private const val SCORE_AFTER_FIRST_CORRECT_ANSWER = SCORE_INCREASE
}
@Test
fun gameViewModel_IncorrectGuess_ErrorFlagSet() {
val incoorectPlayerWord = ""
viewModel.updateUserGuess(incoorectPlayerWord)
viewModel.checkUserGuess()
val currentGameUiState = viewModel.uiState.value
assertEquals(0, currentGameUiState.score)
assertTrue(currentGameUiState.isGuessedWordWrong)
}
@Test
fun gameViewModel_Initialization_FirstWordLoaded() {
val gameUiState = viewModel.uiState.value
val unScrambledWord = getUnscrambledWord(gameUiState.currentScrambledWord)
// Assert that current word is scrambled.
assertNotEquals(unScrambledWord, gameUiState.currentScrambledWord)
// Assert that current word count is set to 1.
assertTrue(gameUiState.currentWordCount == 1)
// Assert that initially the score is 0.
assertTrue(gameUiState.score == 0)
// Assert that the wrong word guessed is false.
assertFalse(gameUiState.isGuessedWordWrong)
// Assert that game is not over.
assertFalse(gameUiState.isGameOver)
}
@Test
fun gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly() {
var expectedScore = 0
var currentGameUiState = viewModel.uiState.value
var correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
repeat(MAX_NO_OF_WORDS) {
expectedScore += SCORE_INCREASE
viewModel.updateUserGuess(correctPlayerWord)
viewModel.checkUserGuess()
currentGameUiState = viewModel.uiState.value
correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
// Assert that after each correct answer, score is updated correctly.
assertEquals(expectedScore, currentGameUiState.score)
}
// Assert that after all questions are answered, the current word count is up-to-date.
assertEquals(MAX_NO_OF_WORDS, currentGameUiState.currentWordCount)
// Assert that after 10 questions are answered, the game is over.
assertTrue(currentGameUiState.isGameOver)
}
}
성공 경로
성공 경로는 사용자가 추측한 단어가 올바른 경우이다.
@Test
fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
var currentGameUiState = viewModel.uiState.value
val correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
viewModel.updateUserGuess(correctPlayerWord)
viewModel.checkUserGuess()
currentGameUiState = viewModel.uiState.value
assertFalse(currentGameUiState.isGuessedWordWrong)
assertEquals(SCORE_AFTER_FIRST_CORRECT_ANSWER, currentGameUiState.score)
}
추측한 단어가 올바르고 점수가 업데이트되는지 확인하는 부분이 assertFalse()와 assertEquals이다.
추측한게 틀렸다면 isGuessedWordWrong = true가 되고, 그렇다면 assertFalse를 통과하지 못하게 된다.
만약 추측한게 맞았다면, assertFalse를 통과하고 assertEquals로 넘어간다.
첫번째 단어를 맞춘 후 점수와 현재 점수(첫번째 단어를 맞춘 후 증가한 점수)가 같다면, 즉 점수가 업데이트 되었는지를 확인한다.
오류 경로
오류 경로는 사용자가 추측한 단어가 틀린 경우이다.
@Test
fun gameViewModel_IncorrectGuess_ErrorFlagSet() {
val incoorectPlayerWord = ""
viewModel.updateUserGuess(incoorectPlayerWord)
viewModel.checkUserGuess()
val currentGameUiState = viewModel.uiState.value
assertEquals(0, currentGameUiState.score)
assertTrue(currentGameUiState.isGuessedWordWrong)
}
추측한 단어가 틀렸기 때문에 currentGameUiState.score의 값이 0이고, isGuessedWordWrong이 True가 되고, assertTrue를 통해 이 값이 True가 맞는지 판별한다.
시작 직후를 상정하기 때문에 점수가 0으로 동일한지 판별하는 것이다.
경계 사례
경계 사례는 2가지 테스트가 존재한다.
하나는 UI의 초기 상태 테스트.
하나는 사용자가 모든 단어를 추측한 후 UI 상태를 테스트하는 것이다.
@Test
fun gameViewModel_Initialization_FirstWordLoaded() {
val gameUiState = viewModel.uiState.value
val unScrambledWord = getUnscrambledWord(gameUiState.currentScrambledWord)
// Assert that current word is scrambled.
assertNotEquals(unScrambledWord, gameUiState.currentScrambledWord)
// Assert that current word count is set to 1.
assertTrue(gameUiState.currentWordCount == 1)
// Assert that initially the score is 0.
assertTrue(gameUiState.score == 0)
// Assert that the wrong word guessed is false.
assertFalse(gameUiState.isGuessedWordWrong)
// Assert that game is not over.
assertFalse(gameUiState.isGameOver)
}
@Test
fun gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly() {
var expectedScore = 0
var currentGameUiState = viewModel.uiState.value
var correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
repeat(MAX_NO_OF_WORDS) {
expectedScore += SCORE_INCREASE
viewModel.updateUserGuess(correctPlayerWord)
viewModel.checkUserGuess()
currentGameUiState = viewModel.uiState.value
correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
// Assert that after each correct answer, score is updated correctly.
assertEquals(expectedScore, currentGameUiState.score)
}
// Assert that after all questions are answered, the current word count is up-to-date.
assertEquals(MAX_NO_OF_WORDS, currentGameUiState.currentWordCount)
// Assert that after 10 questions are answered, the game is over.
assertTrue(currentGameUiState.isGameOver)
}
UI의 초기 상태를 테스트하는 것은 다음과 같다.
assertNotEquals - 글자가 뒤섞이기 전 기본으로 지정된 단어와 현재 뒤섞인 단어가 같지 않아야한다.
같다면 글자를 뒤섞는 함수가 무언가 잘못된 것이므로 게임 진행이 불가능.
assertTrue - 초기값, 문제의 번호 수(초기값이니까 첫번째 문제여야한다)
assertTrue - 초기 값, 점수가 0이 맞는지 체크
assertFalse - 초기 값, 추측한 단어가 틀렸나? 변수가 false인지 체크
assertFalse - 초기 값, 게임 오버인가? 변수가 false인지 체크
위 항목을 전부 통과한다면 UI의 초기 상태 테스트가 종료된다는 것이다.
사용자가 모든 단어를 추측한 후의 UI 상태 테스트는 다음과 같다.
점수가 최신상태이고 currentWordCount가 MAX_NO_OF_WORDS와 같은지, isGameOver가 true인지
MAX_NO_OF_WORDS는 10으로, 해당 앱에선 10문제를 풀 수 있게 상한선이 맞춰져 있다.
그래서 메서드에서 상한선이 10번을 반복시키는 것이고, 예상 점수를 초기값인 0에서 점수 맞출때마다 추가되는 값인 SCORE_INCREASE를 추가시킨다. 여기서 예상 점수와 테스트 시행시 점수가 다르다면 점수 계산에 문제가 생긴 것이므로 테스트가 실패하고 같다면 제대로 점수가 측정되고 있는 것이므로 통과이다.
반복문을 빠져나와 현재 문제 번호 수와 상한선 10이 같다면 게임이 끝난 것이므로 통과해야한다 만약 현재 문제 번호수가 10을 넘긴다면 문제를 끝내는 부분에서 뭔가 잘못된 것이다.
마지막으로 제대로 됐다면 isGameOver = True로 설정되므로 이 값이 True인지 다시 한번 체크하고 테스트가 종료된다.
테스트 인스턴스 수명 주기 개요
테스트에서 viewModel이 초기화되는 방식을 자세히 보면 모든 테스트에서 사용되는 viewModel이 class의 맨 처음에 단 한번만 초기화되는 것을 알 수 있다.
그렇다면 동일한 viewModel 인스턴스가 재사용되냐고 물어볼 수 있고, 성공 경로 이후 바로 UI 초기 테스트인 경계 사례 테스트를 하게 되면 문제가 발생하지 않냐는 질문이 올 수 있다.
두 질문 모두 아니다라고 할 수 있다. 테스트 메서드는 개별적으로 실행되어 변경 가능한 테스트 인스턴스 상태로 인한 예상치 못한 부작용을 방지한다. 기본적으로 JUnit은 각 테스트 메서드가 실행되기 전에 테스트 클래스의 새 인스턴스를 만든다.
지금까지 GameViewModelTest 클래스에 4개의 테스트 메서드가 있었으므로 GameViewModelTest는 4번 인스턴스화된다. 각 인스턴스에는 viewModel 속성의 자체 사본이 존재하기 때문에 테스트의 실행 순서는 중요하지 않다.
'개발 > AOS' 카테고리의 다른 글
탐색 그래프 없이 화면 변경 (1) | 2024.05.16 |
---|---|
Compose Navigation 경로 정의 및 NavHostController (0) | 2024.05.14 |
애플리케이션 테스트 01. 자동테스트란? (0) | 2024.05.09 |
앱 아키텍처 알아보기 (0) | 2024.05.08 |
rememberSaveable이란 (0) | 2024.05.08 |
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!