티스토리 뷰
💡 넥스트 스탭의 TDD, 클린 코드 with Kotlin 8기 강의를 수강하며 정리한 내용입니다.
목차
배경
최근 넥스트 스탭의 TDD, 클린 코드 with Kotlin 8기 강의를 수강하고 있다. 아직 1주차 밖에 지나지 않았지만, 1주차 과제 중 테스트 코드 작성과 관련되어 리뷰어분께 좋은 내용을 전달받았고, 덕분에 많은 생각을 할 수 있게 되었다.
먼저, 당시 나는 콘솔 출력을 포함하는 로직은 테스트 코드로 어떻게 검증할 수 있을지, 테스트 코드 작성을 어떻게 해야할지 헤맸고 리뷰어분에게 도움을 요청했다. 리뷰어분은 해결 방법을 알려주기에 앞서 '왜 테스트 코드를 작성해야하는가?'에 대한 애기를 해주었다. 테스트 코드가 무엇을 확인하기 위함이고, 그게 테스트 코드로 확인이 필요할만큼 복잡하고 중요한 로직인지 먼저 생각하라는 의견을 주었다.
테스트 코드를 작성하는 이유
단위 테스트를 포함하여 테스트 코드를 작성하는 이유는 무엇일까? 내 생각은 '매번 손과 눈으로 데이터를 검증히는 반복 과정을 자동으로 검증하자.'는 것이다. 또한 이를 통해 사내 정책을 테스트 코드와 테스트 데이터로 기록할수 있고, 리팩토링의 부담감을 줄이며 유지보수를 용이하게 할 수 있다. 결국 직접 프로그램을 실행시켜보기에 앞서, 불안한 요소를 눈으로 확인하여 검증할 수 있다는 장점이 있다.
테스트 코드 작성이 어려울 때
그렇다면 무엇이 테스트 코드 작성을 어렵게할까? 모든 로직을 검증할 수 있도록 테스트 코드를 작성하는 것과 그러한 시도는 중요하다. 하지만 이를 불필요한 테스트까지 코드로 작성하는 것과 혼동하면 안된다. 불필요한 테스트들이 줄어들수록 남아있는 테스트들의 중요성이 더욱 부각된다.
테스트 코드 작성이 어려운 이유는 내가 분명히 무엇을 테스트하고자 하는지 명확히 모르기 때문일 수 있다, 먼저 테스트 코드로 확인해보고자 하는 로직이 명확하게 무엇인지 그리고 그것이 테스트코드로써 검증이 필요한지를 고민해본다.
예시
예를 들어, 다음과 같이 콘솔 출력을 포함하는 로직이 있다. 아래 코드를 테스트 코드로 검증하러면 어떻게 해야할까? 출력 로직도 테스트 코드로써 검증해야할까? 검증해야한다면 어떻게 해야할까? 막막할 것이다.
class RaceClient(
private val inputView: InputView,
private val resultView: ResultView,
) {
fun startRace() {
// 자동차 대수 입력 받기
val carCount = inputView.inputCarCount()
// 시도 횟수 입력 받기
val tryTime = inputView.inputTryTime()
// 입력받은 carCount만큼 자동차 목록을 갖는 race 객체 생성
val race = Race(cars = Cars(carCount))
// 시도 횟수만큼 경주 반복
resultView.printStart()
for (i in 1..tryTime) {
// 결과 출력
resultView.printResult(race.cars)
// 자동차 경주 시작
val raceResult = race.run(RandomNumbers(carCount))
// 결과 갱신
race = Race(cars = raceResult)
}
}
}
로직을 작성하기에 앞서 결과를 콘솔로 출력하는 것이 꼭 테스트 코드로 확인이 필요할만큼 중요하고 복잡한 로직일까? 고민해보자. 사실 테스트 코드로 검증이 필요한 것은 결과를 콘솔로 출력하는 것이 아닌, 결과 값 자체를 검증하는 것이다.
위 로직은 다음과 같이 로직 수행 결과 값을 반환하는 ReaceService클래스로 추출할 수 있다.
class RaceService(
val race: Race,
) {
fun execute(
tryTime: Int,
onResult: (Cars) -> Unit,
randomNumbers: List<RandomNumbers>,
): Race {
var raceByStep = race
for (i in 0..<tryTime) {
// 자동차 경주 시작
raceByStep = Race(raceByStep.run(randomNumbers[i]))
// 결과 출력
onResult(raceByStep.cars)
}
// 경주 결과 반환
return raceByStep
}
}
기존의 RaceClient은 경주 로직을 직접 작성하는 것이 아닌, 중간에 추가한 계층인 RaceService를 호출한다.
class RaceClient(
private val inputView: InputView,
private val resultView: ResultView,
) {
fun startRace() {
// 자동차 대수 입력 받기
val carCount = inputView.inputCarCount()
// 시도 횟수 입력 받기
val tryTime = inputView.inputTryTime()
// 입력받은 carCount만큼 자동차 목록을 갖는 race 객체 생성
val race = Race(cars = Cars(carCount))
val raceService = RaceService(race)
// 경주 시작
val raceReulst = raceService.execute(
tryTime = tryTime,
onResult = resultView::printResult,
randomNumbers = List(tryTime) { RandomNumbers(carCount) },
)
}
}
테스트 코드의 역할은 이 ReaceService의 execute() 메서드의 실행 결과인, 경주 결과를 자동으로 검증하는 것이다.
class RaceServiceTest {
@Test
fun `시도 횟수만큼 자동차 경주를 반복하고 결과를 확인한다`() {
val carCount = 3
val tryTime = 5
val raceService = RaceService(Race(Cars(carCount = carCount)))
val randomNumbers =
List(tryTime) {
RandomNumbers(
listOf(
// 1번 자동차만 한칸씩 이동한다.
MOVE_CONDITION,
MOVE_CONDITION - 1,
MOVE_CONDITION - 1,
),
)
}
val raceResult = raceService.execute(tryTime = tryTime, onResult = { }, randomNumbers = randomNumbers)
// 경기 결과 검증
assertThat(raceResult.cars.list)
.containsExactly(Car(distance = tryTime), Car(), Car())
}
companion object {
private const val MOVE_CONDITION = 4
}
}
결과
테스트 코드 작성이 어려울 때, 테스트 코드를 작성해야하는 이유를 고민해보고, 그것이 정말로 검증이 필요한지 고려해보았다. 예시와 함께 이 과정을 살펴보며 결과적으로 테스트 코드로 콘솔 출력을 검증하는 것이 아닌, 로직의 연산 결과를 테스트코드로써 검증할 수 있게 되었다.
또한 RaceClient 클래스는 View에 의존하여 입출력받은 값으로 RaceService 클래스에 경주 시작을 요청한다. 이때 실제 자동차 경주 실행은 RaceService 클래스에서 수행하게 된다. 결과적으로 테스트 코드 작성뿐만 아니라 SRP를 준수하며 객체지향적 설계도 가능해졌다.