본문 바로가기

테스트

[단위 테스트] 리팩토링 내성 (좋은 단위 테스트의 요소)

리팩토링 내성 : 테스트를 "빨간색"(실패)으로 바꾸지 않고 기본 애플리케이션 코드를 리팩토링 할 수 있는지에 대한 척도다.

 

이러한 상황을 상상해보자.

새로운 기능을 개발했으며 모든 것이 잘 동작한다.
기능이 제 역할을 하고 있으며, 모든 테스트가 동작하고 있다.
이제 코드를 정리하기로 결정했다.
여기에 리팩토링을 조금하고 저기를 조금 고치면 모든 것이 전보다 훨씬 좋아 보인다.
단 하나, 테스트가 실패하고 있다는 것만 빼면 말이다.
리팩토링으로 정확히 무엇이 고장 났는지를 자세히 살펴봤지만, 알고 보니 아무것도 고장 나지 않았다.
기능은 예쩐과 같이 완벽하게 동작한다.
문제는 기반 코드를 수정하면 테스트가 빨간색으로 바뀌게끔 작성됐다는 것이다.
그리고 실제로 기능이 작동하지 않는지는 상관없다.

 

 

이러한 상황을 거짓 양성 (false positive) 이라고 한다.

거짓 양성은 허위 경보다.

실제로 기능이 의도한 대로 작동하지만 테스트는 실패를 나타내는 결과다.

이러한 거짓 양성은 일반적으로 코드를 리팩토링할 때, 즉 구현을 수정하지만 식별할 수 있는 동작은 유지할 때 발생한다. 따라서 좋은 단위 테스트의 한 가지 특성으로 이름 붙이자면 리팩터링 내성이라 할 수 있다.

 

이전에 테스트는 지속 가능한 성장을 하게 한다고 했었다.

지속 가능한 성장을 하게 하는 메커니즘은 회귀 없이 주기적으로 리팩토링하고 새로운 기능을 추가할 수 있는 것이다.

여기에 두가지 장점이 있다.

  • 기존 기능이 고장 났을 때 테스트가 조기 경고를 제공한다. 이러한 조기 경고 덕분에 결함이 있는 코드가 운영 환경에 배포되기 훨씬 전에 문제를 해결할 수 있다.
  • 코드 변경이 회귀로 이어지지 않을 것이라고 확신하게 된다. 이러한 확신이 없으면 리팩토링을 하는 데 주저하게 되고 코드베이스가 나빠질 가능성이 훨씬 높아진다.

거짓 양성은 위의 두가지 이점을 모두 방해한다.

  • 테스트가 타당한 이유 없이 실패하면, 코드 문제에 대응하는 능력과 의지가 희석된다. 시간이 흐르면서 그러한 실패에 익숙해지고 그만큼 신경을 많이 쓰지 않는다. 이내 타당한 실패도 무시하기 시작해 기능이 고장 나도 운영 환경에 들어가게 된다.
  • 반면에 거짓 양성이 빈번하면 테스트 스위트에 대한 신뢰가 서서히 떨어지며, 더 이상 믿을 만한 안전망으로 인식하지 않는다. 즉, 허위 경보로 인식이 나빠진다. 이렇게 신뢰가 부족해지면 리팩토링이 줄어든다. 회귀를 피하려고 코드 변경을 최소한으로 하기 때문이다.

 

거짓 양성의 원인

결론부터 말하자면 거짓 양성의 원인은 구현 세부 사항이다.

 

테스트와 테스트 대상 시스템(sut)의 구현 세부 사항이 많이 결합할수록 허위 경보가 더 많이 생긴다.

거짓 양성이 생길 가능성을 줄이는 방법은 해당 구현 세부 사항에서 테스트를 분리하는 것뿐이다.

테스트를 통해 sut가 제공하는 최종 결과(관련된 절차가 아니라 식별할 수 있는 동작)를 검증하는지 확인해야 한다.

 

다음 예제를 보자.

@Getter
@Setter
public class Message {
	
