각종 커뮤니티에 주기적으로 등장하는 "private 메서드를 테스트하려면 어떻게 하나요?" 혹은 "private 메서드를 테스트해야 하나요?" 와 같은 질문을 보면서 언젠가는 관련해서 정리해봐야겠다고 생각했었는데 꽤 시간이 흘러 이 내용으로 글을 써본다. 주제 자체는 간단한 편이지만 사람마다 생각이 다른 것 같다. 특히 해외 개발자들의 의견이 분분한 것 같다. 사실 이 문제는 효과적인 테스트 케이스(이하 TC)란 무엇인가란 질문과 비슷하다.
private 메서드는 객체지향적인 관점에서 생각한 것이고 노출된 함수 내부에서 접근하는, 클로저안에 숨겨진 함수 역시 동일한 대상이다. 모듈의 외부 인터페이스 뒤에 숨겨진 캡슐화 된 것들을 말한다. 이후 내부 구현이라고 부르겠다. 내부 구현을 테스트해야 할까? 결론부터 말하자면 "아니다." 아니 더 정확히 말하자면 "맞다". 무슨 뚱딴지같은 말장난인 것 같지만 사실이다. 내부 구현에 해당하는 것들에 대해 직접적으로 TC를 작성하는 것은 피해야 하고 오직 공개된 외부 인터페이스를 통해서만 테스트해야 한다. 즉 결론적으로는 테스트가 되어야 한다는 말이다.
반복되는 테스트를 자동화하기 위해 내부 구현에 대한 TC를 "임시로" 작성하는 것은 괜찮지만 결국 외부 인터페이스를 테스트하는 TC들만 남겨야 한다.
현재를 위해 TC를 작성할 수 있지만 남겨지는 TC는 미래를 위해 남겨야 한다.
지금부터 테스트라고 하는 것은 TC를 작성해서 대상 모듈의 테스트를 자동화하는 것이라고 퉁치자. 테스트를 통해 얻을 수 있는 장점에 대해서는 일일이 열거하지 않겠다. 얼마든지 찾아볼 수 있는 정보기 때문이다. 그 장점들에 대해서는 의견이 분분하기도 하고 유닛 테스트냐 통합 테스트냐의 구분도 여기서는 하지 않겠다. 그냥 코드로 작성되어 자동화되는 테스트를 말한다. 이런 테스트는 TDD라는 방법론을 이용할 수도 있고 그렇지 않을 수도 있다.
TDD 혹은 테스트 자동화를 진지하게 활용한 지 어느덧 7년 정도의 시간이 흘렀다. 테스트에 대한 내 생각은 시간이 지날수록 명확해졌다. 테스트의 유일한 목적은 개발자(프로젝트)에게 도움을 주는 것이다. 뭐 뻔한 이야기다. 사실 테스트 케이스(이하 TC)를 어떻게 만들던 그것을 작성하는 개발자에게 뭐든 도움이 된다면 그것으로 좋다고 생각한다. 그렇기 때문에 내부 구현을 테스트하든 말든 그것은 개발자의 자유이며 굳이 어디서 본인 생각이 맞는지 물어볼 필요도 없다. 그래서 개발자 본인이 도움 된다면 private 메서드도 테스트할 수 있다. 켄트백 형이나 마틴, 밥이형의 의견도 필요 없다. 내가 도움이 된다는데... 하지만 그건 어디까지나 혼자 개발할 때의 이야기다. 여럿이서 개발하는 프로젝트의 관점에서라면 이야기가 달라진다.
나도 내부 구현에 대한 TC를 작성하는 경우가 있다. 현재 즉 임시적인 경우에 한해서 말이다. 분할된 루틴의 테스트를 자동화하기 위해 잠깐 테스트를 작성할 수는 있다. 사실 TDD의 프로세스를 따라가다 보면 외부에 공개될 인터페이스인 줄 알았는데 결론적으로 내부 구현으로 옮겨지는 경우도 허다하다. 심지어 인터페이스가 제거되거나 합쳐진다. 이러한 모듈에 변화에 따라 내부 구현을 직접적으로 테스트하는 TC들도 지워지거나 외부 인터페이스 테스트의 일부로 흡수되어야 한다.
내부 구현을 직접적으로 테스트하는 것은 TC의 미래 가치를 떨어트린다. 결국 도움이 안 된다. TC를 만들고는 있지만, TC를 통해 얻을 수 있는 진정한 이점을 모른 채 TDD가 제시하는 레드-그린-리팩토링 사이클에 갇혀 마치 그린이 제공하는 도파민에 중독된 듯 TC를 작성하는 것은 피해야 한다. TC를 만들었다고 꼭 도움이 되는 것이 아니기 때문이다.
직접 해보면 알 수 있지만, 내부 구현을 직접 테스트하는 것이 외부 인터페이스를 통해서 테스트하는 것보다 훨씬 쉽다. 그리고 내부 구현을 테스트 하는 것은 뭔가 직관적이고 손에 착 붙어 만족감을 더 주기도 한다. 빨리 그린을 보기 위해 쉽게 TC를 작성하고 싶을 수도 있다. 그렇게 작성된 많은 양의 TC들을 보면 뿌듯하다. 하지만 무조건 TC를 작성한다고 도움이 되는 것이 아니다. TC는 적을수록 좋다. 최소의 TC로 최대의 효과를 낼수록, 즉 효율이 높을수록 테스트의 가치는 더 뚜렷해진다. 반대의 경우라면 수많은 TC가 프로젝트의 민첩성을 떨어트리고 사사건건 발목을 잡을 것이다. 이런 상태가 되면 테스트에 반감을 갖게 될 수밖에 없다.
불필요한 TC가 많을수록 실제로 도움이 되고 꼭 필요한 TC들의 가치가 떨어지게 되고 프로젝트의 TC에 대한 신뢰도는 바닥을 친다. TC는 신뢰를 잃는 순간 레가시코드보다 못한 프로젝트의 걸림돌이 된다. 이제 와서 TC를 작성하는 것을 그만 만들 수도 없고, 또 지울 수도 없지만, 도움은 전혀 되지 않는.. 혹은 도움이 된다고 착각하는 그런 TC가 된다.
애플리케이션 안에서 각자의 책임을 지고 협업을 해나가는 모듈들은 일부 기능이 변경될 수도 있고 성능이 더 좋은 모듈로 교체될 수도 있다. 일종의 거대한 기관의 톱니바퀴와 같은 것이다.
오래된 톱니바퀴는 더 가볍거나 더 빠르고 안정성이 좋은 톱니바퀴로 교체될 수 있다. 이때 톱니바퀴가 속한 기관은 해당 톱니바퀴에 맞물려 돌아갈 수 있게 톱니의 모양만 알고 있으면 되지 이게 무슨 재질이고 어떤 색인지 혹은 어떤 상표인지 따위는 몰라도 된다. 그냥 무엇이든 톱니 모양만 맞는다면 그것으로 충족하는 것이다. 그렇기 때문에 새로운 톱니바퀴를 돌리기 전에 테스트해볼 것은 그 톱니바퀴와 함께 돌아갈 다른 톱니와 제대로 맞물리는지만 확인하는 것으로 충분하다.
TC는 모듈의 기능이 일부 변경되거나 추가되었을 때 기존 시스템에서 해당 모듈이 책임져야 할 역할을 충실이 이행할 수 있는지를 보장해준다. 그리고 모듈의 내부 구조가 리팩토링 돼서 변경되거나 혹은 아예 새로운 모듈로 다시 만들어졌을 때 애플리케이션이라는 거대한 시스템에서 모듈이 잘 맞물려 돌아갈 수 있는지에 대한 신뢰성을 보장해준다.
그래서 TC는 대상 모듈이 무엇이든 시스템에 맞물려 정상적으로 돌아갈 수 있는지만 확인해야 한다. 톱니바퀴가 어떤 모습인지 내부가 텅 비어있는지 혹은 단단한 구조물로 되어 있는지 혹은 마트료시카 인형처럼 톱니바퀴 안에 톱니바퀴가 있든 말든 상관 없다. 톱니바퀴의 사용자는 톱니바퀴에 공개된 톱니의 모양만 알고 있으면 기관에서 사용할 수 있어야 한다. 그게 그 톱니바퀴의 역할이고 책임이다. TC 역시 모듈의 사용자다. TC는 바뀔 수 있는 구체적인 모듈이 아닌 바뀌지 않을 모듈의 책임을 테스트해야 한다. 모듈이 적절히 책임을 수행하도록 메시지를 받을 수 있는 외부 인터페이스가 자주 바뀐다면 그것은 무언가 잘못 설계되었다는 신호다.
모듈은 언제든지 내부가 다르지만 동일한 외부 인터페이스는 갖는, 즉 동일한 역할과 책임을 갖는 모듈로 교체될 수 있고 그 모듈을 사용하는 테스트하는 TC는 그 모듈의 추상화된 책임만 알고 있으면 된다. 그래서 모듈의 다형성과 자율성을 보장해줘야 한다. 어디서 많이 봤던 내용 아닌가? SOLID 중 의존 역전 원칙(DIP)이다. 엄밀히 따지면 다를 수 있겠지만 목적과 효과 면에서 동일하다. TC 역시 모듈을 사용하는 사용자 입장에서 바라봐야 하고 TC는 모듈의 구체적인 것에 의존하면 안 된다. 추상에 의존해야 한다. 추상화된 책임을 테스트해야지 모듈을 테스트하면 안 된다. 그래야 TC가 유연해지고 궁극적으로 어떤 모듈이든 테스트할 수 있다.
그럼에도 불구하고 내부 구현이 테스트 되어야 한다고 생각되는 모듈이 있다면 그건 해당 내부 구현이 독립된 책임을 갖는 별도의 모듈로 추출되어야 한다는 신호일 가능성이 크다. 내부 구현을 클래스나 모듈로 추출해 그 모듈을 사용하는 구조로 변경한다면 추출된 모듈의 TC를 작성해 외부 인터페이스로 테스트할 수 있다. 이것은 내부 구현이 외부 인터페이스로 변경된 좋은 사례에 해당한다.
TC는 모듈을 사용하는 사용자라고 생각하자. 내부 구조를 전혀 모른채 공개된 것만 알고 있는 사용자 말이다. 그게 TC와 모듈과의 관계이고 TC도 테스트 대상 모듈을 디펜던시로 갖는 모듈이다.
유용한 TC는 현재와 미래의 관점으로 나눠서 생각할 수 있다.
현재를 위해 작성되는 TC는 개발하고 있는 코드의 테스트를 자동화한다. 그래서 인풋 값을 조절하며 결괏값을 확인할 때 소요되는 시간을 줄여 준다. 그 과정에서 내부 구현에(혹은 외부 인터페이스인 줄 알았던) 해당하는 메서드를 잠깐 테스트할 수도 있다. 단순히 자동화에 관점에서 도움이 되니까 말이다. 그리고 TC가 쌓여감에 따라 이후에 작성된 코드가 사이드 이팩트로 이전 코드를 망치는 것을 예방해준다. TDD가 애플리케이션의 구조적인 설계를 직접적으로 도와주지는 않지만 이미 짜인 협력 구조 안에서 각 모듈의 역할과 책임에 필요한 인터페이스를 효과적으로 설계하는 것에는 도움이 된다. 그리고 이 과정에 내부와 외부가 확실히 구분되고 정교하게 다듬어진다. TC를 먼저 작성한다는 것은 모듈을 사용하는 입장에서 개발을 시작하는 것이기 때문이다.
미래를 위한 TC는 대상 모듈의 역할과 책임을 설명할 뿐 아니라 구체적인 사용 방법까지 제시하는 훌륭한 문서가 되어야 한다. 그래서 디스크립션을 읽는 것만으로 TC가 무엇이 어떻게 되는 것을 기대하는지 알 수 있도록 작성해야 한다. 간결하지만 충실하게 말이다. 그리고 대상 모듈의 기능이 변경되거나 추가될 때 변경된 내용이 기존 스펙에 충족하는지 자동으로 확인해주며 변경된 코드에 의해 발생할 수 있는 문제를 최소화 해주어야 한다. 심지어 대상 모듈이 통째로 바뀌었을때 조차 동일한 기능을 수행할 수 있어야 한다.
결국 TDD 사이클을 이용하든 하지 않든 TC를 작성하면서 소비한 시간에 대한 충분한 효율을 얻으려면 현재를 위해 TC를 작성하되 개발이 진행되면서 미래를 위한 TC들로 바뀌어야 한다. 물론 애초에 미래를 위해 작성할 수도 있다. 요지는 개발을 진행하면 필요 없는 TC들은 지우거나 더 나은 테스트로 개선되어야 한다는 것이다.
프로젝트에 TDD나 테스트 자동화를 도입했다면 모듈과 마찬가지로 TC도 지속적으로 개선하고 리팩토링해야 한다. 모듈들이 외부 인터페이스를 유지한 채 내부 코드들을 더 빠르고 더 간결하고 더 이해 가능하게 개선될 수 있는 이유는 TC들이 뒤에서 받쳐주고 있기 때문이다. 모듈과 마찬가지로 처음부터 완벽한 코드가 나올 수 없기 때문에 TC 역시 더 빠르고 더 간결하고 더 이해 가능하게 개선되어야 한다. TC는 모듈을 테스트하는 모듈이다. 적어도 모듈 정도 만큼 중요하게 생각해야 한다. TC를 만들었다고 해서 언젠간 도움이 되는 게 아니다. 오히려 아무짝에도 쓸모 없고 방해만 될수 있다. 도움이 되는 TC를 만들도록 계속 고민하고 더 나은 테스트 방법에 대해 생각해야 한다.
모든 프로젝트, 모든 상황을 꿰뚫는 "유용한 TC를 만드는 최고의 방법" 따윈 없을 거라고 생각한다. 각 프로젝트 상황에 맞는 최선이 있을 뿐이다. 적어도 TC가 어떤 도움을 주는지 혹은 어떤 TC가 도움이 되는지를 아는 것은 각 프로젝트 상황에 적합한 TC를 만들 수 있는 최소한의 준비이자 시작점이라고 생각한다.
이미 몇 번 언급했지만, 내부 구현을 TC가 직접적으로 알고 있으면 안 된다. 공개된 인터페이스를 통해서만 테스트해야 한다. 내부 구현은 어떤 형태로든 공개된 인터페이스에 의해 사용될 것이다. 만약 그렇지 않다면 해당 코드들은 삭제되어야 할 코드들이다. 물론 내부 구현을 직접 테스트하는 게 더 쉬울 수 있지만 우리는 TC를 작성하기 위해 TC를 작성하는 게 아니다. TC를 통해 즉 테스트의 자동화를 통해 도움을 얻고자 TC를 작성하는 것이다.
"내부 구현을 테스트해야 하는가 하지 말아야 하는가" 논쟁에서 내부 구현을 외부 인터페이스에 의존해서 테스트하는 것은 테스트의 완성도를 파악하기 힘들다고 말하는 경우가 있다. 내부 구현이 어디까지 테스트 되고 있는지 파악하기 힘들기 때문이다. 자 여기서 한가지 알려줄 게 있다. 그럴 때 사용하라고 만든 지표가 바로 커버리지(coverage)다. 개발자들이 TC를 잘 작성하고 있는지 감시하려고 만든 수단이 아니다.
커버리지는 공개된 인터페이스로 내부 구조를 테스트할 때처럼 TC만으로 모듈의 테스트 범위를 판단하기 힘들 때 참조하는 테스트의 양적인 품질을 보여주는 지표다. 질적인 품질은 보장해주지 않는다. 전혀 쓸모없는 TC를 만들어도 100% 근접한 결과를 만들 수 있기 때문이다. 커버리지가 TC의 질적인 품질을 보장해주지는 않지만, TC가 모듈의 어느 부분까지 테스트했고 앞으로 어디를 테스트해야 하는지를 확인할 수 있는 척도를 제공해준다. 오죽하면 명칭이 커버리지겠는가? TDD로 프로젝트를 진행한다고 하면 야심 찬 눈빛으로 커버리지를 몇 프로까지 맞추냐는 질문을 하는 사람들이 가끔 있는데 질문의 의도를 생각하면 참.. 좀 그렇다... TDD 혹은 테스트 자동화를 프로젝트에 진지하게 도입하고 있는 개발자라면 다른 프로젝트의 커버리지보다는 테스트 가능한 것과 테스트 불가능한 것을 어떻게 분리해냈느냐가 더 궁금할 것이다. 여기에서 "어떻게"는 기준, 방법, 합의 측면에서 생각해볼 수 있는데, 이 부분은 더 깊게 들어가지 않겠다.
결론적으로 TC도 개발하는 모듈과 동일하게 취급해야 한다는 게 내 생각이다. 모듈을 테스트할 책임을 가진 모듈 말이다. TC와 모듈과의 관계가 모듈과 모듈과의 관계라고 생각한다면 TC가 대상 모듈을 어떻게 다뤄야 하는지도 명확해진다.
프론트엔드 개발자 입장에서 테스트 자동화는 꽤 힘든 과제 중 하나였다. 코드로 작성하는 TC는 입력과 출력이 코드로 표현 가능한 데이터일 때 그리고 출력이 뚜렷한 경우에 적합하다. 결과가 사이드 이팩트이거나 데이터로 확인하기 힘든, 보여지는 영역을 다루는 개발자 입장에서는 고민할 게 많은 영역이었다. 다행히 프론트엔드의 발전과 더불어 프론트엔드를 테스트하는 분야 역시 많이 발전했기 때문에 요즘은 오히려 어떤 것을 혹은 어떤 방법을 선택해야 할지 고민이다.
당연하지만 이 바닥에 최종 완전 결정판 같은 건 없을 것이고 테스트 도구나 방법론들도 소프트웨어 프로젝트의 한 영역으로 지속적인 변화와 개선이 있을 것이다. 변화 속에서 진정 중요한 것은 테스트의 효율과 효용성이라는 점을 잊지 말아야 한다. 냉정하게 바라봐야 한다. 정말 "도움이 되는" 테스트여야만 도움이 되는 것이다. "도움이 될 것 같은 건" 오히려 독일 수도 있다.