1 좋은 단위 테스트의 4대 요소
1.1 좋은 단위 테스트 스위트의 특성
- 개발 주기에 통합돼 있습니다.
- 실제로 사용하는 테스트만 가치가 있습니다. 그렇지 않으면 작성해도 의미가 없습니다.
- 코드베이스에서 가장 중요한 부분만을 대상으로 합니다.
- 모든 실행 코드에 똑같이 신경 쓸 필요가 없습니다.
- 애플리케이션의 핵심(도메인 모델)을 다른 것과 구별하는 것이 중요합니다.
- 시스템의 가장 중요한 부분에 단위 테스트 노력을 기울이고 다른 부분을 간략하게 또는 간접적으로 검증하는 것이 좋습니다.
- 최소한의 유지비로 최대 가치를 끌어냅니다.
- 가치 있는 테스트를 식별할 줄 알아야 합니다.
- 가치 있는 테스트를 작성해야 합니다.
- Good-Unit-Test.md 좋은 단위 테스트 스위트의 특성 참고
1.2 가치 있는 테스트
- 앞에 좋은 단위 테스트 스위트의 특성 중에서 가치 있는 테스트를 식별하고 작성해야 한다고 했습니다. 그렇다면 가치 있는 테스트란 무엇일까요?
- 가치있는 테스트를 식별하기 위해 사용되는 네 가지 특성이 있습니다.
- 회귀 방지
- 리팩터링 내성
- 빠른 피드백
- 유지 보수성
- 이 네가지 특성이 기본이며 이러한 특성으로 어떤 테스트(단위 테스트, 통합 테스트, 엔드 투 엔드 테스트)도 분석할 수 있습니다.
2 회귀(버그) 방지
- 회귀 방지는 테스트가 얼마나 버그(회귀)의 존재를 잘 나타내는지에 대한 척도입니다.
- 회귀(버그)는 코드를 수정한 후 기능이 의도한 대로 작동하지 않는 경우를 말합니다.
- 따라서 회귀 방지는 실제 동작이 의도한 대로 작동하지 않음을 테스트가 얼마나 잘 나타내는지에 대한 척도입니다.
- 회귀 방지 지표를 극대화하려면 테스트가 가능한 많은 코드를 실행하는 것을 목표로 해야 합니다.
- 테스트가 코드를 더 많이 실행할수록 테스트에서 버그가 드러날 확률이 더 높아집니다.
- 직접 작성한 코드와 프로젝트에서 사용한 라이브러리 및 프레임워크 코드 모두 포함합니다.
2.1 회귀 방지 평가 지표
- 회귀 방지 특성의 점수를 평가하려면 다음 사항을 고려해야 합니다.
- 테스트 중에 실행되는 코드의 양
- 코드 복잡도
- 코드의 도메인 유의성
테스트 중에 실행되는 코드의 양
- 일반적으로 실행되는 코드가 많을수록 테스트에서 회귀(버그)가 나타날 가능성이 높습니다.
- 회귀 방지 지표를 극대화하려면 테스트가 가능한 많은 코드를 실행하는 것을 목표로 해야 합니다.
코드 복잡도와 코드의 도메인 유의성
- 복잡한 비즈니스 로직을 나타내는 코드가 보일러플 레이트 코드보다 훨씬 중요합니다.
- 비즈니스에 중요한 기능에서 발생한 버그가 가장 큰 피해를 입히기 때문입니다.
- 반면 단순한 코드를 테스트하는 것은 거의 가치가 없습니다.
- 단순한 코드에서는 버그가 많이 발생하지 않기 때문입니다.
3 리팩터링 내성
- 리팩터링 내성은 테스트를 실패로 바꾸지 않고 기본 애플리케이션 코드를 리팩터링할 수 있는지에 대한 척도입니다.
- 리팩터링 후 기능은 예전과 같이 완벽하게 작동하는데 테스트는 실패할 때 리팩터링 내성이 없다고 합니다.
- 이러한 상황을 거짓 양성이라고 합니다.
- 거짓 양성은 허위 경보입니다.
- 실제로 기능이 의도한 대로 작동하지만 테스트는 실패를 나타냅니다.
리팩터링
리팩터링은 식별할 수 있는 동작을 수정하지 않고 기존 코드를 변경하는 것을 의미합니다. 그 의도는 코드의 비기능적 특징을 개선하는 것으로 가독성을 높이고 복잡도를 낮추는 것입니다. 몇 가지 예를 들자면, 메서드 이름을 바꾸는 것이나 코 드 조각을 새로운 클래스로 추출하는 것을 생각해 볼 수 있습니다.
3.1 거짓 양성의 좋지 않은 영향
- 테스트가 타당한 이유 없이 실패하면, 코드 문제에 대응하는 능력과 의지가 희석됩니다.
- 실패에 익숙해지고 이내 타당한 실패도 무시하기 시작해 기능이 고장 나도 운영 환경에 들어가게 됩니다.
- 거짓 양성이 빈번하면 테스트 스위트에 대한 신뢰가 떨어지기 시작하며 더 이상 안전망으로 인식하지 않습니다.
- 거짓 양성이 빈번하다는 것은 리팩터링 내성이 부족하다는 것이고 점차 리팩터링이 줄어듭니다.
- 회귀를 피하려고 코드 변경을 최소화하기 때문입니다.
- 결과적으로 단위 테스트의 목표인 프로젝트의 지속적인 성장을 가로막습니다.
3.2 거짓 양성의 원인

