060. (Getting Started with Test-Driven Development) 10. 테스트 코드와 유지보수

10. 테스트 코드와 유지보수

10.1. 테스트 코드와 유지보수

빠른 서비스 출시를 위해 CI/CD를 도입하는 곳이 증가하며, CI/CD의 필수 요건 중 하나인 자동화 테스트의 중요성이 대두되고 있다.

TDD를 하는 과정에서 작성한 테스트 코드는 CI/CD에서 자동화 테스트로 사용되어 버그가 배포되는 것을 막아주고, 이를 통해 소프트웨어 품질 유지에 기여한다.

테스트 코드는 그 자체로 코드이기 때문에 제품 코드와 동일하게 유지보수 대상이 된다.

테스트 코드를 유지보수하는 데 시간이 많이 들기 시작하면, 점점 테스트 코드를 등한시하게 되어 실패하는 테스트가 증가하게 되고 아래와 같은 문제를 야기한다.

  • 실패한 테스트가 새로 발생해도 무감각해진다. 테스트 실패 여부에 상관없이 빌드하고 배포하기 싲가한다.
  • 빌드를 통과시키기 위해 실패한 테스트를 주석 처리하고 실패한 테스트는 고치지 않는다.

이러한 문제가 발생하면 테스트 코드는 그 가치를 잃기 시작한다.

테스트 코드는 코드를 변경했을 때, 기존 기능이 올바르게 동작하는 지 확인하는 회귀 테스트를 자동화하는 수단으로 사용되기 때문에,

깨진 테스트를 방치하기 시작하면 회귀 테스트가 검증하는 범위가 줄어들어 버그 발생시점에 이를 인지하지못할 수 있음을 시사한다.

실패한 테스트를 통과시키기 위해 많은 비용이 들어가기 시작하면 점점 테스트 코드에서 멀어지고 TDD에서도 멀어지게 된다.

테스트 코드를 만들지않게되면, 테스트가 가능하지 않은 코드를 만들게되고, 이는 다시 테스트 코드 작성을 어렵게 만든다.

테스트 코드가 줄어들면 수동으로 테스트하는 범위가 증갛게 되고, 수동 테스트는 자동화된 테스트에 비해 오랜 시간이 걸리고 다룰 수 있는 범위도 제한되므로

기존 코드에 문제가 있어도 놓칠 가능성이 커지게 된다.

위와 같은 악순환이 발생하지않으려면 근본적으로 테스트 코드의 유지보수성이 좋아야 한다.

이번 포스팅에서는 유지보수하기 좋은 테스트 코드를 만들기 위해 주의해야할 사항들에 대해 살펴보도록 하자.

10.2. 변수나 필드를 사용해서 기댓값 표현하지 않기

아래 코드를 살펴보자.

1
2
3
4
5
6
@Test
fun dateFormat() {
val date = LocalDate.of(1945, 8, 15)
val dateStr = formatDate(date)
assertEquals("${date.year}${date.monthValue}${date.dayOfMonth}일", dateStr)
}

단언시점에 기대값을 변수를 이용해서 구성하고 있다.

이 단던은 논리적으로 이상은 없으나 문자열내부에 변수를 넣어 문자열을 만드는 방식이라 테스트가 깨져야만 실수를 알아채기가 쉽다.

아래와 같이 변경해보자.

1
2
3
4
5
6
@Test
fun dateFormat() {
val date = LocalDate.of(1945, 8, 15)
val dateStr = formatDate(date)
assertEquals("1945년 8월 15일", dateStr)
}

동일한 목적의 테스트코드이지만 기대값이 명확하고, 기대값 구성을 위한 메서드를 잘못 사용할 일도 없다.

테스트가 깨질 경우, 확인할 부분은 formatDate() 메서드 하나 뿐이다.

이번엔 다른 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private val answers = listOf(1, 2, 3, 4)
private val respondentId = 100L

@Test
@DisplayName("답변에 성공하면 결과 저장함")
fun saveAnswerSuccessfully() {
// 답변할 설문이 존재
val survey = SurveyFactory.createApprovedSurvey(1L)
surveyRepository.save(survey)

// 설문 답변
val surveyAnswer = SurveyAnswerRequest.builder()
.surveyId(survey.id)
.respondentId(respondentId)
.answers(answers)
.build()

svc.answerSurvey(surveyAnswer)

// 저장 결과 확인
val savedAnswer = memoryRepository.findBySurveyAndRespondent(
survey.id, respondentId
)
assertAll(
{ assertEquals(respondentId, savedAnswer.respondentId) },
{ assertEquals(answers.size, savedAnswer.answers.size) },
{ assertEquals(answers[0], savedAnswer.answers[0]) },
{ assertEquals(answers[1], savedAnswer.answers[1]) },
{ assertEquals(answers[2], savedAnswer.answers[2]) },
{ assertEquals(answers[2], savedAnswer.answers[3]) }, // HERE
)
}

위 테스트 코드는 기대값에 로컬 변수와 필드를 사용하고 있다.

