테스트 코드 작성은 항상 옳다라고 배워왔지만 실무를 하다보면 생각보다 테스트 코드를 작성하지 않는 팀도 많고, 테스트 코드 작성에 부정적인 개발자가 많다는 것을 알 수 있습니다.
왜냐하면 테스트 코드를 작성하는 행위는 제품 코드를 작성하는 것과 버금갈만큼 괘나 리소스를 투자하고 유지보수를 해야하는 작업인데 업무를 하다보면 이에 많은 부담을 느끼기도 하고 회사 업무 특성 상 바빠서 그 시간이 부족할 수도 있기 때문입니다.
저 또한 실무에서 수년 전 이미 작성된 테스트 코드가 있는데 돌려보니 fail
이 떴고, 동작하지 않는 테스트 코드를 동작하게끔 수정하려고 하니 도대체 작성자가 무슨 의도를 가지고 무엇을 테스트하려고 이 테스트 코드를 짰는지 의미 파악이 안 되어 그냥 지워버렸던 경험이 있습니다.
이미 작성된 테스트 코드가 있다고 하더라도 신뢰가 들지 않는 테스트 코드라는 생각이 들면 개발자는 테스트를 지워버리거나 바쁘다는 핑계로 테스트를 우회하는 방법을 찾게 됩니다. 이럴거면 차라리 테스트 코드를 작성하지 않는게 생산성에 더 좋지 않을까? 란 생각을 점점 하게 됩니다.
하지만 오늘날 다양한 현실세계의 요구 사항에 맞추어 시스템에 여러 기능을 추가하고 변경하고 개선하는 진화해가는 과정에서 시스템의 규모가 커지면 커질수록 테스트 코드의 부재로부터 오는 부작용은 커진다고 할 수 있습니다.
요즈음의 소프트웨어 세계는 일년에 고작 한두 번만 업데이트 되던 과거의 소프트웨어 세상과는 완전 다릅니다. 계속해서 현실 세계의 요구 사항에 맞추어 하루에도 몇 번씩 새로운 버전을 릴리즈하는 경우가 많습니다.
오늘은 테스트 코드 없이 시스템이 진화하는 과정에서 어떤 단점, 부작용이 생기고 이를 방지하기 위해 테스트 코드를 작성해야 하는 이유를 알아보고자 합니다.
우리는 개발을 할 때 아키텍처든 DB 스키마든 기능 설계든 예측 가능한 범위에 있어서는 항상 확장 가능성을 열어두고 설계, 개발을 합니다. 이는 개발자의 본능이라고도 할 수 있는데요.
확장성을 염두해두고 설계, 개발을 해두면 추후 예측 가능한 범위에 있어 시스템 변화 요구가 들어온다면 쉽게 대응이 가능이 가능해지고 리소스도 적게 투입하면 되기 때문입니다.
하지만 현실 세계의 요구 사항은 어떻게 변할지 모르는 일..! 시스템은 항상 예측 가능한 범위에 있어서만 진화하지 않고 다양한 비즈니스 요구 사항에 맞추어 전혀 예상치 못한 부분에서 기능 추가, 변경 요구가 들어올 수도 있습니다. 요구 사항을 받은 우리에게 불가능이란 없으며 우리는 가능하면 최대한 모든 비즈니스 요구에 대응 해주어야 합니다.
세상에 변하지 않는 코드는 없습니다, 우리는 모든 것이 항상 변할 수 있다고 가정해야 합니다. 만약 테스트 코드가 없다면 현실 세계의 다양한 변화 요구에 개발자는 소극적으로 대처하게 됩니다.
크게 다음과 같은 이유가 있습니다.
기존 코드를 변경함에 있어 때론 전혀 예상치 못한 곳에서 장애가 발생하기도 합니다.(특히 레거시에서…..) 이를 방지하기 위해 개발자는 기능 변경, 리팩터링 함에 있어 관련 영향도를 파악하기 위해 로컬에서 수동 테스트를 하는데요. 시스템이 커지면 커질수록 수동 테스트에 더 많은 시간을 투자하게 만듭니다.
개발자의 몸값은 한두푼이 아닙니다, 릴리즈는 점점 느려지고 결국 회사는 더 많은 돈을 쓰게 됩니다.
개발자에게는 신규 기능을 추가하는 것보다 기존 코드를 리팩터링하거나 기능을 변경하는 것이 훨씬 어려운 작업입니다. 개발자는 코드를 수정하는 과정에서 기존 기능에 영향이 가 장애가 발생하는 것을 걱정해 리팩터링이나 기능 변경에 점점 소극적으로 대응하게 됩니다.
기존 코드 베이스를 많이 수정할수록 어디에 영향이 가는지 파악하는 리소스를 적게 투입하기 위해 최대한 수정을 적게 하게 됩니다. ex) 비즈니스 변화 요구 사항에 따르면 DB 스키마 구조 변경이 필요한데 변경하지 않고 최대한 간단하게 해결하는 등,,
현실 세계의 비즈니스 모습을 그대로 코드에 녹이지 않고 하나하나 히스토리를 쌓기 시작합니다.
이러한 구조는 장기적으로 시스템의 유지 보수를 어렵게 만들고 시스템이 커질수록 현실 세계의 비즈니스 요구에 대응하는데 점점 시간이 오래 걸리게 됩니다.
우리는 현실세계의 비즈니스 요구사항에 대응하는 것 외에 꾸준히 DB 버전 업그레이드 등 외부 시스템의 변화에 맞추어 관련 디펜던시를 업그레이드하는 등의 유지 보수도 함께 합니다.
어느 순간부터 디펜던시 업그레이드로 인한 기능 영향도 파악하는 것도 어렵게 되어 디펜던시 업그레이드가 꼭 필요한게 아니면 하지 않기 시작합니다. 점점 JDK 버전, 스프링 등 디펜던시 버전이 낮아 현재의 비즈니스 변화 요구에 대응하는데 어려움을 겪기 시작하는 것으로 이어집니다. ex) JDK 버전이 너무 낮아서 카프카를 쓰지 못해여,,
점점 현재 시스템을 유지보수하는 것보다 새로 만드는게 더 낫다는 생각이 들기 시작합니다.
우리는 품질이 떨어지는 제품이나 서비스를 만들면 필연적으로 나쁜 결과를 초래한다는 점을 잊으면 안됩니다. 현실 세계의 요구 사항에 대응이 어려운 구조일 수록 고정 비용은 증가하고, 느리게 대처할 수록 회사 매출에 크나큰 악영향을 주게 됩니다.
우리는 테스트를 실행하여 시스템에 특정 값을 입력하고, 출력 결과를 확인해 시스템이 기대한 대로 동작하는지 이러한 테스트가 수백 개 수천 개 모여 제품이 전체적으로 의도한 설계대로 잘 작동하는지 그렇지 못한지를 판단합니다.
테스트 문화가 확실하게 뿌리 내린 조직을 경험해보지 못한 개발자는 테스트를 작성하면 생산성과 속도가 높아진다고 생각하기 어려울 것입니다 오히려 처음에는 기능 구현에 드는 시간만큼, 혹은 그 이상을 테스트 작성에 투자해야하므로 테스트코드가 생산성을 떨어뜨린다고 생각하는 경우도 많습니다.
테스트에 투자하는 게 개발자 생산성을 향상 시키는 이유는 다음과 같습니다.
테스트를 거친 후 커밋되는 코드는 통상적으로 결함이 적습니다.
한번 작성된 코드는 수명이 다하는 날까지 수십 번 수정됩니다. 테스트를 한번 작성해두면 살아있는 낸내 값비싼 결함을 예방해주고 짜증나는 디버깅에서 해방시켜주는 식으로 혜택을 줍니다.
우리는 프로젝트 혹은 프로젝트가 의존하는 다른 코드가 변경되어 테스트가 실패한다면 릴리즈 전에 정상 상태로 되돌릴 수 있습니다.
모든 소프트웨어는 변경된다고 하였습니다.
변경이 필요할 때 좋은 테스트들로 무장한 팀은 테스트들이 주요 기능들을 끊임없이 검증해주기 때문에 자신감을 가지고 변경들을 리뷰하고 수용할 수 있습니다. 이러한 분위기 속에서는 자연스럽게 리팩터링이 권장됩니다.
한번에 하나의 행위만 집중해 검증하는 명확한 테스트는 마치 실행 가능한 문서와도 같습니다.
커버리지가 높은 명확한 테스트 코드가 작성되어 있다면, 신규 입사자가 처음 팀에 합류했을 때 테스트 코드를 보는 것 만으로도 온보딩에 큰 도움이 되기도 합니다.
또한 요구 사항이 변경되어 새로운 코드가 기존 테스트를 통과하지 못한다면 그 문서 자료(테스트)가 이제 낡았음을 분명히 알아차릴 수 있습니다.
Merge Request를 올릴 때 테스트 코드도 함께 작성해서 올리면 리뷰어가 변경된 코드가 제대로 동작하는지 검증하는 시간을 크게 줄여줍니다.
리뷰어는 테스트가 통과되는 지만 보면 되기 때문입니다.
잘 설계된 코드라면 모듈화가 잘 되어 있어야 하며 테스트하기 쉬워야 합니다.
테스트하기 어려운 코드는 너무 많은 역할을 짊어지거나 의존성을 관리하기 어렵게 짜여졌기 때문일 가능성이 큽니다. 다른 코드와 강하게 결합되지 않고 특정 역할에 집중하게끔 좋은 구조로 설계하게 됩니다.
테스트를 잘 갖추면 새로운 버전을 릴리즈할 때마다 장애가 터지지 않을까 불안에 떨지 않아도 됩니다. 불안에 떨지 않으니 비즈니스 변화 요구 사항을 반영하는 주기가 빨라집니다.
테스트의 가장 중요한 목적은 버그 예방입니다. 테스트 코드는 기본적으로 테스트도 제품 코드처럼 다루어야 하며 유지보수 되어야 합니다.
깨지기 쉬운 질 낮은 나쁜 테스트는 해당 테스트와 관련 없는 코드가 변경되어도 실패할 수 있고 테스트 관리 비용을 증가시켜 제품 생산성을 저하시키는 문제를 초래할 수 있습니다. 따라서 우리는 질 높은 테스트 코드를 작성하려고 노력해야 합니다.
만약 테스트 관리 비용을 낮추는데 투자하지 않는다면 엔지니어들은 점점 테스트가 전혀 가치 없다고 결론 내리고 테스트코드를 작성하지 않는게 더 좋을 수 있다고 생각하게 됩니다.
좋은 테스트 코드란 어떤 의도를 가지고 작성됐는지 파악하기 쉽고 한번 작성되면 제품 스펙이 변하지 않는 한 수정할 필요가 없으며 멱등성이 보장되는 잘 깨지지 않는 테스트 코드를 의미합니다.
단위 테스트는 대체적으로 대상 코드와 동시에 작성할 수 있을만큼 작성하기 쉬워서 작성 중인 코드를 검증하는데 집중할 수 있습니다. 또한 빠르게 작성할 수 있으므로 커버지리를 올리기 좋습니다, 커버리지가 높다면 기존 동작을 망가뜨리지 않으리라는 확신 속에서 변경이 가능합니다.
테스트 실패 시 원인 파악이 쉽고 대상 시스템의 사용법과 의도한 동작 방식을 알려주는 문서자료 혹은 예제코드 역할을 해주므로 단위테스트는 생산성을 끌어 올리는 훌륭한 수단이 될 수 있습니다
좌측은 아이스크림 콘 안티 패턴이고 우측은 테스트 피라미드입니다, 우리는 궁극적으로 콘에서 피라미드 구조로 가야 합니다.
아이스크림 콘 구조는 테스트 부채를 해결하지 못한 프로젝트에서 자주 나타나는 안티패턴으로 엔지니어들이 종단간 테스트를 많이 작성하고 통합테스트나 단위 테스트는 훨씬 적게 작성합니다. 이러한 테스트 스위트는 일반적으로 느리고 신뢰할수 없으며 고치기도 어렵습니다.
되도록 작은 테스트를 추구하며 비즈니스 로직 대부분을 검증하는 좁은 범위의 단위 테스트가 80%, 둘 이상의 구성 요소간 상호작용을 검증하는 통합 테스트가 15%, 전체 시스템을 검증하는 종단간 테스트가 5%되도록 함을 권장합니다. (우측의 피라미드 형태로 팀마다 그 비율은 다를 수 있습니다)
질 나쁜 테스트는 커밋되기 전에 수정되어야 합니다. 그렇지 않으면 미래의 후임 엔지니어들이 고통받게 될 것입니다.
우리는 깨지기 쉬운 테스트와 어떻게 고쳐야 하는지 파악하기 어려운 불명확한 테스트 작성을 지양해야 합니다.
실제론 버그가 없음에도, 검증 대상 코드와 관련 없는 변경 때문에 실패하는 테스트를 의미합니다. 이상적인 테스트는 한번 작성한 후로는 대상 시스템의 요구 사항이 변하지 않는 한 절대 수정할 일이 없어야 합니다.
공개API란 코드 소유자가 서드파티에 노출한 API를 의미합니다. 프로그래밍 언어에서의 가시성과는 조금 다른 의미입니다.
어디까지가 공개 API인지는 정해진 정답은 없으나 구글에서는 다음과 같은 경험 법칙을 사용합니다.
간혹 테스트 커버지리를 올리기 위해 공개되지 않은 API도 테스트가 필요한가란 의문을 가지는 사람들이 있습니다.
공개되지 않은 API를 대상으로 테스트 스위트를 작성하면 포맷을 바꾸거나 메서드 명을 바꾸는 등 리팩터링을 할 때마다 깨지기 쉬운 테스트가 되므로 지양하는 것이 좋습니다.
우리는 공개된 API를 여러 케이스에 맞게 테스트 스위트를 여러 개 작성하는 방식으로 공개된 API만 테스트를 해도 모든 API를 테스트하는 것과 같은 수준의 테스트 커버리지를 올릴 수 있습니다.
public API만 이용하는 테스트는 정의상 대상 시스템을 사용하는 사용자의 유즈케이스와 유사한 방식으로 작성되어 더 현실적이고 잘 깨지지 않습니다.
시스템이 기대하는 대로 동작하는지 확인하는 방법은 상태 테스트와 상호작용 테스트가 있습니다. 상태 테스트란 메서드 호출 후 시스템 자체를 관찰하는 테스트이고, 상호작용 테스트란 호출 후 처리하는 과정에서 다른 모듈들과 협력해 기대한 일련의 동작을 수행하는 지를 확인하는 테스트를 의미합니다.
상호작용 테스트의 예
@Test
public void shouldWriteToDatabase() {
accounts.createUser("foobar");
verify(database).put("foobar"); // 메서드 호출 여부 확인
}
상태 테스트의 예
@Test
public void shouldWriteToDatabase() {
accounts.createUser("foobar");
assertThat(accounts.getUser("foobar")).isNotNull();
}
상호작용 테스트는 상태 테스트보다 깨지기 쉽습니다. 상태 테스트는 결과가 무엇인지에 초점을 맞추고 상호작용 테스트는 시스템이 어떻게 의도한대로 동작하는지에 초점을 맞춥니다.
상호작용 테스트는 실패되어야 하는 상황에서도 테스트가 성공할 가능성이 크고 리팩터링을 통해 다른 API를 호출하도록 변경됐다면 테스트가 실패할 수 있습니다. 이와 달리 시스템의 상태를 확인했다면 테스트가 깨질 염려를 크게 줄일 수 있습니다.
따라서 상호작용 테스트 보다는 상태 테스트를 작성하는 것이 좋습니다.
깨지기 쉬운 테스트를 모두 제거 했어도 언젠가 테스트는 실패할 것입니다. 테스트가 실패하는 이유는 크게 2가지입니다.
테스트 실패가 발생하면 어느 케이스에 속하는지 알아야 하며 이 일을 얼마나 빠르게 마치느냐는 테스트의 명확성에 달려있습니다.
명확한 테스트란 테스트의 존재 이유와 실패 원인을 엔지니어가 곧바로 알아차릴 수 있는 테스트를 의미합니다. 명확한 테스트를 작성하기 위해서 테스트를 완전하고 간결하게 만들면 좋습니다.
테스트의 명확성이 떨어지면 작성자가 팀을 떠난 지 오래인 수년 전 테스트가 갑자기 실패했을 때, 이를 수정하고자 들여다봐도 무엇을 검사하려 했는지, 어떻게 고쳐야 하는지 알 수 없는 난감한 상황이 발생합니다. 최악의 경우 해법을 찾기 못한 엔지니어가 불명확한 테스트들을 지워버리게 됩니다.
많은 엔지니어가 본능적으로 제품 코드 메서드 하나에 테스트 메서드 하나 두는 식으로 테스트의 구조를 대상 코드의 구조와 일치시키려고 합니다. 이러한 메서드 중심 테스트 케이스 작성 방법은 처음엔 편리하지만 대상 메서드가 복잡해질수록 테스트도 같이 복잡해져서 실패해도 원인을 파악하기 어려워집니다.
메서드를 중심으로 테스트를 작성하는 경우엔 메서드의 기능이 변경되며 테스트 확장하는 과정에서 테스트 스위트의 유지보수가 점점 복잡해질 수 있습니다. 이를 행위 중심으로 테스트를 작성하면 좋습니다.
여기서 행위란 특정 상태에서 특정한 일련의 입력을 받았을 때 시스템이 보장하는 반응을 의미하며 given when then 패턴으로 주로 표현합니다.
@Test
public void testDisplayTransactionResults_showsItemName() {
// given
주어진 입력
// when
transactionProciessor.displayTransactionResults(주어진 입력);
// then
assertThat(ui.getText()).contains("물품을 구입하셨습니다");
}
@Test
public void testDisplayTransactionResults_showsLowBalanceWarning() {
// given
주어진 입력
// when
transactionProciessor.displayTransactionResults(주어진 입력);
// then
assertThat(ui.getText()).contains("잔고가 부족합니다");
}
테스트 각각은 단 하나의 행위만 다뤄야 하며 대부분 테스트에는 when과 then 블록이 하나씩이면 충분합니다.
제품 코드는 여러 논리가 내포되어 복잡하기 때문에 이를 검증하기 위하여 테스트를 작성합니다. 테스트 작성 시에는 논리(연산자, 반복문, 조건문 등)가 들어가지 않도록 작성하는 것이 좋습니다. 논리가 조금만 들어가도 추론하기가 어려워지며 복잡성이 증가하고 버그를 감추기가 매우 쉬워지기 때문입니다.
@Test
public void shouldNavigateToAlbumsPages() {
String baseUrl = "http://naver.com/";
Navigator nav = new Navigator(baseUrl);
nav.goToAlbumPage();
assertThat(nav.getCurrentUrl()).isEqualTo(baseUrl + "/albums");
}
여기서 논리를 제거하면 감춰져 있던 버그가 드러납니다
@Test
public void shouldNavigateToAlbumsPages() {
Navigator nav = new Navigator("http://naver.com/");
nav.goToAlbumPage();
assertThat(nav.getCurrentUrl()).isEqualTo("http://naver.com//albums");
}
비록 URL이 2번 서술됐지만 테스트코드에서는 스마트한 로직보다 직설적인 코드를 고집하는게 좋습니다. 더 서술적이고 의미 있는 테스트를 만들기 위해 약간의 중복을 허용합니다.
대부분의 소프트웨어는 반복하지 말라 (Don’t repeat yourself)라는 DRY 원칙을 숭배합니다. DRY는 개념들을 각각 독립된 하나의 장소에서 구현하여 코드 중복을 최소로 줄이면 유지보수하기 더 쉽다고 말합니다. 이 원칙대로 프로그래밍을 하면 기능을 변경해야할 때 한 곳의 코드만 수정하면 끝이므로 아주 유용합니다.
하지만 테스트에서는 사정이 다릅니다, 좋은 테스트는 안정적이고 대상 시스템의 행위가 변경되면 실패하도록 설계됩니다. 따라서 테스트코드에서는 DRY가 주는 혜택이 그리 크지 않고 테스트가 복잡해질수록 손해가 막심해집니다. 제품코드는 복잡해져도 동작을 보장해주는 테스트 스위트가 있어서 괜찮지만 테스트가 복잡해져서 테스트를 검증할 테스트가 필요하다고 느껴지기 시작하면 무언가 잘못된 것이라고 할 수 있습니다.
테스트 코드는 DRY 대신 “단순하고 명료하게” DAMP(Descriptive And Meaningful Phrase)가 우선 되도록 노력해야 합니다. 단순하고 명료하게만 만들어준다면 테스트에서 상대적으로 많은 중복은 괜찮습니다.
DAMP는 DRY를 대체하지는 않고 보완해주는 개념이라고 보아야 합니다. 무조건적으로 DRY 원칙을 적용하면 안된다는 의미가 아닙니다.
테스트 케이스들을 위한 DRY 원칙이 적용된 도우미 메서드는 테스트를 더 명확하게 만드는데 도움이 될 수도 있습니다. 핵심은 테스트에서의 리팩터링은 반복을 줄이는 쪽이 아니라 아니라 더 서술적이고 의미있게 하는 방향으로 이루어져야 한다는 의미입니다.
테스트에서 DRY 원칙을 적용할 때 공유 셋업(junit으로 보면 @Before 등), 공유 도우미 메서드와 공용 검증 메서드, 도우미 메서드를 활용한 공유값 처리 등의 케이스가 있는데 대표적으로 공유값 처리를 보겠습니다.
수백 개의 테스트 케이스를 작성하다 보면 여러 테스트 케이스에 걸쳐 나타나는 공유값을 처리하는 과정에서 문제가 발생합니다. 필요한 값들을 테스트마다 일일이 준비하려면 장황하고 귀찮기 때문에 본능적으로 공유 값들을 상수로 정의해서 사용하고 싶어집니다. 상수명을 서술적으로 지으면 어느정도 도움이 되겠지만 테스트 케이스가 많아질 수록 왜 각 테스트에서 이 값을 선정했는지 알기 어려워지고 세부 정보를 알기 위해 파일 상단까지 스크롤해야하는 문제가 발생합니다.
도우미 메서드를 이용해 필요한 값 이외에는 기본값을 설정하도록 하는 방식으로 공유 값을 처리해주면 좋습니다.
// 각 매개변수에 임의의 기본값을 정의해 생성자를 랩핑
def newUser(name = "hello", age = 10) {
return User(name, age)
}
// --> 각 테스트 케이스에서는 필요한 값 외에는 기본값을 할당받음
// 자바처럼 이름 있는 매개변수를 지원하지 않는 경우 빌더 패턴으로 흉내 가능
private static User.Builder newUser() {
return User.newBuilder()
.setName("hello")
.setAge(10);
}
// --> 각 테스트 케이스에서는 필요한 값 외에는 기본값을 할당받음
이처럼 도우미 메서드를 활용하면 불필요한 정보로 오염되거나 다른 테스트와 충돌할 염려 없이 각 테스트에서는 정확히 필요한 값만 생성해 사용할 수 있습니다.