이 글은 Kostis Kapelonis의 글 Software Testing Anti-patterns을 번역한 글입니다. 이 글은 메일을 통해 저자에게 허락을 구한 뒤 번역되었으며, 원문은 링크에서 찾아보실 수 있습니다.
그동안 소프트웨어 개발 중 발생하는 테스트 안티 패턴에 대한 글을 여럿 보았습니다.
그리고 많은 글이 특정 언어의 로우 레벨 세부 사항에만 집중한다는 것을 알게 되었습니다.
본 글은 특정 언어에 국한되지 않는 공통적인 테스트 안티 패턴을 소개합니다.
아직 테스트 용어는 일치된 정의가 없습니다. 즉, 개발자 백명에게 통합 테스트, 단위 테스트, 엔드 투 엔드 테스트의 차이점을 물어보면 각자 다른 대답을 할 것입니다. 그러므로 이 글은 테스트 피라미드(Test pyramid)로 표현되는 일반적인 케이스에 집중하도록 하겠습니다.
테스트 피라미드에 대해 모른다면 아래 링크를 한 번 살펴보시길 바랍니다.
테스트 피라미드만으로도 충분히 글 하나를 쓸 수 있지만 이번에는 피라미드 가장 아래의 단위 테스트와 통합 테스트에 대해 이야기 해보겠습니다. 엔드 투 엔드 테스트는 다음에 다루도록 하겠습니다.
이제 단위 테스트와 통합 테스트의 성질을 살펴봅시다.
명칭 | 대상 | 범위 | 속도 | 복잡도 | 셋업(Setup) |
---|---|---|---|---|---|
단위 테스트 (Unit tests) | 클래스/메쏘드 | 소스 코드 | 매우 빠름 | 낮음 | 필요하지 않음 |
통합 테스트 (Integration tests) | 컴포넌트/서비스 | 동작하는 시스템의 일부 | 느림 | 중간 | 필요함 |
단위 테스트는 넓은 의미의 대상을 가리킵니다. 클래스나 메쏘드를 직접 접근/실행시키는 테스트를 가르키며, 일반적으로 xUnit
(주: 자바의 유닛 테스트 라이브러리) 혹은 다른 테스트 라이브러리를 통해 실행시킵니다. 단위 테스트는 보통 단일 클래스, 메쏘드, 함수를 대상으로 그 외 부분은 모킹되거나(mocked
) 스텁(stubbed
) 처리됩니다.
반면 통합 테스트는 컴포넌트 전체를 테스트합니다. 컴포넌트는 여러 클래스, 메쏘드, 함수, 모듈, 시스템 일부 혹은 애플리케이션 전체가 될 수 있습니다. 통합 테스트는 특정 데이터를 집어넣은 결과를 살펴보는 테스트입니다. 일반적으로 실행 전에 애플리케이션 배포 및 복잡한 셋업이 필요하고, 외부 시스템은 전부 모킹, 교체되거나 (인메모리(in-memory
) 데이터베이스 등) 그대로 사용될 수도 있습니다. 그래서 단위 테스트와달리 테스트 환경을 갖추거나 검증하기 위해 추가적인 준비가 필요할 수 있습니다.
통합 테스트는 정의가 불분명하여 범위나 다른 세부사항에 대해 여러 의견이 존재합니다.
저는 일반적으로
여기서 하나 이상 해당된다면 통합 테스트라고 봅니다.
용어에 대한 정의를 내렸으니 본격적으로 안티 패턴을 살펴봅시다. 아래 목록은 일상적으로 흔하게 접할 수 있는 순서대로 작성하였습니다.
이 패턴은 보통 작은 규모에서 보통 규모 회사에서 자주 나타나는데, 단위 테스트만 작성되고 통합 테스트가 전혀 작성되지 않는 경우입니다. 일반적으로 통합 테스트가 부족한 이유는 아래와 같습니다.
첫 번째 상황이라면 할 수 있는 일이 별로 없습니다. 어떤 팀이든 효율적으로 일하기 위해서는 멘토가 필요합니다. 두 번째 상황은 이어지는 5, 7, 8번에서 더 자세하게 다루겠습니다.
세번째 상황처럼 테스트 환경을 구축하기가 너무 어려운 경우도 있습니다. 저도 예전에 특별한 종류의 하드웨어를 호스트 머신에 설치해야 했던 적이 있었습니다. 하지만 이런 경우는 말 그대로 특별한 경우일 뿐입니다.
평범한 웹/백엔드 애플리케이션이라면 테스트 환경을 구축하는 일은 더이상 문제가 되지 않습니다. 거기에 가상 머신과 컨테이너 기술의 등장으로 테스트 환경 구축이 더욱 쉬워졌습니다. 환경 구축이 정말로 어렵다면 먼저 프로세스를 손 보는게 맞습니다.
그렇다면 통합 테스트가 왜 그렇게 중요할까요?
가장 중요한 이유는 어떤 이슈는 통합 테스트를 통해서만 발견할 수 있기 때문입니다. 대표적인 케이스는 데이터베이스 이슈입니다. 데이터베이스 트랜잭션(transaction
), 트리거(trigger
) 혹은 저장 프로시져(stored procedures
) 이슈는 통합 테스트를 통해서만 검증할 수 있습니다. 다른 모듈이나 외부 모듈을 사용하는 경우 또는 성능을 검증해야 할 때도 통합 테스트를 통해서만 검증할 수 있습니다.
요약하면:
이슈 | 단위 테스트를 통한 검증 가능성 | 통합 테스트를 통한 검증 가능성 |
---|---|---|
기본적인 비즈니스 로직 | 가능 | 가능 |
컴포넌트 간의 통합 이슈 | 불가 | 가능 |
데이터베이스 트랜잭션 | 불가 | 가능 |
데이터베이스 트리거/저장 프로시져 | 불가 | 가능 |
다른 모듈/API에 노출되는 인터페이스 (contract ) | 불가 | 가능 |
다른 시스템에 노출되는 인터페이스 (contract ) | 불가 | 가능 |
성능/타임아웃 | 불가 | 가능 |
데드락(dead lock ), 라이브락(live lock ) | 어느 정도 가능 | 가능 |
시스템 전체 보안 이슈 | 불가 | 가능 |
쉽게 생각하면 시스템의 여러 부분과 관련있는 이슈라면 통합 테스트가 필요하다는 것입니다. 이 추세는 최근 마이크로서비스(microservice
) 열풍과 함께 더욱 강해졌습니다. 그러므로 모든 시스템을 혼자 개발하는 것이 아닌 이상 어떤 방식으로든 자동으로 시스템 간의 계약이 깨지지 않았는지 확인할 필요가 있습니다.
정리하면 애플리케이션 디펜던시 형태가 극단적으로 단순한(isolated) 것이 아니라면, 단위 테스트를 통해 발견할 수 없는 이슈를 발견하기 위해 통합 테스트가 꼭 필요합니다.
이 패턴은 첫 번째 경우의 정반대 상황으로 큰 규모의 회사나 엔터프라이즈 프로젝트에서 자주 발생합니다.
이는 대다수 개발자가 단위 테스트는 쓸모없고, 통합 테스트를 통해서만 버그를 줄일 수 있다고 생각하는 경우에 벌어집니다. 상당수 경력 개발자가 단위 테스트를 시간 낭비로 치부하는데, 이는 과거 코드 커버리지(code coverage)에 집착하는 팀 문화에 사소한 부분까지 단위 테스트 작성을 강요받았기 때문입니다.
통합 테스트만 가지고도 프로젝트를 운영할 수 있습니다. 하지만 테스트를 작성하고 실행하는데 필요한 시간이 과도하게 낭비됩니다. 위 표를 보면 단위 테스트로 검증 가능한 모든 이슈는 통합 테스트를 통해 검증할 수 있습니다. 그러므로 통합 테스트를 통해 유닛 테스트를 완전히 대체할 수 있다고 생각할 수 있습니다. 하지만 이 전략은 장기적으로 유효하지 않습니다.
아래 네 개 메쏘드, 클래스, 함수를 가진 서비스를 생각해봅시다.
숫자는 모듈의 순환복잡도(cyclomatic complexity
) - 주: 해당 모듈에서 분기(if-else)문을 통해 나타날 수 있는 상황의 개수 - 를 나타냅니다.
성실한 개발자 Mary는 먼저 단위 테스트를 작성하고자 합니다. 모든 케이스를 검증하려면 테스트가 몇 개나 필요할까요?
간단한 계산 결과 2 + 5 + 3 + 2 = 12개가 필요합니다. 물론 이 숫자는 모듈 하나를 검증하기 위한 테스트의 개수입니다.
게으른 개발자 Joe는 반대로 유닛 테스트가 쓸모없다고 생각하므로 통합 테스트만을 작성하기로 결심합니다.
이 경우에 Joe가 작성해야하는 통합 테스트의 개수는 몇 개일까요? 먼저 경우의 수를 고려해봅시다.
계산 결과 모든 가능한 경우의 수는 2 * 5 * 3 * 2 = 60개입니다. 물론 그렇다고 해서 Joe가 모든 테스트를 작성한다고 볼 수는 없습니다. Joe는 대표적인 테스트 케이스를 검증하는 테스트만 작성하려고 할 것입니다. 대표 케이스에 대한 테스트는 최소한의 노력으로 충분한 커버리지를 제공할 수도 있습니다.
간단한 이야기같지만 실제로는 금방 문제 상황에 봉착합니다. 현실적으로 60개 케이스는 서로 다른 확률로 발생합니다. 몇몇 시나리오는 자주 발생하지 않는 케이스(corner case
)입니다. 예를 들어 모듈 C에 세 가지 케이스가 있고 한 개는 특별한 케이스일 때, 이 경우 해당 케이스가 컴포넌트 A나 B의 특별한 경우에 대해서만 발생한다면 이 케이스를 재현하기 위해 복잡한 셋업이 필요하다는 얘기가 됩니다. (해당 케이스를 재현하기 위해 모듈 C뿐만 아니라 A와 B를 모두 건드려야하므로)
반면 Mary는 간단한 단위 테스트만으로 복잡성을 크게 증가시키지 않고 해당 케이스를 테스트할 수 있습니다.
물론 Mary가 단위 테스트만 작성한다면 안티 패턴 1에 해당됩니다. 이 상황을 피하려면 단위 테스트와 함께 통합 테스트를 같이 작성해야 합니다. 단위 테스트를 통해서 개별 비즈니스 로직을 점검하고, 통합 테스트 케이스 1 ~ 2개로 해당 모듈이 예상대로 동작하는지 검증해야합니다.
단위 테스트로도 비즈니스 로직을 점검할 수 있으므로, 통합 테스트는 다른 시스템 컴포넌트와의 동작을 검증하는데 집중하는게 낫습니다. 직렬화(serialization)/역직렬화(deserialization)나 큐, 데이터베이스 통신 같은 것 말이죠.
그렇게 함으로써 단위 테스트 대비 통합 테스트의 숫자를 크게 줄일 수 있으며, 테스트 피라미드에 언급된 이상적인 테스트 비율에 가까워지게 됩니다.
통합 테스트의 두 번째 문제점은 속도입니다. 일반적으로 통합 테스트는 단위 테스트에 비해 현저히 느리며, 소스 코드 이외에 크게 필요한 것이 없는 단위 테스트와 달리 통합 테스트는 외부 시스템과의 I/O 수행이 필수적이기 때문에 빠르게 실행시키기 어렵습니다.
이해를 돕기 위해 각각의 경우 테스트를 전부 실행시키는데 시간이 얼마나 필요한지 살펴봅시다.
경우 | Joe (통합 테스트만 작성) | Mary (둘 모두 작성) |
---|---|---|
단위 테스트만 실행 | 없음 | 24초 |
통합 테스트만 실행 | 6.4분 | 64초 |
둘 모두 실행 | 6.4분 | 1.4분 |
총 실행시간 격차가 상당합니다.
코드를 작성하고 6분을 기다리는 것과 1분을 기다리는 것의 차이를 생각해보면 실제 격차는 더 크게 벌어집니다. 게다가 통합 테스트 1건 당 800ms는 상당히 낙관적인 수치입니다. 어떤 경우 통합 테스트 한 건 실행에 수 분이 걸릴 수도 있습니다.
요약하면 통합 테스트만으로 모든 케이스를 검증하는 것은 굉장한 시간 낭비입니다. 그러므로 통합 테스트만 있는 경우에는 설사 CI를 통해 모든 테스트 과정을 자동화하더라도 반응 주기(feedback loop
)가 훨씬 지체될 것 입니다.
유닛 테스트 없이 통합 테스트만 작성하는 것이 안티 패턴인 마지막 이유는 실패한 테스트를 디버깅하는 것이 훨씬 복잡하기 때문입니다. 통합 테스트는 필연적으로 여러 컴포넌트를 대상으로 수행되기 때문에 테스트가 실패한다면 서로 다른 컴포넌트에서 원인을 찾아야합니다. 테스트 대상 컴포넌트 개수가 늘어나면 문제를 특정짓는 것이 더욱 어려워집니다.
통합 테스트가 실패하면 실패 원인을 찾아 해결 방법을 찾을 수 있어야합니다. 하지만 통합 테스트는 복잡하고 대상이 광범위하기 때문에 디버깅이 어렵습니다. 다른 예를 들어봅시다. 이번에는 통합테스트밖에 없는 이커머스 애플리케이션을 가정해보겠습니다.
다른 팀 개발자가 새로운 커밋을 작성하고, 아래와 같은 결과를 확인했다고 생각해봅시다. (CI를 통해 커밋을 작성할 때마다 테스트가 수행된다고 가정)
결과를 보면 금방 "고객이 물건을 구매하는 경우"에 대한 테스트가 실패했다는 것을 알 수 있습니다. 하지만 이 테스트 결과는 크게 도움이 되지 않습니다. 테스트 실패 원인을 특정지을 수 없기 때문입니다.
결국 테스트 실패 원인을 찾으려면 로그와 테스트 환경의 각종 지표(metric)를 살펴봐야만 합니다. 그러므로 복잡한 애플리케이션에서 통합 테스트를 디버깅하려면 코드를 받아 통합테스트를 로컬 환경에서 실행시켜보는 수밖에 없죠.
이제 당신이 Mary와 함께 작업한다고 생각해봅시다. 이번에는 단위 테스트와 통합 테스트가 모두 작성된 상태입니다. 이제 다른 팀 동료가 커밋을 작성하고, 다음과 같은 테스트 결과를 확인했습니다.
이제 두 개 테스트가 실패했습니다.
이 경우 문제를 특정 짓는 것이 훨씬 간단합니다. 할인 관련 코드를 디버깅하면 통합 테스트도 금방 고칠 수 있습니다.
단위 테스트와 통합 테스트가 함께 있다면 버그를 찾아내는 일이 훨씬 덜 고통스러워 집니다.
이 부분이 이번 글에서 가장 길고 중요한 부분입니다. 이론적으로 통합 테스트만으로 모든 경우를 검증할 수 있지만 유닛 테스트가 훨씬 유지보수하기 쉽고, 자주 발생하지 않는 케이스를 쉽게 재현할 수 있으며, 실행 시간이 짧습니다. 또 실패한 통합 테스트보다는 실패한 유닛 테스트를 디버깅하는 것이 더 쉽습니다. 만약 통합 테스트만을 작성하고 있다면 개발 시간과 회사의 돈을 모두 낭비하고 있는 것입니다. 단위 테스트와 통합 테스트가 모두 필요하고, 이 둘은 서로 전혀 다른 존재가 아닙니다. 이 둘이 전혀 다르다는 식의 글은 잘못된 정보를 전달하고 있습니다. 슬픈 일입니다.
여기까지 두 가지 테스트가 모두 필요한 이유를 살펴보았습니다. 이제 두 테스트를 어떤 비율로 작성해야하는지 알아봅시다.
여기에 엄격한 규칙은 없고, 애플리케이션의 성격에 따라 크게 달라집니다. 중요한 점은 어떤 종류의 테스트가 애플리케이션 개발에 더 큰 도움이 될 수 있는가를 이해하는 것입니다. 테스트 피라미드는 일반적인 사례의 예시일 뿐입니다. 테스트 피라미드는 애플리케이션이 일반 상업용 웹 애플리케이션이라고 전제하지만 항상 그렇지는 않으니까요. 이제 다른 상황을 각각 살펴봅시다.
현재 커맨드 라인 유틸리티를 작성하고 있습니다. 이 프로그램은 CSV 같은 파일을 읽어들여 JSON 같은 다른 형식으로 변환합니다. 이 애플리케이션은 독립적으로 작동하며, 다른 시스템과 상호작용하거나 네트워크 통신하지 않습니다. 변환 과정은 대부분 복잡한 수학적 과정으로 이뤄져있습니다.
이 경우 아래와 같은 테스트 모델이 필요합니다.
이 경우 유닛 테스트가 지배적이며, 피라미드 형태를 따르지 않습니다.
이제 엔터프라이즈 시스템에 들어갈 새로운 애플리케이션을 개발하고 있습니다. 이 애플리케이션은 결제 게이트웨이 애플리케이션이며, 외부 시스템에 결제 정보를 처리하여 전달합니다. 이 애플리케이션은 모든 트랜잭션 기록을 외부 디비에 보관해야 하며, 외부 결제 모듈 제공자(Paypal, Stripe, WorldPay)와 상호작용해야 합니다. 그리고 인보이스를 생성하는 다른 시스템에 결제 정보를 전송하는 역할도 수행해야 합니다.
이 경우 아래와 같은 테스트 모델이 필요합니다.
이 경우 통합 테스트가 지배적이며, 마찬가지로 피라미드 형태를 따르지 않습니다.
이제 웹 사이트를 제작 방식을 혁신할 새로운 스타트업에서 일하고 있습니다. 이 애플리케이션은 아주 특별한 방식으로 웹 브라우저 상에서 웹 사이트를 생성하는 기능을 제공할 것입니다.
에플리케이션은 웹페이지에 삽입 가능한 HTML 요소와 템플릿을 제공하고, 앱 내 마켓을 통해 템플릿을 구매할 수도 있습니다. 또 애플리케이션은 드래그 앤 드랍 방식을 통해 요소를 배치하고, 사이즈를 재조정하거나 각종 속성을 수정하는 쉽고 친절한 방식을 제공합니다.
이 경우 아래와 같은 테스트 모델이 필요합니다.
이 경우 엔드 투 엔드 테스트가 지배적이며, 마찬가지로 피라미드 형태를 따르지 않습니다.
조금 극단적인 예시를 사용했지만 중요한 점은 애플리케이션 기능 수행을 도울 수 있는 방향으로 테스트를 작성해야 한다는 것입니다. 저는 통합 테스트를 전혀 작성하지 않는 결제 관리 시스템이나 엔드 투 엔드 테스트를 전혀 작성하지 않는 GUI 웹 사이트 생성기를 봤습니다.
어떤 글은 각 테스트의 구체적인 비율을 얘기하기도 합니다만 어떤 애플리케이션을 작성하고 있느냐에 따라 그 비율은 정확할수도 전혀 상관없을 수도 있습니다.
이전 파트에서는 어떤 테스트를 어느 비율로 작성해야 하는지에 대해 얘기했습니다. 이제 어떤 기능을 우선적으로 테스트해야 하는지에 대해 얘기해야겠죠.
코드 커버리지 100%는 궁극적인 목표가 될 수 있겠지만 달성하기 어려울 뿐만 아니라 애플리케이션에 버그가 발생하지 않는 것을 보장해주지도 않습니다.
모든 기능을 전부 테스트할 수 있는 경우도 있습니다. 작은 팀으로 진행되는 새로운 프로젝트에서 모두가 테스트의 중요성을 아는 경우죠.
물론 모두가 이렇게 운이 좋지는 않습니다. 대부분 테스트가 최소한으로 작성되어 있거나 전혀 존재하지 않는 경우에 직면해 있습니다. 큰 기업에서 레거시(legacy) 코드는 거의 항상 존재합니다.
이상적인 상황이라면 레거시 애플리케이션의 모든 기능에 대한 테스트를 작성할 수 있을만큼 개발시간이 충분합니다. 하지만 일반적인 프로젝트 매니저는 테스트 추가나 리팩토링 대신 새로운 기능 하나를 더 추가하길 원할 겁니다. 그러므로 새로운 기능 추가와 테스트 작성 사이에서 타협점을 찾아야만 합니다.
이 상황에서 무엇을 테스트해야 할까요? 어디에 더 많은 시간을 들여야 할까요? 저는 종종 개발자가 애플리케이션 안정성과 전혀 상관없는 단위 테스트를 작성하기 위해 시간을 낭비하는 것을 지켜봤습니다. 애플리케이션 데이터 모델의 속성을 검증하는 사소한 단위 테스트 같은 것들 말이죠.
코드 커버리지는 다른 섹션에서 다룰 생각이며, 이번 파트에서는 코드 중요성(severity
)이 테스트와 어떤 연관이 있는지 알아보겠습니다.
개발자에게 소스 코드를 보여달라고 하면 아마 IDE나 레포지토리를 열고 각 폴더 구조를 보여줄 것입니다.
위 사진은 코드의 물리적 모델을 잘 보여줍니다. 모델은 소스코드를 담고 있는 폴더와 파일 시스템을 결정합니다. 이 모델은 코드를 작성하고 관리하는데는 도움이 되지만 개별 폴더의 중요성까지 나타내지는 않습니다. 평평한 구조의 폴더(flat list of folders)는 모든 폴더가 동등하게 중요하다는 인상을 주니까요.
전체 애플리케이션 동작에 개별 컴포넌트가 미치는 영향은 전혀 다릅니다. 이커머스 애플리케이션 예시를 들어봅시다. 아래 두 버그 중에서 어떤 쪽이 더 심각한 버그일까요?
두 가지 버그 모두 고쳐져야 하겠지만 첫 번째가 더 중요한 경우 입니다. 그러므로 테스트가 전혀 작성되지 않은 상태로 인수 인계를 받았을 때, 가장 먼저 해야할 일은 결제 기능 테스트를 작성하는 일입니다. 결제 기능과 추천 엔진은 폴더 구조 상 같은 레벨에 있지만 테스트 상 중요성은 전혀 다릅니다.
이제 중간 규모에서 큰 애플리케이션을 유지보수한다고 생각해봅시다. 이제 물리적 모델과는 전혀 다른 새로운 멘탈 모델이 필요합니다.
예시는 세 레이어를 보여주지만 애플리케이션 크기에 따라 이것보다 적을 수도 많을 수도 있습니다.
중요코드(critical code)는 자주 깨지고, 가장 많은 새 기능이 추가되며 유저에게 가장 큰 영향을 끼치는 부분입니다.
다음은 핵심코드(core code)로, 가끔 깨지고, 많은 새 기능이 추가되며 유저에게 중간 정도의 영향을 끼치는 부분입니다. 나머지는 거의 바뀌지 않으며, 유저에게 최소한의 영향만을 끼치는 부분입니다.
새로운 테스트를 작성할 때 위 멘탈 모델을 따라야 합니다. 새로운 기능이 중요코드나 핵심코드에 속하는지 먼저 물어봐야 합니다. 그렇다면 빨리 테스트를 작성해야 합니다. 그렇지 않다면 그 시간을 다른 곳에 투자하는 것이 더 낫습니다.
코드를 중요도에 따라 나누는 멘탈 모델은 코드 커버리지가 어느 정도 필요한가라는 오래된 질문에 답을 줄 수 있습니다. 각 코드의 중요도를 알고 있다면 대답은 명확합니다:
중요코드를 100% 커버하는 테스트 코드를 먼저 작성하라.
이미 작성을 마쳤다면 핵심코드의 커버리지를 100%로 만들어라.
두 가지에 속하지 않는 코드에 대한 테스트를 작성해서 커버리지를 더 높일 필요는 없다.
중요한 점은 핵심코드가 언제나 애플리케이션의 일부분이라는 것입니다. 만약 핵심코드가 20%라면 20%의 코드 커버리지를 가지는 것만으로 프로덕션에서 발생하는 버그를 크게 줄일 수 있습니다.
요약하면, 아래와 같은 코드에 대한 테스트를 먼저 작성해야 합니다.
테스트가 더 많아질수록 더 좋을까요?
그렇지 않습니다. 테스트가 바른 방향으로 작성되는지를 체크해야 합니다.
잘못된 테스트는 나쁩니다:
엄격하게 말하면 테스트 코드 또한 다른 모든 코드와 같습니다. 테스트 코드 또한 리팩토링을 통해 점진적으로 개선되어야 합니다. 새 기능을 추가하기 위해 기존 테스트를 조금씩 변경하고 있다면 테스트가 잘못된 방향으로 작성되었을 가능성이 높습니다.
제가 다녔던 어떤 회사에서 새로운 프로젝트를 시작하면서 의욕적으로 모든 기능을 커버하는 테스트를 작성한 뒤, 얼마 지나지 않아 새 기능이 추가되면서 많은 기존 테스트가 변경되어야했던 적이 있습니다. 또 다른 새로운 기능이 추가되면 더 많은 테스트가 변경되어야 했죠. 결국 나중에는 기존 테스트를 리팩토링하는데 더 많은 시간이 소요되었습니다.
결국 개발자는 패배를 인정하고 테스트를 시간 낭비라 여기며 테스트를 모조리 버리고 새로운 기능 추가에만 집중합니다. 어떤 경우에는 새 기능 추가를 잠시 중단하기도 하는데, 이는 너무 많은 기존 테스트가 영향을 받기 때문입니다.
이런 상황이 발생한 것은 테스트 코드의 낮은 퀄리티 때문입니다. 지속적으로 리팩토링되어야하는 테스트는 테스트가 대상 코드와 강하게 결합되어있기 때문이고, 안타깝지만 이런 상황을 알아채려면 많은 경험이 필요합니다.
낮은 퀄리티의 테스트를 작성하고 있다는 것을 가장 잘 보여주는 증상은 새로운 기능 하나를 추가하면서 수 많은 테스트가 리팩토링되어야 하는 경우입니다. 이 경우 문제는 테스트 코드가 내부 구현을 검증하는 방식으로 작성되었다는 것입니다.
이커머스의 고객 오브젝트가 다음과 같이 구성되어 있다고 생각해봅시다.
고객의 type
은 게스트를 뜻하는 0
과 가입된 유저를 뜻하는 1
두 가지로 구성되어 있습니다. 개발자는 게스트와 가입된 유저를 위한 테스트를 각각 10개씩 작성합니다. 그리고 각 테스트는 이 type
필드를 바라보고 있다고 가정하겠습니다.
시간이 지나고 제휴 고객 타입을 2
로 추가합니다. 추가 테스트를 10개 더 작성합니다.
마지막으로 프리미엄 고객이 추가되면서 테스트를 10개 더 작성합니다.
이제 4개 type
에 대한 40개의 테스트가 존재합니다.
숙련된 개발자라면 다음에 벌어질 일을 상상할 수 있습니다.
다음 요구사항이 추가됩니다:
이제 고객 오브젝트는 다음과 같이 변경됩니다.
이제 서로 다른 4개의 오브젝트가 외래키(foreign key)로 연결되어, type
필드가 제거되면서 갑자기 40개 테스트가 깨지게 됩니다.
물론 이런 간단한 예시에서는 하위 호환성을 위해 type
필드를 남겨둘 수도 있습니다. 하지만 현실에서는 그렇게 할 수 없을 때도 있습니다. 또 단지 단위 테스트 통과때문에 오래된 코드를 남겨두는 것은 그 자체로 중대한 안티 패턴입니다.
실제로 이런 일이 발생하면 개발자는 매니저에게 테스트를 고치기 위한 추가 일정을 요구하게 됩니다. 물론 매니저는 단위 테스트는 시간 낭비이며, 기능 추가를 막아서는 안 된다고 할 것입니다. 결국 팀은 단위 테스트를 버리고 통과하지 않는 테스트를 무시하기 시작합니다.
테스트를 하는 것 그 자체는 문제가 아닙니다. 하지만 테스트가 특정 필드를 바라보게끔 하는 것은 문제가 됩니다. 내부 구현을 테스트하는 대신 테스트는 동작을 테스트해야 합니다. 위의 간단한 예시에서 고객 오브젝트의 필드를 직접 테스트하는 대신 비즈니스 요구사항을 테스트했어야 합니다. 즉 테스트는 아래와 비슷한 형태가 되어야 합니다.
테스트는 고객 오브젝트의 내부 구현에 대해 알지 못합니다. 테스트는 오로지 고객 오브젝트가 어떤 식으로 다른 오브젝트와 상호작용하는지만 알고 있습니다. 물론 다른 오브젝트는 상황에 따라 모킹될 수 있습니다. 또 테스트가 기술적인 세부사항보다 비즈니스 요구사항을 나타내고 있다는 점도 중요한 포인트입니다.
고객 오브젝트의 내부 구현이 변경되더라도 위 테스트는 살아남을 것입니다. 고객 오브젝트를 생성하는 코드는 바뀔 수 있겠지만 그 코드는 createSampleCustomer()
와 같은 헬퍼를 통해 한 곳에서 관리되어야 할 겁니다.
물론 이러한 비즈니스 요구사항 자체가 바뀔 수도 있습니다. 하지만 loginAsGuest()
나 register()
, showAffiliateSales()
그리고 getPremiunDiscount()
가 한꺼번에 변하지는 않을 것이며, 40개를 리팩토링하기보다는 10개를 리팩토링하게 될 것입니다.
기능을 추가하면서 지속적으로 리팩토링을 해야한다면 테스트가 내부 구현과 강하게 결합되어 있다는 것을 의미합니다.
글이 상당히 길기 때문에 1편은 여기에서 마무리 짓고 2편으로 돌아오도록 하겠습니다.
감사합니다.
번역 정말 감사드립니다. 제가 테스트 작성하면서 고민하던 사항의 원인을 몰랐는데 글을 읽고 알게 되네요!!
정말 감사드립니다.