	private String header;
    private String body;
    private String footer; 
    
}

public interface IRenderer {
	
    String render(Message message);
    
}

@Getter
public class MessageRenderer implements IRenderer {
	
    private List<IRenderer> subRenderers;
	
    public MessageRenderer() {
    	subRenderers = List.of(
        	new HeaderRenderer(),
            new BodyRenderer(),
            new FooterRenderer()
        );
    }
    
    public String render(Message message) {
    	return subRenderers.stream()
            .map(renderer -> renderer.render(message))
            .collect(Collectors.joining());
    }
}

 

MessageRenderer 클래스에는 List 타입의 subRenderers 필드가 존재하고, render 메서드에서 메시지의 일부에 대한 실제 작업을 subRendere에게 작업을 위임한다.

 

MessageRenderer를 어떻게 테스트 할 수 있을까?

void MessageRendere는_올바른_subRenderes를_사용한다(){
	
    MessageRenderer sut = new MessageRenderer();
    
    List<IRenderer> renderers = sut.getSubRenderers();
    
    // Then
    assertThat(renderers).hasSize(3);
    assertThat(renderers.get(0)).isInstanceOf(HeaderRenderer.class);
    assertThat(renderers.get(1)).isInstanceOf(BodyRenderer.class);
    assertThat(renderers.get(2)).isInstanceOf(FooterRenderer.class);
    
}

 

이 테스트는 renderers의 개수와 올바른 순서로 나타나는지 여부를 확인한다.

처음에는 테스트가 좋아 보이지만, MessageRenderer의 식별할 수 있는 동작을 실제로 확인하는가?

List 안에 원소의 순서를 바꾸거나, 그 중 하나를 새 것으로 교체하면 어떻게 될까?

 

테스트를 수행하면 빨간색(컴파일 오류)으로 변할 것이다.

이는 테스트가 sut가 생성한 결과가 아니라 sut의 구현 세부 사항과 결합했기 때문이다.

이 테스트는 똑같이 적용할 수 있는 다른 구현을 고려하지 않고 특정 구현만 예상해서 알고리즘을 검사한다.

 

sut 알고리즘과 결합된 테스트. 이러한 테스트는 특정 구현을 예상하므로 꺠지기 쉽다.

 

MesageRenderer 클래스의 상당 부분을 리팩토링하면 테스트가 실패한다.

리팩토링 과정은 애플리케이션의 식별할 수 있는 동작에 영향을 주지 않으면서 구현을 변경하는 것이다.

그리고 변경할 때마다 빨간색(컴파일 오류)으로 변하는 것은 바로 테스트가 구현 세부 사항에 관계돼 있기 때문이다.

 

구현 세부 사항 대신 최종 결과를 목표로 하기

테스트를 깨지지 않게 하고 리팩토링 내성을 높이는 방법은 sut의 구현 세부 사항과 테스트 간의 결합도를 낮추는 것뿐이다.

즉, 코드의 내부 작업과 테스트 사이를 가능한 한 멀리 떨어뜨리고 최종 결과를 목표로 하는 것이다.

 

테스트의 새 버전을 보자.

void 메시지를_렌더링한다(){
	
    MessageRenderer sut = new MessageRenderer();
    Message message = new Message(
    	Header = "h",		// 자바 코드에서 이런 문법은 없지만 이해가 쉽도록 이렇게 작성했음
        Body = "b",
        Footer = "f"
    );
    
    String html = sut.render(message);
    
    Assert.equals("<h1>h</h1><b>b</b><i>f</i>", html);
}

 

이 테스트는 MesageRenderer를 블랙박스로 취급하고 식별할 수 있는 동작에만 신경 쓴다.

결과적으로 테스트는 리팩토링 내성이 부쩍 늘었다.

즉 HTML 출력을 똑같이 지키는 한, sut의 변경 사항은 테스트에 영향을 미치지 않는다.

 

구현 세부 사항이 아닌 sut의 식별할 수 있는 동작과 결합된 테스트

 

참고

단위 테스트 블라디미르 코리코프 지음