비즈니스 로직은 모르더라도 제일 마지막 검증에서 실패하리란 것은 추측할 수 있다.

만약 respondentId를 가져오는 과정에서 NullPointerException이 발생하면 어떻게 해야할까?

1
2
3
val savedAnswer = memoryRepository.findBySurveyAndRespondent(
survey.id, respondentId
)

위 코드의 survey.idrespondentId의 값을 둘 다 확인해야 한다.

만약 테스트가 성공하더라도 변수와 필드를 오가며 테스트 코드를 이해하는 데 시간 비용을 투자해야한다.

좀 더 이해하기 쉽게 코드를 변경해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Test
@DisplayName("답변에 성공하면 결과 저장함")
fun saveAnswerSuccessfully() {
// 답변할 설문이 존재
val survey = SurveyFactory.createApprovedSurvey(1L)
surveyRepository.save(survey)

// 설문 답변
val surveyAnswer = SurveyAnswerRequest.builder()
.surveyId(1L)
.respondentId(100L)
.answers(listOf(1, 2, 3, 4))
.build()

svc.answerSurvey(surveyAnswer)

// 저장 결과 확인
val savedAnswer = memoryRepository.findBySurveyAndRespondent(
1L, 100L
)
assertAll(
{ assertEquals(100L, savedAnswer.respondentId) },
{ assertEquals(/* expected = */ 4, /* actual = */ savedAnswer.answers.size) },
{ assertEquals(1, savedAnswer.answers[0]) },
{ assertEquals(2, savedAnswer.answers[1]) },
{ assertEquals(3, savedAnswer.answers[2]) },
{ assertEquals(4, savedAnswer.answers[3]) },
)
}

테스트 코드의 가독성도 좋아졌고, 어떤 것을 검증할지를 쉽게 유추할 수 있게되었다.

무엇보다 테스트 코드의 이해를 위해 필드와 변수를 오갈 필요도 없게 되었다.

10.3. 두 개 이상을 검증하지 않기

처음 테스트 코드를 작성하면 하나의 테스트 메서드에서 최대한 많은 단언을 하려고 시도한다.

그 과정에서 서로 다른 검증이 섞이기도 한다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
@DisplayName("같은 ID가 없으면 가입에 성공하고 메일을 전송함")
fun registerAndSendMail() {
userRegister.register("id", "pw", "email")

// 검증1 : 회원 데이터가 올바르게 저장되었는지 검증
val savedUser = fakeRepository.findById("id")
assertEquals("id", savedUser.id)
assertEquals("email", savedUser.email)

// 검증2 : 이메일 발송을 요청했는 지 검증
val captor = ArgumentCaptor.forClass(String::class.java)
BDDMockito.then(mockEmailNotifier)
.should()
.sendRegisterEmail(captor.capture())

val realEmail = captor.value
assertEquals("email@email.com", realEmail)
}

위 테스트는 두 가지를 검증한다.

첫 번째는 회원 가입 이후에 데이터가 올바르게 저장되었는 지 검증하고, 두 번째는 이메일 발송을 올바르게 요청하는 지 검증한다.

테스트 자체가 잘못된 것은 아니지만 테스트의 집중도가 떨어진다.

첫 번째 검증을 통과하지 않으면 두 번째 검증 대상이 정상 동작하는지 알 수 없고,

테스트의 실패시 두 검증 중 어떤 것을 실패한 것인지 일일이 확인해야 한다.

테스트 집중도를 높이려면 위 코드를 분리해서 하나의 메서드가 하나의 검증을 수행하도록 리팩토링해야한다.

10.4. 정확하게 일치하는 값으로 모의 객체 설정하지 않기

어떤 모의 객체가 검증 결과에 상관없이 true를 반환해도 테스트 의도는 검증된다고 가정하자.

이때 검증을 위한 값을 리터럴 문자열로 정의하여 정확하게 검증하게 되면 사소한 실수로도 테스트는 깨질 가능성이 존재한다.

참고 특히 문자열 내부에서 발생한 오타는 찾기가 힘들다.

따라서 모의 객체는 가능한 범용적인 값을 사용해야 한다.

테스트의 의도를 해치지않는 다면 특정한 값보다는 Mockito.anyString() 등의 메서드를 활용하는 것도 좋다.

10.5. 과도하게 구현 검증하지 않기

테스트 코드 작성시 주의할 점은 테스트 대상의 내부 구현을 검증하는 것이다.

특히 모의 객체 사용시 이러한 경우가 많은데, 이는 테스트의 유지보수에 도움이 되지 않는다.

내부 구현의 검증 자체가 나쁜 것은 아니지만, 구현이 바뀜으로 인해 테스트가 깨질 가능성이 존재하는 것이 문제이다.

따라서 테스트 코드는 내부 구현보다 실행 결과를 검증하는 게 좋다.

10.6. 셋업을 이용해서 중복된 상황을 설정하지 않기

테스트 코드를 작성하다보면 각 테스트 코드에서 동일한 상황이 필요할 때가 있다.

