‘블라디미르 코리코프’ 저 ‘단위 테스트’ 책을 읽고 정리한 내용
📚 단위 테스트의 목표
- 단위 테스트에 시간을 투자할 때는 항상 최대한 이득을 얻도록 노력해야 한다.
- 테스트에 드는 노력을 가능한 줄이고 그에 따르는 이득을 최대화 해야 한다.
무난하게 성장하고 유지보수가 많이 필요하지 않으며 끊임없이 변화하는 고객의 요구에 신속히 대응할 수 있는 프로젝트도 있지만, 그렇지 못한 프로젝트도 존재한다. 단위 테스트를 매우 많이 작성하더라도 많은 버그와 유지비로 프로젝트의 진행이 느리다.
📚 단위 테스트 현황
지난 20년간 단위 테스트의 중요성이 대두되었고, 이제는 대부분 회사에서 필수로 간주되고 있으며 대부분의 프로그래머들은 단위 테스트를 실천하고 중요성을 알고 있다.
- ‘단위 테스트를 작성해야하는지’는 더 이상 논쟁거리가 아니다.
- ‘더 좋은 단위 테스트를 작성하는 것은 어떤 의미인지’로 바뀐 상태다.
📚 단위 테스트의 목표
- 단위 테스트의 주 목적은 ‘더 나은 설계’가 아니다.
- 더 나은 설계는 따라오는 부수효과일 뿐이다.
프로덕션 코드에 단위 테스트를 작성하기 어렵다면 코드간 결합도가 높은 저품질 상태임을 뜻한다. 이런 경우 단위 테스트가 코드의 결합도를 낮출 수 있는 좋은 장치로 역할을 수행한다.
그러나 단위 테스트가 가능하다고 해서 반드시 코드 품질이 좋은 것 또한 아니다. 낮은 결합도를 가진 프로덕션 코드여도, 실제 런타임에서 어떤 이펙트를 발생시킬지 알 수 없다. 그렇다면 단위 테스트의 주 목표는 무엇인가?
- 소프트웨어 프로젝트의 엔트로피(시스템 내 무질서도)를 최대한 막는 것
- 이를 통해 소프트웨어 프로젝트의 지속 가능한 성장을 가능케 하는 것
테스트는 안전망 역할을 하며, 대부분의 회귀 (특정 사건 이후 기능이 의도한 대로 동작하지 않는 현상)에 대한 보험을 제공하는 도구다. 지속성과 확장성이 핵심이며, 이를 통해 장기적으로 개발 속도를 유지할 수 있다.
결국 유지보수 단계에서 코드베이스를 신뢰할 수 있다는 말로 들린다. 한 명의 소프트웨어 프로그래머가 방대한 프로덕션 코드에 대해 이해하는 것에는 분명한 한계가 존재하고, 한계를 벗어난 나머지 코드 베이스에 대해서는 작성되어 있는 테스트의 결과를 믿는 것이다. 이를 기반으로 다음 개발을 진행해 나갈 수 있는 것을 신뢰라고 생각한다.
📚 좋은 테스트와 좋지 않은 테스트를 가르는 요인
좋지 않은 테스트가 포함될 경우 테스트가 없는 프로젝트와 결과가 같아진다. 단지 그 시점이 조금 늦춰질 뿐이다. 모든 테스트가 똑같이 작성되지는 않는다. 일부 테스트는 아주 중요하고 소프트웨어 품질에 매우 많은 기여를 한다. 그 밖에 다른 테스트는 그렇지 않다. 잘못된 경고가 발생하고, 회귀 오류를 알아내는지 도움이 되지 않으며 유지보수가 어렵고 느리다.
프로젝트에 테스트를 더 많이 실행하더라도 단위 테스트의 목표(지속성, 확장성)를 달성할 수 없다.
- 기반 코드를 리팩터링 할 때 테스트도 리팩터링 (테스트도 유지보수 대상)
- 테스트가 잘못된 경고를 발생시킬 경우 처리 (테스트도 유지보수 대상)
- 각 코드 변경시 테스트를 반드시 실행
- 기반 코드가 어떻게 동작하는지 이해할 때 테스트를 읽는데 많은 시간을 투자할 것
온보딩 기간동안 단순히 프로덕션 코드만 읽으면서 서비스를 이해하려고 했었는데, 제대로 저격 당했다. 프로덕션 코드 뿐만 아니라 테스트 코드를 확인하면서 도메인, 서비스 코드의 작성 의도를 이해하는 것이 더 좋은 방향일거 같다.
📚 테스트 스위트 품질 측정을 위한 커버리지 지표
테스트 스위트(Test Suite)
테스트 케이스들을 하나로 묶은 단위
코드 커버리지(테스트 커버리지)
코드 커버리지(테스트 커버리지)는 괜찮은 부정 지표지만 동시에 좋지 않은 긍정 지표다. 테스트 커버리지가 너무 낮을 경우 테스트가 충분하지 않다는 좋은 증거가 되지만, 테스트 커버리지가 100%라고 해서 반드시 양질의 테스트 스위트가 보장되지는 않는다.
아래 예제를 통해 상황을 이해해보자.
public boolean isStringLong(String input){
if (input.length() > 5) {
return true;
}
return false;
}
@Test
void test() {
assertThat(isStringLong("abc")).isFalse() // true;
}
메서드의 전체 라인수는 5줄이지만,
테스트가 동작하는 라인 수는 return true;
를 제외한 4줄이다.
따라서 테스트 커버리지는 80%다.
여기서 리팩터링을 통해 불필요한 if문을 제거하면 어떻게 될까?
public boolean isStringLong(String input){
return input.length() > 5;
}
@Test
void test() {
assertThat(isStringLong("abc")).isFalse() // true;
}
메서드 전체 라인 수가 1줄로 줄어들고 테스트가 모든 라인을 수행하므로 테스트 커버리지가 100%가 된다.
예제를 통해 커버리지 숫자에 대해 얼마나 쉽게 장난을 칠 수 있는지 알 수 있다. 프로덕션 코드가 적을수록 커버리지 결과는 좋아진다. 그러나 프로덕션 코드를 줄이는 것이 반드시 코드베이스의 유지 보수성을 높이는 방법은 아니다.
분기 커버리지
또 다른 커버리지 지표는 분기 커버리지다.
분기 커버리지는 코드 커버리지의 단점을 극복하는데 도움이 된다.
분기 커버리지 지표는 원시 코드 라인수 대신 if
, switch
와 같은 제어 구조에 중점을 둔다.
public boolean isStringLong(String input){
return input.length() > 5;
}
@Test
void test() {
assertThat(isStringLong("abc")).isFalse() // true;
}
isStringLong
메서드 구현 방식에 사용된 라인 수는 관여하지 않고 조건문의 분기에 대해서만 관여한다.
현재 false를 반환하는 상황에 대해서만 검증했으므로 분기 커버리지는 50%다.
100%가 되기 위해선 true를 반환하는 상황(5자 이상의 문자열)을 검증하면 된다.
커버리지 지표에 관한 문제점
분기 커버리지로 테스트 커버리지보다 더 나은 결과를 얻을 수 있지만, 테스트 스위트의 품질을 결정하는데 있어 어떤 커비리지 지표도 의존할 수 없다.
- 테스트 대상 시스템의 모든 가능한 결과를 검증한다고 보장할 수 없다.
- 외부 라이브러리의 코드 경로를 고려할 수 있는 커버리지 지표는 없다.
public class StringLongResult {
private static boolean result;
public static boolean getResult() {
return result;
}
public static void setResult(boolean result){
this.result = result;
}
}
public boolean isStringLong(String input) {
boolean result = input.length() > 5;
StringLongResult.setResult(result);
return result;
}
@Test
void test() {
assertThat(isStringLong("abc")).isFalse() // true;
}
isStringLong
메서드에는 결과 값을 반환하는 명시적인 결과와, StringLongResult
클래스의 필드에 새로운 값을 씌우는 암묵적인 결과가 있다. 그리고 테스트 코드에서는 암묵적인 결과에 대해 검증하지 않았음에도
여전히 테스트 커버리지 100%와 분기 커버리지 50%를 보여주고 있다.
보다시피 커버리지 지표는 항상 모든 기반 코드를 테스트했다고 보장할 수 없다.
또한 모든 커버리지 지표가 외부 라이브러리가 통과하는 코드 경로를 고려하지 않는다는 것이다.
private int parseStringToInteger(String input) {
return Integer.parseInt(input);
}
@Test
void test() {
assertThat(parseStringToInteger("5")).isEqualTo(5);
}
분기 커버리지 지표는 100%로 표시되며 모든 구성요소를 검증했지만,
Integer.parse
라이브러리에서 수행하는 코드 경로는 여러가지가 존재할 것이다.
- Null 값 입력
- 공백 입력
- 정수가 아닌 문자열 입력
- 정수 문자열 입력
- …
물론 외부 라이브러리의 모든 경우를 판단하라는 것이 아니다. (해서도 안된다.) 다만 커버리지 지표로는 단위 테스트가 얼마나 좋은지 나쁜지를 판단할 수 없다는 예시이다.
특정 커버리지 숫자를 목표로 하기?
병원에 있는 환자를 생각해보자. 체온이 높으면 열이 난다는 것을 의미할 수 있으며, 이는 유용한 관찰이다. 그러나 병원은 환자의 적절한 체온을 목표로 두어서는 안된다. 단순히 목표가 되면 환자 옆에 에어컨을 설치해서 ‘효율적으로’ 빨리 끝낼 수도 있다. 물론 이런 접근은 아무런 의미가 없다.
마찬가지로 특정 커버리지 숫자를 목표로 하는 것은 단위 테스트의 목표와 반대되는 잘못된 동기부여가 된다. 사람들은 좋은 테스트 대신 인공적인 커버리지 달성 테스트를 작성하게 된다.
📚 무엇이 성공적인 테스트 스위트를 만드는가?
결론부터 말하면, 테스트 스위트가 얼마나 좋은지는 자동으로 확인할 수 없다. 개인의 판단에 맡겨야 한다. 성공적인 테스트 스위트는 다음과 같은 특성을 가지고 있다.
- 개발 주기에 통합돼 있음
- 코드 베이스에서 가장 중요한 부분만을 대상으로 함
- 최소한의 유지비로 최대의 가치를 끌어냄
개발 주기에 통합돼 있음
자동화된 테스트를 할 수 있는 방법은 끊임없이 하는 것뿐이다. 모든 테스트는 개발 주기에 통합돼야 한다. 이상적으로는 아무리 적은 양의 코드가 변경되어도 매번 실행해야 한다.
코드베이스에서 가장 중요한 부분만을 대상으로 함
코드베이스의 모든 부분에 똑같이 주목할 필요는 없다. 시스템의 가장 중요한 부분에 단위 테스트 노력을 기울이고, 다른 부분은 간략/간적접으로 검증하는 것이 좋다. 대부분의 애플리케이션에서 가장 중요한 부분은 비즈니스 로직이 있는 부분이다. 비즈니스 로직 테스트가 시간 투자대비 최고 수익을 낼 수 있다.
그 외에 다른 부분은 3가지 범주로 나눌 수 있다.
- 인프라 코드
- 데이터베이스나 서드파티 시스템과 같은 외부 서비스 및 종속성
- 모든 것을 하나로 묶는 코드
이 중 일부는 단위 테스트를 철저히 해야 할 수 있다. 예를 들어 인프라 코드에 복잡하고 중요한 알고리즘이 있을 수 있으므로 테스트를 많이하는 것이 좋다. 그러나 일반적으로는 인프라코드보다 도메인 코드에 관심을 더 갖는 것이 좋다. 통합 테스트도 시스템이 전체적으로 어떻게 동작하는지 확인할 수 있다. 그러나 초점은 역시 도메인 모델에 머물러야한다. 결국 모두 중요한 테스트들이지만, 비즈니스 로직을 품은 도메인 모델에 초점을 맞추는 것이 투자대비 효율이 좋다.
최소 유지비로 최대 가치를 끌어냄
단위 테스트에서 가장 어려운 부분은 최소 유지비로 최대 가치를 달성하는 것이다. 결국 테스트도 유지보수 대상이다. 가치가 유지비를 상회하는 테스트만 관리하는 것이 좋다.
- 가치 있는 테스트(더 나아가, 가치가 낮은 테스트) 식별하기
- 가치 있는 테스트 작성하기
📚 요약
- 코드는 점점 나빠진다.
- 지속적인 정리와 리팩터링이 없으면 엔트로피가 계속해서 증가한다.
- 테스트를 통해 이를 막아낼 수 있다.
- 모든 테스트를 똑같이 작성할 필요는 없다.
- 각 테스트는 비용과 편익 요소가 존재한다.
- 가치 있는 테스트만 남기고 모두 제거하라.
- 테스트도 유지보수 대상이고 비용이다.
- 커버리지 지표는 좋은 부정 지표이며 동시에 나쁜 긍정 지표이다.
- 지표를 목표로 삼는 행위는 지양하자.
- 성공적인 테스트 스위트는 다음과 같은 특성을 나타낸다.
- 개발 주기에 통합돼 있다.
- 코드베이스 중 가장 중요한 부분만을 대상으로 한다.
- 최소한의 유지비로 최대의 가치를 끌어낸다.
- 반대로 말하면, 3가지에 집중하면 성공적인 테스트가 될 가능성이 높다!
댓글남기기