- 리팩터링 과정은 애플리케이션의 식별 가능한 동작에 영향을 주지 않으면서 구현을 변경하는 것입니다.
- 따라서 리팩터링 후 테스트가 실패하는 것의 원인은 테스트가 구현 세부 사항과 결 합되었기 때문입니다.
- 테스트와 SUT(테스트 대상 시스템)의 구현 세부 사항이 많이 결합할수록 거짓 양성(허위 경보)이 더 많이 생깁니다.
3.3 거짓 양성 해결
- 거짓 양성을 줄이는 방법은 구현 세부 사항에서 테스트를 분리하는 것뿐입니다.
- 결합도를 낮추려면 테스트는 SUT가 수행한 단계가 아니라 최종 결과를 검증해야 합니다.
- 테스트는 최종 사용자 관점에서 SUT를 검증해야 하고 의미 있는 결과만 확인해야 합니다.
- SUT의 메서드를 진입점으로 하고 최종 결과만 검증하는 블랙박스 테스트를 해야 합니다.
3.4 예시
- MessageRenderer는 머리글, 본문, 바닥글을 포함하는 Message의 HTML 표현을 생성하는 역할을 합니다.
Message.java
- Message는 머리글, 본문, 바닥글을 가지고 있습니다.
@Setter
@Getter
public class Message {
private String header;
private String body;
private String footer;
}
Renderer.java
- Message를 가지고 HTML 표현을 생성하는 render 메서드를 가진 인터페이스입니다.
public interface Renderer {
String render(Message message);
}
MessageRenderer.java
- Renderer 인터페이스의 구현체입니다.
- 서브 Renderer 구현체들을 가지고 있어 HTML 표현을 생성하는 일을 위임하고 생성된 결과를 HTML 문서로 결합합니다.
@Getter
public class MessageRenderer implements Renderer {
private List<Renderer> subRenderer = Arrays.asList(
new HeaderRenderer(),
new BodyRenderer(),
new FooterRenderer());
@Override
public String render(Message message) {
return subRenderer.stream()
.map(renderer -> renderer.render(message))
.collect(Collectors.joining(""));
}
}
HeaderRenderer.java
class HeaderRenderer implements Renderer {
@Override
public String render(Message message) {
return String.format("<header>%s</header>", message.getHeader());
}
}
BodyRenderer.java
class BodyRenderer implements Renderer {
@Override
public String render(Message message) {
return String.format("<b>%s</b>", message.getBody());
}
}
FooterRenderer.java
class FooterRenderer implements Renderer {
@Override
public String render(Message message) {
return String.format("<footer>%s</footer>", message.getFooter());
}
}
깨지기 쉬운 테스트
@Test
void MessageRenderer_uses_correct_sub_renderers() {
// Arrange
Message message = new Message("h", "b", "f");
MessageRenderer sut = new MessageRenderer();
// Act
List<Renderer> renderers = sut.getSubRenderer();
// Assert
assertThat(renderers.size()).isEqualTo(3);
assertThat(renderers.get(0)).isInstanceOf(HeaderRenderer.class);
assertThat(renderers.get(0).render(message)).isEqualTo("<header>h</header>");
assertThat(renderers.get(1)).isInstanceOf(BodyRenderer.class);
assertThat(renderers.get(1).render(message)).isEqualTo("<b>b</b>");
assertThat(renderers.get(2)).isInstanceOf(FooterRenderer.class);
assertThat(renderers.get(2).render(message)).isEqualTo("<footer>f</footer>");
}
- 위 테스트는 하위 렌더링 클래스가 예상하는 모든 유형이고 올바른 순서로 나타나는지를 확인합니다.
- 하위 렌더링 클래스를 재배열하거나 그중 하나를 새 것으로 교체하면 어떻게 될까요?
- 많은 경우 테스트를 수행하면 빨간색으로 변할 것입니다.
- 이는 테스트가 SUT가 생성한 결과가 아니라 SUT의 구현 세부 사항에 결합했기 때문입니다.
- 구현 세부 사항에 결합한 테스트를 깨지기 쉬운 테스트라고 합니다.
테스트 개선
@Test
void rendering_a_message() {
// Arrange
Message message = new Message("header", "body", "footer");
MessageRenderer sut = new MessageRenderer();
// Act
String html = sut.render(message);
// Assert
assertThat(html).isEqualTo("<header>header</header><b>body</b><footer>footer</footer>");
}
- 깨지기 쉬운 테스트를 개선하는 방법은 SUT의 구현 세부 사항과 테스트 간의 결합도를 낮춰 리팩터링 내성을 갖추는 것입니다.
- MessageRenderer의 최종 결과는 메시지의 HTML 표현입니다.
- 이는 클래스에서 얻을 수 있는 관찰 가능한 결과로 검증하는 것이 마땅합니다.
- HTML 표현이 그대로 유지되는 한 정확히 어떻게 생성되는지는 알 필요가 없습니다.
- 아래 테스트는 SUT의 수행 절차를 검증하지 않고 오로지 최종 결과만을 검증합니다.
리팩터링
public class MessageRenderer implements Renderer {
@Override
public String render(Message message) {
return "<header>" + message.getHeader() + "</header>"
+ "<b>" + message.getBody() + "</b>"
+ "<footer>" + message.getFooter() + "</footer>";
}
}
- 현재 깨지기 쉬운 테스트와 개선된 테스트 두 가지가 있을 때 MessageRenderer를 리팩터링하면 어떻게 될까요?
- MessageRenderer를 아래와 같이 하위 Renderer에 일을 위임하지 않고 직접하도록 리팩터링했습니다.
리팩터링 후 깨지는 깨지기 쉬운 테스트

