자동차 경주 게임
이번 주차의 미션은 '자동차 경주 게임' 프로그램 구현이다. 기능구현 측면에서 저번 주차의 '숫자 야구 게임'과 비슷하다.
이번 주차는 학습 목표로 '함수를 분리하는 것'과 '함수별로 테스트를 작성하는 것'이 추가되었다.
'숫자 야구 게임'에서 함수를 분리하는 것은 신경썼었지만 테스트 코드는 충분히 고민해보지 못해 아쉬웠는데
학습 목표로 추가되어 테스트코드에 대해 좀 더 집중해서 미션을 수행해보려고 한다.
테스트 코드에 대한 의문
프로그램에 대한 설계를 빠르게 마치고 구현 기능 목록을 작성한 다음에 기능 구현에 들어갔다.
기능 구현 자체는 어렵지 않았지만 저번 주차처럼 테스트 코드를 작성해봤지만 뭔가 찝찝함이 들었다.
- 테스트 코드는 언제 작성해야 하는 것일까? 기능 구현 전? 기능 구현 후?
- 무엇을 테스트해야 하고 테스트의 범위는 어디까지인가?
테스트 코드에 대한 자료를 읽어봐도 이 두 가지 의문점들이 좀처럼 해소되지 않았다.
또한, 테스트 코드를 작성하기 위해 의도했던 설계를 벗어나는 것에 "이게 맞나?"라는 생각이 계속 들었다.
TDD 개발 방법론을 살펴 보면 설계 후 테스트 코드를 먼저 작성하고 기능 구현, 그리고 리팩토링 순서로 순환한다.
그렇지만 제대로 이해하지 못한 방법론을 적용하여 개발하는 것은 시기상조인듯하여
학습 목표인 '단위 테스트'에 좀 더 익숙해지는 것을 목표로 삼았다.
테스트의 범위
class User {
fun requestInputCarNames(): List<Car> {
val input = Console.readLine()
Validator.validateInput(input)
return createCars(input)
}
fun createCars(input: String): List<Car> {
val carNames = input.split(NAME_SEPARATOR).map { it.trim() }
carNames.forEach { Validator.validateCarName(it) }
return carNames.map { Car(it) }
}
}
object Validator {
fun isEmptyInput(input: String): Boolean {
return input.isEmpty()
}
fun validateInput(input: String) {
if (isEmptyInput(input)) {
throw IllegalArgumentException("입력이 잘못되었어요.")
}
}
}
User 객체를 통해 사용자에게 입력을 받은 뒤 Car 리스트를 생성해 전달한다.
그리고 입력값에 대한 유효성 검사 처리를 Validator 에게 맡겼다.
여기서 Validator의 단위 테스트를 아래와 같이 작성했다.
class ValidatorTest {
@Test
fun `빈 문자열인 경우 True 반환`() {
val input = ""
val result = Validator.isEmptyInput(input)
assertThat(result).isTrue()
}
@Test
fun `빈 문자열이 아닐 경우 False 반환`() {
val input = ","
val result = Validator.isEmptyInput(input)
assertThat(result).isFalse()
}
@Test
fun `빈 문자열인 경우 예외 발생`() {
val input = ""
val exception = assertThrows<IllegalArgumentException> { Validator.validateInput(input) }
assertThat(exception.message).isEqualTo("입력이 잘못되었어요.")
}
@Test
fun `빈 문자열이 아닌 경우 예외 발생하지 않음`() {
val input = "테스트"
assertDoesNotThrow { Validator.validateInput(input) }
}
}
그런데 Validator의 유효성 검사를 하기 위한 세부적인 메서드들까지 단위 테스트를 진행하는 것이 불필요하다는 생각이 들었다.
Validator의 역할은 유효성 검사가 성공하면 예외를 발생시키고, 실패하면 예외를 발생시키는 것이다.
인풋에 따라 예상되는 결과만을 테스트하는 목적으로 단위 테스트를 하는 것이 자연스럽다고 생각했다.
즉, 모든 메서드에 대한 단위 테스트가 아닌 해당 객체가 수행해야 하는 목적을 중심으로 테스트 코드를 작성하였다.
class ValidatorTest {
@Test
fun `빈 문자열인 경우 예외 발생`() {
val input = ""
val exception = assertThrows<IllegalArgumentException> { Validator.validateInput(input) }
assertThat(exception.message).isEqualTo("입력이 잘못되었어요.")
}
@Test
fun `빈 문자열이 아닌 경우 예외 발생하지 않음`() {
val input = "테스트"
assertDoesNotThrow { Validator.validateInput(input) }
}
}
이처럼 isEmptyInput 메서드처럼 세세한 로직까지 테스트하는 것이 아닌
사용자의 입력값에 따라 예외가 발생될 수 있는 상황에 대해서만 단위 테스트의 범위를 설정했다.
이렇게 객체가 수행해야 하는 책임과 역할을 기준으로 단위 테스트의 범위를 설정할 수 있다는 것을 알게 되었다.
테스트 코드 작성과 유지 또한 비용이 많이 드는 작업이라고 봤는데
객체가 자기 역할을 수행하는데 중요한 로직을 중심으로 인풋과 아웃풋에 초점을 맞춰
테스트 코드를 작성하는 것이 테스트 비용을 낮추는 행위가 아닐까 하다.
테스트 코드 vs 기능 구현
테스트 코드를 먼저 작성하는 것인지 기능 구현을 먼저 작성하는 것인지 과제를 수행하고 나서도 감이 잘 안온다.
TDD 방법론의 순서를 보면 아래와 같은데 그럼 테스트 코드를 먼저 작성해야 하는 것일까?
일차원적으로 그래야 하는 것보다 단위 테스트에 대한 범위처럼 인풋과 예상되는 결과에 따라서
어떤 테스트를 진행해야 하는지 구상하는 것이지 아닐까 싶다.
그리고 기능 구현을 함과 동시에 테스트 코드를 작성하고 기능 구현을 완료한 뒤 테스트를 실행하는 것이 아닐까 싶다.
과제를 하면서는 설계한대로 기능 구현을 어느정도 하고 테스트 코드를 작성한 뒤 테스트를 진행했다.
그리고 리팩토링을 하면서 조금이나마 테스트 코드를 왜 작성해놓는지 알 것 같았다.
리팩토링을 하더라도 테스트의 결과는 변하면 안되었다.
즉, 객체가 수행하는 일이 바뀌지 않는 이상 예상되는 결과는 의도한대로 일어나야 한다.
만약 테스트의 결과가 이전과 다르다면 그건 애초에 테스트 코드를 잘못 작성했거나 리팩토링을 실패했다는 뜻이다.
따라서 테스트 코드가 안전하다는 가정 하에서 리팩토링에 대한 부담을 덜 수 있을 것 같았다.
이러한 '기능 구현 -> 테스트 -> 리팩토링 -> 테스트' 사이클을 반복할수록 코드에 대한 품질을 올리고
작업에 대한 효율성과 안정성을 가져올 수 있다는 것을 배웠다.
'과제 > 우테코 6기 프리코스' 카테고리의 다른 글
우테코 6기 프리코스 안드로이드 - 3주차 미션 (0) | 2023.11.08 |
---|---|
우테코 6기 프리코스 안드로이드 - 1주차 미션 (0) | 2023.10.25 |