056. (Getting Started with Test-Driven Development) 6. 테스트 코드의 구성

6. 테스트 코드의 구성

6.1. 기능에서의 상황

기능은 주어진 상황에 따라 다르게 동작한다.

예를 들어 다음 기능을 보자.

  • 파일에서 숫자를 읽어와 숫자의 합을 구한다.
  • 한 줄마다 한 개의 숫자를 포함한다.

이 기능을 MathUtils.sum() 메서드로 구현하가 고정해보자.

아래 처럼 sum() 메서드에 파일을 파라미터로 전달하게 되고, sum() 메서드는 파라미터로 전달받은 파일에서 한 줄씩 읽어와 숫자로 변환한 뒤 합계를 계산할 것이다.

언뜻 간단해보이지만 추가로 고려해야할 부분이 있다.

먼저, 파일이 없는 경우 예외를 발생시키거나 문제 상황을 결과로 반환해야한다.

두 번째, 데이터 중에 숫자가 아닌 잘못된 데이터가 존재하는 경우에도 이에 맞는 결과를 생성해야 한다.

이처럼 주어진 상황에 따라 기능 실행 결과를 달라지며, 이는 테스트 코드 구조에도 영향을 준다.

6.2. 테스트 코드의 구성 요소 : 상황, 실행, 결과 확인

테스트 코드는 기능을 실행하고 그 결과를 확인하므로 상황, 실행, 결과 확인의 세 가지 요소로 테스트 코드를 구성할 수 있다.

따라서 어떠한 상황이 주어지고, 그 상황에서 기능을 실행하고, 실행한 결과를 확인하는 세 가지가 테스트 코드의 기본 골격이다.

참고 상황, 실행, 결과 확인은 각각 given, when, then에 대응한다.

JUnit에서 상황을 설정하는 방법은 테스트할 대상에 따라 달라진다.

숫자 야구 게임을 예시로 들어보자.

숫자 야구 게임을 구현한 BaseballGame 클래스를 객체를 생성하는 시점에 정답 숫자를 지정한다.

이 경우 아래와 같이 테스트 메서드마다 객체를 생성해서 상황을 설정할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class BaseballGameTest {

@Test
fun exactMatch() {
// 정답이 456인 상황
val game = BaseballGame("456")
// 실행
val score = game.guess("456")
// 결과 확인
assertEquals(3, score.strikes)
assertEquals(0, score.balls)
}

@Test
fun noMatch() {
// 정답이 123인 상황
val game = BaseballGame("123")
// 실행
val score = game.guess("456")
// 결과 확인
assertEquals(3, score.strikes)
assertEquals(0, score.balls)
}
}

또 다른 방법은 @BeforeEach를 적용한 메서드에서 상황을 결정하는 것이다.

이 경우 주로 상황 설정과 관련된 대상을 필드로 보관한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class BaseballGameTest {

lateinit var game: BaseballGame

@BeforeEach
fun givenGame() {
game = BaseballGame("456")
}

@Test
fun exactMatch() {
// 실행
val score = game.guess("456")
// 결과 확인
assertEquals(3, score.strikes)
assertEquals(0, score.balls)
}
}

다만 실행 결과가 항상 리턴값으로 존재하는 것은 아니다.

예외가 발생하는 것이 정상인 경우도 존재하기 때문이다.

예를 들어 야구 게임 생성 기능의 테스트 코드는 정답 숫자에 동일한 숫자가 존재하면 게임 생성에 실패해야 한다.

이 경우 게임 생성 실패 결과를 표시하기 위해 BaseballGame 생성자가 IllegalArgumentException 예외를 발생시킬 수도 있다.

1
2
3
4
5
6
7
@Test
@DisplayName("정답 숫자에 동일한 숫자가 있는 경우")
fun genGameWithDupNumberThenFail() {
assertThrows(IllegalArgumentException::class.java) {
BaseballGame("110")
}
}

6.3. 외부 상황과 외부 결과

상황 설정이 테스트 대상으로 국한된 것은 아니다.

상황에는 외부 요인도 있다.

MathUtils.sum() 메서드 코드로 다시 살펴보자.

1
2
val dataFile = File("file.txt")
val sum: Long = MathUtils.sum(dataFile)

위 코드를 테스트하기 위해 파일이 존재하지않는 상황에서의 결과도 확인해야한다.

그렇다면 어떻게 파일이 존재하지않는 상황을 만들 수 있을까?

제일 쉬운 방법은 존재하지않는 파일을 경로로 사용하는 것이다.

1
2
3
4
5
6
7
8
@Test
@DisplayName("데이터가 없을 경우 예외를 발생시킨다.")
fun noDataFileThenException() {
val dataFile = File("badpath.txt")
assertThrows(IllegalArgumentException::class.java) {
MathUtils.sum(dataFile)
}
}

하지만 이는 완벽하지않다.

분명히 파일이 존재하지않는 경로였는데, 우연히 해당 위치에 동일한 이름을 가진 파일이 생성되는 경우를 보장할 수 없기 때문이다.

따라서 아래와 같이 명시적으로 파일이 없는 상황을 만들어 더욱 확실하게 처리하는 것이 좋다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
@DisplayName("데이터가 없을 경우 예외를 발생시킨다.")
fun noDataFileThenException() {
givenNoFile("badpath.txt")

val dataFile = File("badpath.txt")
assertThrows(IllegalArgumentException::class.java) {
MathUtils.sum(dataFile)
}
}

private fun givenNoFile(path: String) {
val file = File(path)
if (file.exists()) {
var deleted = file.delete()
if (deleted.not()) {
throw RuntimeException("fail givenNoFile : $path")
}
}
}

마찬가지로 파일이 존재하는 상황도 모사할 수 있다.

상황에 알맞는 파일을 미리 만들어두면 되는 것이다.

6.3.1. 외부 상태에서 테스트 결과에 영향을 주지 않게 하기

테스트 코드는 한 번만 실행하고 끝나지 않는다.

TDD를 진행하는 동안에도 계속 실행하고, 개발이 끝난 이후에도 반복적으로 테스트를 실행해서 문제가 없는 지 검증한다.

그렇게 때문에 테스트는 언제 실행되더라도 정상적으로 동작하는 것이 중요하다.

간헐적으로 실패하는 테스트는 결국 테스트 결과에 대한 불신으로 이어진다.

외부 상태에 따라 테스트의 성공 여부가 바뀌지 않으려면 테스트 실행 전에 외불르 원하는 상태로 만들거나,

테스트 실행 후에 외부 상태를 원래대로 되돌려 놓아야 한다.

6.3.2. 외부 상태와 테스트 어려움

상황과 결과에 영향을 주는 외부 요인은 파일, DBMS, 외부 서버 등 다양하다.

이들 외부 환경을 테스트에 맞게 구성하는 것이 항상 가능한 것은 아니다.

만약 테스트를 위해 특정 RESTful API를 사용하는 경우 아래와 같은 경우도 상정해야 한다.

  • API 서버에 연결할 수 없는 상황
  • API 서버에서 응답을 5초 이내에 받지 못하는 상황

이처럼 테스트 대상이 아닌 외부 요인은 테스트 코드에서 다루기 힘든 존재이다.

이럴 경우 대역을 통해 테스트 대상이 의존하는 대상의 실제 구현을 대치할 수 있는 방법이 존재한다.

자세한 내용은 추후 포스팅에서 알아보도록 하자.