리팩터링 후 깨지지 않는 리팩터링 내성이 있는 테스트


- 리팩터링 후에도 여전히 초록불이 뜨는 테스트입니다.
4 회귀 방지와 리팩터링 내성
- 회귀 방지와 리팩터링 내성은 테스트 스위트의 정확도에 기여합니다.
- 프로젝트가 시작한 직후에는 회귀 방지를 훌륭히 갖추는 것이 중요한데 반해 리팩터링 내성은 바로 필요하지 않습니다.
- 프로젝트 초기에는 코드 정리를 많이 할 필요가 없기 때문에 허위 경보가 발생하더라도 쉽게 리팩터링할 수 있습니다.
- 그러나 리팩터링 내성은 프로젝트가 성장함에 따라 점점 더 중요해집니다.
4.1 오류 유형

- 테스트가 통과되고 기능이 의도한 대로 작동하는 상황은 올바른 추론입니다.
- 기능이 고장나서 테스트가 실패해도 올바른 추론입니다.
- 기능이 고장났는데 테스트에서 오류가 발생하지 않으면 문제가 되는데 이는 오른쪽 상단의 거짓 음성입니다.
- 거짓 음성은 알려지지 않은 버그입니다.
- 거짓 음성을 피하는데 좋은 특성은 바로 회귀 방지입니다.
- 회귀 방지가 훌륭한 테스트는 거짓 음성의 수를 최소화하는데 도움이 됩니다.
- 반면에 기능은 올바르지만 테스트가 실패하는 것은 거짓 양성이라 합니다.
- 거짓 양성을 피하는데 좋은 특성은 리팩터링 내성입니다.
4.2 테스트 정확도 극대화
테스트 정확도 = 발견된 버그 수 / 허위 경보 발생 수- 테스트 정확도를 향상시키는 방법은 두 가지입니다.
- 회귀를 더 잘 찾아내는 테스트로 개선하기
- 허위 경보를 발생시키지 않는 테스트로 개선하기
- 둘 다 매우 중요합니다.
- 경보가 허위로 발생하지 않더라도 버그를 찾을 수 없는 테스트는 소용없습니다.
- 마찬가지로 코드에서 모든 버그를 찾을 수 있더라도 허위 경보가 많이 발생하면 테스트의 정확도는 0에 가까워집니다.
5 빠른 피드백
- 테스트가 얼마나 빨리 실행되는지에 대한 척도입니다.
- 빠른 피드백은 단위 테스트의 필수 속성입니다.
- 테스트가 빠르게 실행되면 코드에 결함이 생기자마자 버그에 대해 경고하기 시작할 정도로 피드백 루프를 대폭 줄여 버그 수정 비용을 0까지 줄일 수 있습니다.
- 느린 테스트는 피드백을 느리게 하고 잠재적으로 버그를 뒤늦게 눈에 띄게 해서 버그 수정 비용이 증가합니다.
6 유지 보수성
- 유지 보수성은 두 가지 요소로 구성됩니다.
- 테스트가 얼마나 이해하기 어려운가
- 테스트가 얼마나 실행하기 어려운가
테스트가 얼마나 이해하기 어려운가
- 이는 테스트의 크기와 관련있습니다.
- 테스트 코드 라인이 적을수록 더 읽기 쉽습니다.
- 작은 코드는 변경도 쉽습니다.
- 테스트 코드의 품질은 제품 코드만큼 중요합니다.
- 작은 테스트를 작성하기 위해 인위적으로 압축해 절차를 생략하 지 않습니다.
테스트가 얼마나 실행하기 어려운가
- 테스트가 프로세스 외부 종속성으로 작동하면 데이터베이스 서버를 재부팅하고 네트워크 연결 문제를 해결하는 등 의존성을 상시 운영하는데 시간이 들어갑니다.
- 테스트에 관련된 프로세스 외부 의존성은 적을수록 쉽게 운영할 수 있습니다.
7 이상적인 테스트
- 테스트의 가치는 네 가지 특성에서 각각 얻은 점수의 곱으로 추정됩니다.
- 즉 어떤 특성이라도 0이되면 테스트의 가치는 0이 되는 것입니다.
- 따라서 테스트가 가치가 있으려면 네 가지 범주 모두에서 점수를 내야 합니다.
7.1 이상적인 테스트를 만들 수 있는가?
- 이상적인 테스트는 네 가지 특성 모두에서 최대 점수를 받는 테스트입니다.
- 각 특성마다 점수의 범위가 0-1이면 모두 1점을 얻어야 합니다.
- 안타깝지만 이상적인 테스트를 만드는 것은 불가능합니다.
- 회귀 방지, 리팩터링 내성, 빠른 피드백은 서로 배타적이기 때문입니다.
- 셋 중 하나를 희생해야 나머지 둘을 최대로 할 수 있습니다.
- 그러 나 하나의 특성을 그냥 버릴 수 없습니다. 앞서 언급했듯 한 특성이 0이 되면 점수는 곱이기 때문에 테스트가 의미없어집니다.
7.1.1 극단적인 사례1: 엔드 투 엔드 테스트
- 엔드 투 엔드 테스트는 최종 사용자 관점에서 시스템을 테스트합니다.
- UI, 데이터베이스, 외부 애플리케이션을 포함한 모든 시스템 구성요소를 테스트합니다.
- 엔드 투 엔드는 테스트는 많은 코드를 테스트하므로 회귀 방지 특성에서 높은 점수를 얻습니다.
- 직접 작성한 코드뿐만 아니라 외부 라이브러리, 프레임워크, 서드파티 애플리케이션 등과 같이 직접 작성하지 않은 코드를 가장 많이 수행합니다.
- 엔드 투 엔드 테스트는 거짓 양성에 면역이 돼 리팩터링 내성이 훌륭합니다.
- 어떤 특정 구현을 강요하지 않고 최종 사용자 입장에서 기능이 어떻게 동작하는지 테스트하기 때문입니다.
- 그러나 느린 속도라는 큰 단점이 있습니다.
- 모든 시스템 구성요소로부터 피드백을 빨리 받기가 어렵습니다.
- 따라서 엔드 투 엔드 테스트만으로 코드베이스를 다루는 것은 불가능합니다.