생각해보니 내부 구현을 검증해도 괜찮은가-는 이 글에서 꽤 중요한 아젠다이기도 합니다. 넘어가려고 했지만 그래도 자세히 다뤄보는 것이 좋겠다고 생각했습니다. 위에서 언급한 레퍼런스인 향로님의 글 마지막에서는 이렇게 말하고 있습니다.
레거시 코드에 테스트 코드를 추가할 때는 내부 구현을 검증할 수 있다. 이렇게 할 경우 각 대상 코드들을 격리화할 수 있어서 로직 파악이나 문제 파악에 좀 더 빠를 순 있지만, 리팩토링 내성이 전혀 없는 코드임은 인지하고 있어야 한다.
개인적으로 이 방식을 권장하고 싶지는 않습니다. 어떻게든 돌아가고 있는 레거시에 테스트틑 붙이는 이유는 단 하나입니다: 리팩토링 전, 회귀 방지를 위해 테스트로 감싸기 위함이죠. 결국, 레거시에 테스트를 붙이는 것은 미래의 리팩토링을 염두에 둔 작업입니다.
A 클래스는 내부적으로 B, C 클래스에 의존하고 있다고 합시다. 그렇다면 A 클래스 입장에서 B, C 클래스는 아직 테스트가 작성되지 않아, ‘올바르게 작동한다’고 보장할 수 없는 black box 영역입니다. B, C 클래스는 다시 내부적으로 다른 클래스들을 의존하고 있을 것이고… 블랙 박스 안에는 다시 수많은 블랙 박스들이 들어있을 것입니다. 아니 이조차도 확신할 수 없습니다. 말 그대로 안이 보이지 않는 ‘블랙 박스’이기 때문입니다.
우리가 많은 시간이 있어서 이 모든 블랙 박스를 화이트 박스로 만들 수 있으면 좋을 것입니다. 하지만 레거시는 크고 방대하며 시간은 한정되어 있기 때문에, 우리가 당장 할 수 있는 것은 A 클래스의 역할과 책임에 대해 검증하는 것입니다.
이제 테스트 코드를 작성하기 시작합니다. A 클래스에 대한 테스트를 작성하다 보면 B, C 클래스이 계속 거슬립니다. 분명 테스트 코드를 짰는데, 성공해야 하는 테스트가 실패합니다. 원인을 찾아보니, 아니나 다를까 아직 테스트가 없는 B, C 클래스가 원인이었습니다. 블랙 박스 영역이 우리의 A 클래스까지 오염시키기 시작한 것입니다.
우리는 예측하기 어려운 B, C 클래스에 의해 A 클래스의 테스트의 성공과 실패가 좌우되는 것을 원하지 않습니다. 그건 그러니 블랙 박스 영역을 격리하도록 합시다. 어떻게? mocking과 stubbing으로 의존관계를 끊어내는 것입니다. 이때, stubbing 하는 행위는 B, C 클래스가 수행하는 모든 동작에 대한 것이 아닙니다. 우리가 A 클래스를 테스트할 때, B와 C 클래스에 대해서 ‘그렇게 동작할 것으로 기대하는’ 메서드가 있습니다. 그 ‘기대’에 대해서 stubbing 하게 됩니다.
만약 블랙박스 영역을 격리하지 않으면 어떻게 될까요? 위에서 말했던 것처럼, 내부 구현을 검증할 수밖에 없습니다. 당연한 일입니다. 기존처럼 input을 통해 - output을 검증하는 방식은, 그 입력이 함수(혹은 메서드) f에 의해 결정된다는 것을 전제할 때만 유효합니다.
실제로 우리가 테스트하는 대상은 여러 개의 함수 박스로 복잡하게 표현되겠지만, 중요한 것은 그 함수 박스들이 동작하는 방식이 일관적이라는 것입니다. 조금 더 현실과 가깝게 input x가 function f, g, h에 의해 output y로 나오게 된다고 합시다. 블랙 박스 영역이라 함은, 갑자기 function g가 g’으로 바뀌어 버리는 것이나 마찬가지입니다.
상상해봅시다. 수십 개의 (x, y) 쌍이 있습니다. 우리는 f, g, h의 동작이 이럴 것이라고 예상하고 x를 넣은 다음, 실제로 나온 y값을 통해 우리의 예상이 맞는지 검증합니다. 그런데 어느 때는 g가 g일 때도 있고 g’일 때도 있다면, 무엇이 문제인지 찾을 수 있을까요? (x, y) 쌍만으로는 그 원인을 찾기가 절대 불가능할 것입니다. 이것이 레거시 코드에서는 ‘구현이 아닌 결과를 검증하라’ 는 원칙을 지키기 어려운 이유입니다.
위에서 말헀던 f, g, h의 문제는 어디서 올까요? 바로 g가 우리가 예상했던 것처럼 작동하지 않는다는 점에서 기인합니다. 이를 위해서 f + g + h 박스 위에서 구현을 검증하기- 즉, g의 알고리즘을 검증할 수도 있습니다. 아니면, 조금 더 시간을 써서 g에 대한 단위 테스트를 작성하고 / 잘못된 부분을 리팩토링 할 수 있으면 좋겠죠. 그치만 g 안쪽에 어떤 블랙박스 괴물들이 도사리는지 모르니, 함부로 손을 댈 수는 없다고 아까 얘기했었죠?
그럼에도 우리는 g의 구현을 검증하기는 싫습니다. 그럼 이렇게 해봅시다. 우리의 예상대로 작동하는 g를 새로 만들어버리는 건 어떨까요?
fake g를 만듭니다(mocking). 실제 g가 할 수 있는 일보다는 적겠지만… 우리의 박스 안에서 쓰이는 부분만 제대로 만들면 됩니다. 모형이 그 안쪽까지 살아 숨쉴 필요는 없잖아요? 그리고, 우리가 생각하는 g의 로직을 적습니다(stubbing). 그리고 기존처럼 (x, y)의 쌍을 통해 제대로 작동하는지 확인하면 끝!
이렇게 우리는 블랙박스 영역을 격리함으로써, 기존처럼 구현이 아닌 결과를 통해 검증할 수 있게 되었습니다.
아울러, ‘단위 테스트’의 말을 인용하면서 이 섹션을 마무리하겠습니다.
테스트를 작성할 때 특정 구현을 암시하지 말라.
먼저 센터 id가 null이면 예외가 발생해야 합니다.
@Test
void 센터_id가_null이면_예외발생() {
// when & then
assertThrows(
CenterNotFoundException.class,
() -> messageService.sendMessageToCenterManager(null, 1L));
}
쉽죠?
이제 템플릿 id가 null이면 디폴트 메세지 내용을 사용해야 합니다. 하지만, 이 메서드의 내부를 검증하지 않는 한, 테스트를 작성하기 어려워보입니다. 그 이유는 뭘까요? 먼저 외부 모듈을 사용하여 메세지를 전송하는 로직은 다음과 같습니다.
- 먼저 수신자를 결정합니다. 이는 센터 id 값으로 정해집니다.
- 메세지를 보낼 내용을 결정합니다. 이는 템플릿 id 값으로 정해집니다.
- 메세지 객체를 만듭니다. 이 메세지 객체는 외부 모듈에서 정의된 클래스입니다. 메세지의 필드에 수신자, 발신자, 내용을 설정합니다.
- 이 메세지 객체를 인자로 받는 요청 DTO를 만듭니다. 그리고 외부 모듈의 서비스 메서드의 인자로 전달하여 호출합니다. 그리고 외부 모듈의 응답을 리턴합니다.
우리가 검증하고 싶은 것은 메세지 객체의 내용이 올바르게 설정되었는지… 입니다. 그러나 내부를 검증하지 않는 한, 우리가 할 수 있는 것은 메세지가 인자로 전달되는 메서드인 sendOne
의 리턴값을 통해 이 값이 올바르게 전달되었는지를 체크할 수밖에 없습니다. 하지만 아까 전, 우리는 이 외부 모듈(defaultMessageService
)을 모킹했었죠? 따라서 이 역시 확인이 불가능합니다.
그렇다면 불편하지만 내부를 직접 검증할 수밖에 없습니다. 우리가 원하는 것은 defaultMessageService.sendOne
에 전달되는 인자가 우리가 의도한 것인지 체크하는 것입니다. 다행히 Mockito에서는 ArgumentCaptor
를 통해 이 기능을 지원합니다. 사용법은 그리 복잡하지 않습니다. 바로 코드로 확인해봅시다.
@Test
void 템플릿_id가_null이면_기본템플릿_사용한다() {
// given
ArgumentCaptor<SingleMessageSendingRequest> requestCaptor =
ArgumentCaptor.forClass(SingleMessageSendingRequest.class);
// when
messageService.sendMessageToCenterManager(center.getId(), null);
// then
verify(defaultMessageService).sendOne(requestCaptor.capture());
assertEquals(defaultContent, requestCaptor.getValue().getMessage().getText());
}
ArgumentCaptor
를 선언하여 우리가 캡쳐할 인자를 지정합니다.verify(yourMock).doSomethingStubbed(yourCaptor.capture())
와 같이 모킹된 객체의 스터빙된 메서드에 캡쳐할 인자를 전달하고, 캡쳐 메서드를 호출합니다.- 캡쳐된 값을 비교합니다.
무슨 말이냐면… 바로 코드로 봅시다. 아래와 같은 테스트가 존재한다고 합시다.
// 비교할 보안정보 인스턴스를 만든다
Credentials credentials =
new Credentials("baeldung", "correct_password", "correct_key");
// mocking된 platform에 대하여 authenticate 메서드를 호출할 때
// credentialsCaptor에 인자값을 캡쳐하고
// AUTHENTICATED 상태를 반환하도록 stubbing 한다
when(platform.authenticate(credentialsCaptor.capture()))
.thenReturn(AuthenticationStatus.AUTHENTICATED);
// stubbing된 authenticate를 호출하는 테스트를 수행한다
assertTrue(emailService.authenticatedSuccessfully(credentials));
// 초기에 만들었던 보안정보와 캡쳐된 인자값이 일치하는지 확인한다
assertEquals(credentials, credentialsCaptor.getValue());
인자를 캡쳐 후 → 특정 값과 동일한지 확인하는 단언문을 작성하는 것보다는, 스터빙 단계에서 특정 값과 일치하는 인자가 들어올 때만 원하는 값을 반환하도록 stubbing 하라는 것입니다.
// 비교할 보안정보 인스턴스를 만든다
Credentials credentials =
new Credentials("baeldung", "correct_password", "correct_key");
// mocking된 platform에 대하여 authenticate 메서드를 호출할 때
// 비교할 보안정보 인스턴스와 값이 같은지 확인하고
// 같은 경우에만 AUTHENTICATED를 반환하도록 stubbing 한다
when(platform.authenticate(eq(credentials)))
.thenReturn(AuthenticationStatus.AUTHENTICATED);
// stubbing된 authenticated를 호출하는 메서드를 테스트한다
assertTrue(emailService.authenticatedSuccessfully(credentials));
어떤 점이 좋아졌을까요?
- 가독성이 좋아졌습니다. 코드 전체에 흩뿌려져 있던 인자 캡쳐 → 비교 로직이
eq(credentials)
표현으로 압축되었습니다. 훨씬 이해하기 쉽습니다. - 단위 테스트가 한 가지만 테스트하도록 만들어줍니다. 기존의 단언문
assertEquals(credentials, credentialsCaptor.getValue());
는 언뜻 보기에는 인자값과 보안정보값을 비교하는 것처럼 보이지만, 실제로는capture()
가 정상적으로 호출되었는지 테스트한다는 의미도 내포하고 있습니다. 만약authenticatedSuccessfully
메서드가 내부 로직 상의 문제로authenticate
를 호출하지 않았다고 합시다. 진짜 문제는 서비스 메서드에 있지만, junit은 인자가 제대로 캡쳐되지 않았다는MockitoException
을 발생시킵니다. 즉 하나의 테스트가 의도치 않게 여러 가지를 테스트하게 되면서, 문제의 원인을 발견하기 어려워졌다고 할 수 있겠습니다.
단, 우리 코드는 verify()
를 통해 인자 캡쳐를 사용하고 있기 때문에 해당사항이 없습니다. stubbing 할 때 조심하자는 것만 체크하고 넘어갑시다.
정리해봅시다. 지금까지 우리는 내부에서 외부 클래스의 메서드로 넘어가는 인자에 대하여, 외부 클래스를 mocking / stubbing 한 뒤 인자를 캡쳐하는 방식으로 해결했습니다. 이는 외부 클래스의 경우 테스트하기 어렵기 때문입니다 (테스트할 때마다 전송 비용으로 10원씩 낼 수는 없으니까요).
하지만 인자 캡쳐는 근본적으로 구현을 검증하는 것입니다. 우리 테스트에서는 값이 defaultContent
와 같은지 확인하기 위해서 무엇을 하고 있나요? SingleMessageSendingRequest
의 인자인 Message
를 까서 그 프로퍼티인 text
로 비교하고 있죠? 하지만 우리는 메서드 시그니쳐를 봤을 때는 센터와 템플릿의 id 값, 그리고 SingeMessageSentResponse
만 알 수 있습니다. 인풋과 아웃풋이 아닌 다른 대상을 단언한다는 것은 구현을 검증한다는 것입니다 (런타임 예외는 예외입니다). 우리가 작성한 테스트는 어느 쪽도 아니므로 구현을 검증하는 것이라 할 수 있습니다.
결과를 테스트하기 위해서는 무엇을 해야 할까요? SingleMessageSentResponse
를 검증해야 합니다. 하지만 이 결과를 만드는 DefaultMessageService
가 모킹된 상태이므로 검증할 수 없는 값입니다. 모든 문제는, 우리가 컨트롤할 수 없는 외부 의존성이 제대로 격리되지 않았다는 점에서 발생합니다. 비단 SingleMessageSentResponse
뿐만이 아니라, Message
클래스와 SingleMessageSendingRequest
도 그렇습니다.
이제부터 우리가 테스트하려는 sendMessageToCenterManager
에서 외부 의존성을 완전히 격리가 가능하도록 리팩토링 해봅시다.
public SingleMessageSentResponse sendMessageToCenterManager(
Long centerId, Long messageTemplateId) {
String to = getDestinationPhoneNumber(centerId);
String content = getTemplateContent(messageTemplateId);
return sendMessage(from, to, content);
}
private SingleMessageSentResponse sendMessage(
String from, String to, String content) {
Message message = new Message();
message.setTo(to);
message.setFrom(from);
message.setText(content);
return defaultMessageService.sendOne(
new SingleMessageSendingRequest(message));
}
from, to, content는 우리가 컨트롤할 수 있는 값입니다. 하지만 Message
는 과연 그런가요? 만약 우리가 비용 관계로 메세지 전송에 C사의 솔루션이 아닌 D사의 솔루션을 사용하게 되었다고 합시다. 이로 인해 DefaultMessageService
가 아니라 NewMessageService
를 사용하게 되었고, 내부적으로도 Message
객체가 아닌 NewMessage
를 사용하게 되었습니다. 그리고 메세지를 보낼 때도 sendOne(...)
이 아니라 newSend(NewMessage)
를 호출한다고 합시다.
우리의 테스트 코드는 어떻게 될까요? 스터빙부터 다시 해야 합니다. 모킹 대상이 아예 바뀌었으니까요.
when(
defaultMessageService.sendOne(
any(SingleMessageSendingRequest.class))
)
.thenReturn(null);
당연히 인자 캡쳐로 검증하는 테스트 역시 깨집니다. 하지만 새롭게 리팩토링한 코드에 대한 테스트는 어떨까요? 놀랍게도, 이제는 더 큰 문제가 발생합니다. 기존에는 외부 의존성을 모킹하고 스터빙했다면, 이 외부 의존성을 sendMessage
로 몰아넣는 과정에서 private 메서드를 만들어버리고 만 것입니다.
맞습니다. 예상하셨겠지만 private method를 stubbing하는 것 역시 내부 구현을 검증하는 것입니다. PowerMock이라는 마법같은 라이브러리를 쓸 수 있겠지만 그게 좋지 않다는 건 다들 아시겠죠. 아무튼, 오히려 공개되어 있는 외부 인터페이스까지 비공개 메서드 안에 감춰지면서 문제가 생겼습니다.
즉 외부 의존성을 격리하는 것까지는 괜찮은데 그걸 열심히 포장해서 상자 안에 넣어버린 바람에 아무도 그 포장을 깔 수가 없게 되버린 것이죠. 그럼 어떻게 해야 할까요?
상자 밖으로 꺼내면 되겠죠?
sendMessage(from, to, content)
라는 공개 메서드를 가지는 클래스 MessageServiceAdaptor
를 만듭니다. 그 클래스는 내부적으로 NewMessageService
에 의존합니다. sendMessage
메서드의 구현은 인자 값을 바탕으로 NewMessageService.newSend
를 호출하여 메세지를 전송하는 것입니다.
즉, 우리가 기존에 만든 MessageService
는 MessageServiceAdaptor
를 의존합니다. 그리고 MessageServiceAdaptor
는 호출 시 인자로 넣어준 from, to, content 값을 적절히 사용하여 외부 의존성의 스펙에 맞게 NewMessageService.newSend
를 호출하여 메세지를 보내게 됩니다. 만약 외부 의존성이 변경된다면? 호환이 가능하도록 다시 구현부를 작성해야겠죠. 어댑터 쪽의 테스트는 깨질 수도 있겠습니다만, 우리가 원하는 MessageService
에 대한 테스트 코드는 깨지지 않을 것입니다.