검증 그 이상의 테스트 코드
서론
최근에 사내에서 실시한 기술 세미나 이른바 테코톡에서 테스트 코드를 주제로 발표를 하게되었다.
언제부턴가 테스트 코드가 없으면 불안함을 많이 느끼는 터라 테스트를 잘 짜기 위해선 무엇이 중요할지, 테스트 코드가 가져올 수 있는 긍정적인 영향은 무엇일지에 대해 많은 고민을 해왔었다.
그 과정에서 테스트 코드 리팩토링을 통해 속도, 메모리 등과 관련해 테스트 환경도 개선해보고, 테스트 커버리지 100% 적용, ATDD 등 다양한 경험을 해볼 수 있었다.
하지만 그렇다보니 준비 과정에서 오히려 하고 싶은 말이 너무 많아져서 발표 주제의 방향성이 모호해지는 걸 느꼈다.
그러다가 2023 인프콘에서 이민우님께서 발표하신 "인프런에서는 수천 개의 테스트 코드를 이렇게 다루고 있어요"를 보고 깊은 인상을 받아서 발표의 방향성을 잡는데 많은 참고를 할 수 있었다.
결국 하고 싶었던 얘기는 테스트는 단순한 검증 그 이상의 의미를 갖는다는 것이었다. (발표 제목도 "검증, 그 이상의 테스트 코드"였다)
이 글에서는 발표 준비 과정에서 정리했던 테스트 코드가 갖는 검증 이외의 기능과, 해당 기능을 최대한으로 누리기 위해서 실용적인 관점에서 어떻게 테스트를 개선할 수 있을지에 대해 정리한다.
물론 해당 내용은 전적으로 나의 자그마한 경험에서 우러나온 것이며, 이는 당연히 더 나은 의견에 의해 언제든지 대치가능하다.
기능 명세 역할을 하는 테스트 코드
우리는 특정 기능에 대한 성공 및 실패 케이스를 테스트 코드로 작성한다.
이렇게 쌓이는 테스트 코드들은 추후 다른 개발자가 해당 프로젝트에 랜딩할 때 기존 프로덕션 코드가 어떻게 동작하는지 알려주는 좋은 역할을 할 수 있다.
하지만 물론 테스트 코드의 "코드"만으론 이 역할을 다할 수 없다.
해당 테스트 코드가 무엇을 검증하기 위한 것인지, 더 나아가 테스트하고자 하는 프로덕션 코드가 무슨 역할을 하는 것인지 한 눈에 알아볼 수 있게 하는 무언가가 필요하다.
DisplayName 잘쓰기
Junit 환경에서는 @DisplayName
어노테이션이 해당 역할을 할 수 있다.
하지만, 때때로 이를 기능 명세의 측면이 아닌 개발자 "본인이 확인하기 위해서" 사용하는 경우를 보곤한다.
아래는 개인적으로 가장 많이 목격했던 두 가지 사례이다.
@Test
@DisplayName("메일 삭제 검증 성공 테스트")
void verifyMailDeletableTest() {
...
}
첫 번째는 기능 그 자체가 아닌 테스트의 결과를 명시하는 경우이다.
이런 식의 DisplayName
은, 프로덕션 코드의 목적은 설명할 수 있지만 "어떤 경우에 메일을 삭제할 수 있다는 것인지" 에 대한 구체적인 기능은 설명하지 못하고 있다.
이는 테스트 코드가 기능 명세로써의 역할을 적절히 수행하지 못하고 있는 것이다.
@Test
@DisplayName("메일 삭제 검증 - 소유자, True, 예외 X")
void verifyMailDeletableTest() {
...
}
두 번째 케이스이다.
조합해서 보면, 메일 소유자가 메일을 삭제할 수 있는지 테스트하는 것으로 보이고 리턴 값 및 예외 발생 여부를 명세하는 것으로 보인다.
하지만, 읽는 사람으로 하여금 기능을 추측하게 만든다는 것 자체가 적절하지 않은 기능명세라는 증거이기도 하며 이런 방식은 명세해야하는 로직이 조금만 복잡해져도 곧 작성자 본인만 알아볼 수 있게되는 경우가 많다.
개선
@Test
@DisplayName("메일 소유자는 메일을 삭제할 수 있다.")
void verifyMailDeletableTest() {
...
}
명확한 기능명세의 역할을 하기 위해선 DisplayName
이 개발 관점이 아닌 비즈니스 관점에서 표현되는 것이 중요하다.
코드를 전혀 모르는 사람도 알아보기 쉽고, 코드 내부의 구현이 어떻게 변하더라도 해당 요구사항이 없어지지 않는 한 해당 DisplayName
은 유지될 것이다.
이런 식의 테스트 코드는 프로덕션 코드와 함께 Merge Request(Pull Request)에 게시될 경우, 코드 리뷰에서도 매우 유리하다. (프로덕션 코드의 동작 방식을 예측할 수 있기 때문에)
물론 모든 코드가 이를 따를 순 없을 것이다.
하지만 적어도 비즈니스 로직을 담고 있는 코드라면 비즈니스 관점에서 작성하는 것이 가장 좋다.
@Test
@DisplayName("메일 소유자는 메일을 삭제할 수 있다.")
void ownerCanDeleteMail() {
...
}
@Test
@DisplayName("메일을 소유하지 않은 사용자는 메일을 삭제할 수 없다.")
void cannotDeleteMailIfNotOwner() {
...
}
@Test
@DisplayName("서비스 관리자는 자신의 소유가 아닌 메일도 삭제할 수 있다.")
void serviceAdminCanDeleteMail() {
...
}
개인적으론 프로덕션 코드 개발에 뛰어들기 전에, 위와 같이 구현하고자 하는 기능 명세를 먼저 테스트 코드에 작성하고 가면 정말 좋은 것 같다.
구현해야 하는 기능과 검증 사항이 매우 명확해지고 이는 곧 좋은 프로덕션 코드의 설계로 이어지기 때문이다.
코드 추가 / 수정에 자신감을 줄 수 있는 테스트 코드
테스트 코드는 회귀 버그를 탐지할 수 있게 해주고, 이는 곧 새로운 기능을 추가하거나 기존 기능을 변경하는 때에 영향 범위를 파악할 수 있게 해준다.
덕분에 잘 작성된 테스트 코드가 많아질수록 개발자들은 코드 추가 및 수정에 더 적은 부담을 느끼게 되며 이는 더욱 과감한 리팩토링 또한 가능하게 해주어 코드의 품질을 개선하는데 긍정적인 역할을 할 수 있다.
하지만, 이는 어디까지나 "신뢰할 수 있는 테스트" 아래에서 가능하다.
작성된 테스트가 많더라도 테스트가 무의미하거나 잘못된 결과를 내어 신뢰도가 저해되는 상황에서는 개발자의 자신감은 오히려 독이 된다.
테스트 코드의 이점을 누리기 위해선 테스트 코드의 신뢰성이 무엇보다도 보장되어야한다.
외부 코드에 의존하지 않는 테스트
테스트 코드는, 특히 단위 테스트에서는 테스트가 외부 코드에 직접적으로 의존하지 않도록 해야한다.
public class MailService {
private final MailAuthService mailAuthService;
public void getMail(String id) {
...
mailAuthService.verify(mail);
...
return mail;
}
}
public class MailAuthService {
public verify(Mail mail) {
...
if (mail.isNotAccessible()) throw new ResourceNotAuthorizedException();
}
}
위 코드를 보자.
MailService
는 메일을 조회하는 getMail()
메서드를 가지고 있고, 해당 메서드는 인가를 담당하는 것으로 보이는 MailAuthService
의 verify()
메서드를 호출한다.
MailAuthService
는 verify()
메서드에서 메일이 접근 불가능하면 ResourceNotAuthorizedException
을 뱉는다.
@Mock
MailAuthService mailAuthService;
@InjectMocks
MailService mailService;
@Test
@DisplayName("메일을 조회할 때 해당 메일에 접근할 수 없으면 예외가 발생한다.")
void exceptionIfMailNotAccessible() {
// given
given(mailAuthService.verify(mail))
.willReturn(ResourceNotAuthorizedException.class);
// when & then
assertThatThrownBy(() -> mailService.getMail(mail.getId()))
.isInstanceOf(ResourceNotAuthorizedException.class);
}
다음은 MailService.getMail()
을 테스트하기 위한 단위 테스트이다.
현재 구현된 두 개 클래스의 실제 동작을 참고하여, MailAuthService
의 verify()
메서드 호출 시 발생하는 Exception
을 그대로 모킹해놓았다.
테스트 코드는 별 문제없이 성공할 것이다.
하지만 우리가 검증하고자 하는 MailService.getMail()
자체에는 전혀 드러나있지 않는 ResourceNotAuthorizedException
이 테스트 코드 내에 그대로 침투해있다.
이는 외부 코드, 그러니깐 MailAuthService.verify()
의 코드가 추후 수정되는 경우 문제를 일으킬 수 있다.
public class MailAuthService {
public verify(Mail mail) {
...
if (mail.isNotAccessible()) throw new MailNotAuthorizedException(); // Exception 클래스 변경
}
}
만약 위처럼 MailAuthService.verify()
메서드가 뱉는 예외가 다른 클래스로 변경되었다고 가정해보자.
순식간에 기존 테스트 코드는 "절대로 발생하지 않는 상황"을 가정한 무의미한 테스트 코드가 된다.
더욱 더 큰 문제는 해당 테스트가 여전히 통과한다는 것이다.
따라서 개발자는 문제를 눈치채지 못하고 무의미한 테스트 코드를 방치하게 될 확률이 높으며, "접근 불가한 메일에 접근 시 예외가 발생한다" 라는 경우를 테스트로 잘 커버하고 있다는 환상에 빠지게 될지 모른다.
개선
궁극적인 해결책은 단위 테스트 대상과 통합 테스트 대상을 구분하여 테스트를 작성하는 것이다.
위와 같은 테스트는 MailAuthService
라는 외부 코드와의 상호작용이 필수적이며, 이런 상황에선 단위 테스트보단 아래와 같은 통합 테스트가 훨씬 적절한 방법일 것이다.
@SpringBootTest
@DisplayName("메일을 조회할 때 해당 메일에 접근할 수 없으면 예외가 발생한다.")
void exceptionIfMailNotAccessible() {
// given
Mail notAccessibleMail = new Mail();
// when & then
assertThatThrownBy(() -> mailService.getMail(notAccessibleMail.getId()));
}
거짓 양성 / 음성 경계하기
테스트 수행 시 무언가 문제가 발견되어 테스트가 실패할 때에 테스트를 양성, 그렇지 않고 무사 통과할때를 음성이라고 표현한다.
거짓 양성과 음성이라는 것은 말 그대로, 원래라면 실패해야할 테스트가 성공하고, 성공해야할 테스트가 실패하는 경우를 말한다.
테스트의 거짓 양성과 음성이 많아질수록 테스트의 신뢰도는 급격히 낮아지게 되고, 이는 곧 테스트의 품질을 낮추는 것이므로 테스트가 거짓 양성 혹은 음성을 뿜을 수 있는 상황을 경계해야한다.
개인적으로 겪었던 대표적인 상황은 Mockito 라이브러리의 any()
메서드를 무분별하게 사용했을 때였다.
@Test
@DisplayName("메일 번호와 사용자 번호로 메일을 조회한다.")
void findMailTest() {
// given
long mailNo = 1L;
long userNo = 2L;
given(mailRepository.findByNoAndUserNo(anyLong(), anyLong()))
.willReturn(new Mail(mailNo, userNo));
// when
Mail mail = mailService.getMail(mailNo, userNo);
// then
assertThat(mail.getNo()).isEqualTo(mailNo);
assertThat(mail.getUserNo()).isEqualTo(userNo);
}
위와 같은 테스트 코드가 있다고 가정해보자.
mailRepository
의 findByNoAndUserNo()
메서드에 파라미터로 anyLong()
메서드를 사용해 Mocking을 해주었다.
위와 같은 모킹 방식은 mailRepository.findByNoAndUserNo()
를 호출할 때, 그 값이 무엇이든간에 파라미터로 long
타입을 넘겨주기만 하면 willReturn
에 정의된 객체를 반환해주도록 만든다.
더불어 when절의 mailService.getMail()
을 호출할 때, mailNo
, userNo
순서로 파라미터를 넘겨주고 있다는 점을 주목하자.
public class MailService {
private final MailRepository mailRepository;
// userNo, mailNo 순으로 파라미터 받음
public Mail getMail(long userNo, long mailNo) {
Mail mail = mailRepository.findByNoAndUserNo(mailNo, userNo);
return mail;
}
}
하지만 실제 프로덕션 코드를 보면 테스트 코드와는 다르게 userNo
, mailNo
순으로 파라미터를 받도록 정의되어있다.
다시 말해, 테스트 코드가 잘못 작성된 것이다.
우리의 기대대로라면 잘못 작성된 테스트 코드는 실패해야하지만, mailRepository
의 findByNoAndUserNo
는 값이 뒤바뀌든말든 long
타입 파라미터 두 개를 받았으므로 willReturn
에 정의된 Mail
객체를 반환할 것이다.
즉, 잘못된 테스트 코드가 통과하게 되는 거짓 음성이 발생하게 된다.
개선
간단하지만 확실한 방법은, 가능한 any()
사용을 지양하는 것이다.
@Test
@DisplayName("메일 번호와 사용자 번호로 메일을 조회한다.")
void findMailTest() {
// given
long mailNo = 1L;
long userNo = 2L;
// any() 대신 직접적인 값 명시
given(mailRepository.findByNoAndUserNo(mailNo, userNo))
.willReturn(new Mail(mailNo, userNo));
// when
Mail mail = mailService.getMail(mailNo, userNo);
// then
assertThat(mail.getNo()).isEqualTo(mailNo);
assertThat(mail.getUserNo()).isEqualTo(userNo);
}
물론, 이는 자바의 기본 타입이 아닌 직접 정의한 객체를 사용할 때는 생각보다 까다로울 수 있다. (equals
를 모두 정의해줘야하기 때문)
무작정 특정 메서드를 사용하지 말자는 취지보다는 테스트 코드가 거짓 음성 혹은 양성을 일으킬 여지가 있는지 면밀히 살펴보고 이를 방지하는 것이 중요하다는 점을 강조하고 싶다.
테스트 격리하기
바둑을 두는 사람에게는 단수에 대한 본능이 있다고 한다면, 개발자들에겐 DRY(Do not Repeat Yourself)한 코드에 대한 본능이 있는 것 같다.
각 테스트에 직간접적으로 영향을 미치는 코드들임에도 반복된다는 이유로 공통된 변수 및 클래스로 선언하는 상황들이 많다.
private long ownerNo = 1L;
@BeforeEach
void beforeEach() {
ownerRepository.save(new Owner(ownerNo));
mailRepository.save(new Mail(3L));
}
@Test
@DisplayName("메일의 소유자가 아닌 사람은 메일을 조회할 수 없다.")
void cannotGetMailIfNotOwner() {
// when
Optional<Mail> mail = mailService.findByOwner(ownerNo);
// then
assertThat(mail).isEmpty();
}
@Test
@DisplayName("존재하지 않는 사용자의 메일은 조회할 수 없다.")
void cannotGetMailIfNotExist() {
// when
Optional<Mail> mail = mailService.findByOwner(2L);
// then
assertThat(mail).isEmpty();
}
위 테스트 코드에선 @BeforeEach
를 통해 테스트에 필요한 데이터들을 저장해주고 있고, ownerNo
와 같은 테스트에 직접적인 영향을 끼치는 변수 또한 테스트 블록 외부에 정의되어있다.
위와 같은 테스트 코드는 몇 가지 문제가 발생할 여지가 있다.
첫 번째는 테스트 코드가 정상적으로 작성되었는지 확인하기 위해선 외부 코드를 참고해야한다는 것이다.
"소유자의 것이 아닌 메일" 혹은 "존재하지 않는 메일"과 같은 테스트 데이터들이 정상적으로 세팅되어져있는지 테스트 코드 블록 내에서 확인할 수 없다.
이는 테스트의 가독성을 크게 떨어트릴 수 있다.
두 번째는 테스트가 외부의 변수에 의해 영향을 받는다는 것이다.
@BeforeEach
뿐 아니라, 외부에 정의된 ownerNo
와 같은 값도 만약 현재의 1L
이 아닌 2L
로 변경되면 테스트의 성공/실패 여부가 달라지게 될 것이다.
특히나 해당 값을 사용하는 테스트 코드들이 많아지면 많아질수록 해당 변수는 더 이상 수정 혹은 제거하기 부담스러운 값이 되고 만다.
개선
테스트에서도 DRY한 코드를 유지하는 것은 똑같이 중요하지만 그보다 더 중요한 것은 DAMP(Descriptive And Meaningful Phrase)한 코드를 유지하는 것이다.
@Test
@DisplayName("메일의 소유자가 아닌 사람은 메일을 조회할 수 없다.")
void cannotGetMailIfNotOwner() {
// given
long ownerNo = 1L;
long notOwnerNo = 2L;
ownerRepository.save(new Owner(ownerNo));
ownerRepository.save(new Owner(notOwnerNo));
mailRepository.save(new Mail(ownerNo));
// when
Optional<Mail> mail = mailService.findByOwner(notOwnerNo);
// then
assertThat(mail).isEmpty();
}
@Test
@DisplayName("존재하지 않는 사용자의 메일은 조회할 수 없다.")
void cannotGetMailIfOwnerNotExist() {
// given
long ownerNo = 1L;
ownerRepository.save(new Owner(ownerNo));
mailRepository.save(new Mail(ownerNo));
// when
long notExistOwnerNo = 2L;
Optional<Mail> mail = mailService.findByOwner(notExistOwnerNo);
// then
assertThat(mail).isEmpty();
}
비록 더미 데이터를 저장하는 코드나 변수들에 대해 공통되는 코드들이 여럿있지만 테스트가 서로 간에 영향을 끼치지 않으며 동시에 외부 코드에 의해 영향을 받지 않는 형태가 되었다.
또한, 오히려 테스트의 일부분을 조금만 수정하면 다양한 테스트 케이스들에 대한 테스트 코드를 쉽게 작성할 수 있다.
그밖에 많은 것들
앞서 작성한 내용들 외에도, 좋은 테스트 코드를 작성하기 위해 생각해볼만한 수 십가지 이야깃거리들이 있다.
품질 높은 테스트 코드에 대한 욕심이 커지면 커질수록 그런 이야깃거리들을 모두 검토해보고 실제 테스트 코드에 적용해보고 싶은 욕심도 매우 커진다.
하지만 모든 일이 그러하듯, 대부분은 실용적인 이유로 잘 안되는 경우가 훨씬 많으며 오버엔지니어링 또한 걱정되는 부분 중 하나일 것이다.
결국 만고의 진리는 자신의 프로젝트에 적합한 수준의 스케일에서의 적당한 수준의 타협인 것 같다.
가장 중요한 것은 사실 테스트 코드 그 자체라기보단, 더 높은 품질의 지속가능한 소프트웨어를 만들기 위한 노력과 고민이 아닐까하는 힘빠지고 추상적이지만 나름 뼈있는 말로 마무리 하고 싶다.