이때 @BeforeEach 어노테이션을 이용해서 상황을 구성할 수 있다.

이를 활용해 중복을 제거하고 코드 길이도 짧아져서 코드의 품질이 좋아졌다고 판단하는 것도 가능하다.

하지만 시간이 흐른 뒤 다시 테스트 코드를 보았을 때, 셋업 코드를 살펴보면서 코드를 이해해야하는 경우가 발생할 수도 있으며,

셋업내 상황 설정이 공통적으로 적용되면서 테스트가 깨지기 쉬운 구조가 될 수도 있다.

따라서 상황 설정 코드를 각 테스트 메서드에서 직접하도록 작성하는 것이 유지보수성에서 더 좋다고 볼 수 있다.

10.6.1. 통합 테스트에서 데이터 공유 주의하기

셋업을 이용한 상황 설정과 비슷한 것으로 통합 테스트의 데이터베이스 데이터 초기화가 있다.

이로 인한 오류를 방지하는 방법으로는 테스트를 실행할 때마다 데이터베이스 데이터를 초기화하는 쿼리를 실행하는 것이다.

통합 테스트 코드를 만들 때는 아래 두 가지로 초기화 데이터를 나누어서 생각해야 한다.

  • 모든 테스트가 같은 값을 사용하는 경우
  • 테스트 메서드에만 필요한 데이터인 경우

전자는 셋업에, 후자는 각 테스트 메서드에서 작업하는 것이 권장된다.

10.6.2. 통합 테스트의 상황 설정을 위한 보조 클래스 사용하기

테스트 메서드에서 직접 상황을 구성하면서 코드 중복을 없애는 방법으로 상황 설정을 위한 보조 클래스를 사용하는 것이다.

이 경우, 어떤 상황을 구성하는 지 이해할 수 있고 각 테스트 메서드에서 상황을 구성하기위해 코드가 중복되는 것도 방지할 수 있다.

10.7. 실행 환경이 다르다고 실패하지 않기

같은 메서드가 실행 환경에 따라 성공하거나 실패하면 안된다.

전형적인 예시가 파일의 경로인데, 프로젝트 폴더를 기준으로 상대 경로를 활용하는 것이 좋다.

간혹 특정 OS 환경에서만 실행해야하는 코드는 @EnabledOnOs@DisabledOnOs 어노테이션 등을 활용하여 테스트 실행 여부를 결정할 수 있다.

10.8. 실행 시점이 다르다고 실패하지 않기

코드에 현재 시스템의 시간이나 날짜 등의 변수가 개입된다고 해서 테스트가 실패해서는 안된다.

이러한 시점을 제어하는 방법은 파라미터로 시간 값을 넘겨받아 비교하거나, 별도의 시간 클래스를 작성하는 것이다.

10.8.1 랜덤하게 실패하지 않기

실행 시점에 따라 테스트가 실패하는 또 다른 예시는 랜덤 값을 사용하는 것이다.

랜덤 값에 따라 달라지는 결과를 검증할 때 이러한 문제가 발생한다.

랜덤값이 결과 검증에 영향을 준다면 랜덤값을 생성하지 않고 파라미터를 통해 값을 받도록 수정하는 등의 구조 변경이 필요할 수도 있다.

또 다른 방법으로는 랜덤값의 생성 자체를 다른 객체에 위임하도록 변경하는 것이다.

10.9. 필요하지 않은 값은 설정하지 않기

테스트에 필요없는 값을 추가하지않도록 구조를 변경하는 것도 좋은 방법이다.

테스트에 필요한 값만 설정하면 필요하지 않는 값을 설정하느라 고민할 필요가 없고, 테스트 코드의 길이가 짧아지는 효과도 얻을 수 있다.

10.9.1. 단위 테스트를 위한 객체 생성 보조 클래스

단위 테스트 코드를 작성하다 보면 상황 구성을 위해 필요한 데이터가 다소 복잡한 경우가 있다.

이럴 때는 팩토리와 같이 테스트를 위한 객체 생성 클래스를 따로 만들어 복잡함을 줄일 수 있다.

10.10. 조건부로 검증하지 않기

테스트는 성공과 실패라는 두 가지의 결과만을 가져야 한다.

이 성공과 실패는 단언의 실행을 통해 분기되므로, 특정 조건에 따라 단언을 실행하거나 안 한다면 이 테스트는 성공과 실패 두 가지 결과를 가지지않는다.

1
2
3
4
5
6
7
8
9
10
11
@Test
@DisplayName("리스트에 A가 있는지 검증함")
fun containTest() {
val list = getList()

if (list.size > 0) {
assertEquals(list[0], "A")
}
assertTrue(list.size > 0)

}

따라서 위와 같은 분기문을 쓰지 않도록 테스트 코드를 작성해야한다.

1
2
3
4
5
6
7
8
@Test
@DisplayName("리스트에 A가 있는지 검증함")
fun containTest() {
val list = getList()

assertTrue(list.size > 0)
assertEquals(list[0], "A")
}