Unit Testing - 01장 단위 테스트의 목표

728x90

2022년 (처음이자) 새롭게 입사하게 된 맘편한세상(= 맘시터) 개발팀은 매주 1회 사내 스터디를 진행하고 있다.
자율적인 참여이지만, 필참이라는 마인드로 시간이 되는 선에서는 모두 참여하려고 한다.
내가 합류한 시점에는 클린 아키텍처 책 스터디가 막 끝난 상황이었고, 새로운 도서를 선정하게 됐다. 때마침 최근에 읽고있던 '오브젝트'도 희망 서적 중 하나로 언급이되었지만, 최근 SNS상의 많은 개발자분들이 책 구성이 좋다고 언급하는 Unit Testing 책이 선정되었다! 관심이 없지는 않던터라 (

테스트는 항상 고민해야하는 녀석이기에..

) 기쁜 마음으로 도서를 구매하였다!

단위 테스트는 단순히 테스트를 작성하는 것보다 더 큰 범주이다. 단위 테스트에 시간을 투자할 때는 항상 최대한 이득을 얻도록 노력해야하고 테스트에 드는 노력을 가능한 한 줄이고 그에 따르는 이득을 최대화해야 한다. 두 가지를 모두 달성하는 것은 쉽지않다.
이러한 두 균형을 달성한 프로젝트는 무난하게 성장하고 유지 보수가 많이 필요하지 않으며 끊임없이 변화하는 고객의 요구에 신속히 대응할 수 있는 프로젝트다. 반면에 그렇지 못한 프로젝트는 노력을 많이 들이고 단위 테스트를 매우 많이 작성하더라도 많은 버그와 유지비로 프로젝트 진행이 느려지게 된다.
이런 차이는 다양한 단위 테스트 기술 간의 차이 때문이다. 일부는 훌륭한 결과를 만들고 소프트웨어 품질을 지키는데 도움이 된다. 하지만 그렇지 않은 경우도 있는데, 일반적으로 그다지 도움이 되지도 않을뿐더러 자주 고장나고 유지보수가 많이 필요하다.

이 책을 통해서 어떤 단위 테스트 기술이 좋은지를 구별하는 데 도움을 받을 수 있고 테스트에 대한 비용 편익 분석(cost-benefit analysis) 방법을 배우고 특정 상황에서 적절한 테스트 기술을 적용할 수 있을 것이다. 또한 공통적인 안티 패턴(처음에는 괜찮은 것 같지만 미래에 문제를 야기하는 패턴)을 피하는 방법도 배운다.

단위 테스트 현황

이제는 대부분 회사에서 단위 테스트는 필수로 간주될 정도로 당연한 것이다. 대부분의 프로그래머는 단위 테스트를 실천하고 중요성을 알고 있다.
단위 테스트를 적용해야 하는지는 더 이상의 논쟁거리가 아니다. 한번 작성하고 버리는 프로젝트가 아니라면 단위 테스트는 늘 적용해야한다.

모든 새로운 기술과 마찬가지로 단위 테스트도 계속 발전하고 있다.
논쟁은 단위 테스트를 작성해야 하는가?에서 좋은 단위 테스트를 작성하는 것은 어떤 의미인가?로 바뀌었다.
많은 프로젝트에는 자동화된 테스트가 있으며 심지어 많은 테스트가 실행된다. 그러나 테스트를 해도 개발자들이 원하는 결과를 얻지 못하는 경우가 많다.
프로젝트에는 많은 노력이 필요하다. 새로운 기능을 구현하려면 시간이 많이 들고, 이미 구현된 기능에는 새로운 버그가 지속적으로 나타난다.
도움이 될 것이라 생각한 단위 테스트는 이러한 상황에 전혀 도움이 되지 않고 오히려 상황을 악화시킬 수도 있다.

이는 제대로 작동하지 않는 단위 테스트의 결과다. 좋은 테스트와 좋지 않은 테스트의 차이는 취향이나 개인적인 선호도의 문제가 아니라 현재 작업 중인 중대한 프로젝트의 성패를 가르는 문제다.

어떤 것이 단위 테스트를 좋게 만드는지에 대한 논쟁은 매우 중요하다. 하지만 오늘날 소프트웨어 개발 업계에서 많이 논의되지는 않는다.
기본적인 단위 테스트의 기본적인 내용의 학습에서 그치는 것이 아니라 노력 대비 최대의 이익을 끌어내는 방식으로 단위 테스트를 수행하는 것이다.
이 시점에 도달하면, 대부분의 책은 다음 단계로 가는 방법을 독자들에게 맡기지만, 이 책은 그 다음 단계로 안내한다. 이상적인 단위 테스트에 대해 정확하고 과학적인 정의를 다룬다. 이 정의가 실제 사례에서 어떻게 적용되는지 살펴본다.

단위 테스트의 목표

흔히 단위 테스트 활동이 더 나은 설계로 이어진다고 한다. 이는 사실이다. 코드 베이스에 대해 단위 테스트 작성이 필요하면 일반적으로 더 나은 설계로 이어진다. 하지만 단위 테스트의 주목표는 아니다. 더 나은 설계는 단지 좋은 부수 효과일 뿐이다.

[단위 테스트와 코드 설계의 관계]

코드 조각을 단위 테스트하는 것은 훌륭한 리트머스 시험이지만, 한 방향으로만 작동한다.
이는 괜찮은 부정 지표다. 즉, 비교적 높은 정확도로 저품질 코드를 가려낸다. 코드를 단위 테스트하기 어렵다면 코드 개선이 반드시 필요하다는 것을 의미한다. 보통 강결합(tight coupling)에서 저품질이 나타나는데, 여기서 강결합은 제품 코드가 서로 충분히 분리되지 않아서 따로 테스트하기 어려움을 뜻한다. 

안타깝게도 코드 조각을 단위 테스트할 수 있다는 것은 좋지 않은 긍정 지표다. 
코드베이스를 쉽게 단위 테스트할 수 있다고 해도 반드시 코드 품질이 좋은 것을 의미하지는 않는다. 
낮은 결합도를 보여도 프로젝트는 '대참사'가 될 수도 있다. 

단위 테스트의 목표는 무엇인가? 소프트웨어 프로젝트의 지속 가능한 성장을 가능하게 하는 것이다. 지속 가능하다는 것이 핵심이다.
프로젝트가 상당히 쉽게 성장할 수 있다. 특히 처음부터 시작할 때 그렇다. 하지만 시간이 지나면서 이렇게 계속 성장하기는 훨씬 어렵다.

테스트가 없는 일반 프로젝트의 성장 추이는 처음에는 발목을 잡을 것이 없으므로 빨리 시작할 수 있다. 아직 잘못된 아키텍처 결정이 없고, 걱정할 만한 코드가 있지도 않다. 그러나 시간이 지나면서 점점 더 많은 시간을 들여야 처음에 보여준 것과 같은 정도의 진척을 낼 수 있다. 결국 개발 속도가 현저히 느려지고, 심지어 전혀 진행하지 못할 정도로 느려질 수 있다.

개발 속도가 빠르게 감소하는 이러한 현상을 소프트웨어 엔트포리(software entropy)라고도 한다. 엔트로피(시스템 내 무질서도)는 소프트웨어 시스템에도 적용할 수 있는 수학적이고 과학적인 개념이다.

소프트웨어에서 엔트로피는 품질을 떨어뜨리는 코드 형태로 나타난다. 코드베이스에서 무언가를 변경할 때마다 무질서도(엔트로피)는 증가한다.
지속적인 정리와 리팩터링 등과 같은 적절한 관리를 하지 않고 방치하면 시스템이 점점 더 복잡해지고 무질서해진다.
하나의 버그를 수정하면 더 많은 버그를 양산하고, 소프트웨어의 한 부분을 수정하면 다른 부분들이 고장 난다. 즉, 도미노 현상과 같다. 결국 코드베이스를 신뢰할 수 없게 된다. 그리고 제일 안 좋은 것은 안정화가 어렵다는 것이다.

테스트로 이러한 경향을 뒤집을 수 있다. 테스트는 안전망 역할을 하며, 대부분의 회귀(regression)에 대한 보험을 제공하는 도구라 할 수 있다.
테스트는 새로운 기능을 도입하거나 새로운 요구 사항에 더 잘 맞게 리팩터링한 후에도 기존 기능이 잘 작동하는지 확인하는 데 도움이 된다.

여기서 한 가지 단점이 있는데, 이러한 테스트는 초반에 노력이 필요하다는 것이다. 그러나 프로젝트 후반에도 잘 성장할 수 있도록 하므로 장기적으로 보면 그 비용을 메울 수 있다. 코드베이스를 지속적으로 검증하는 테스트 없이는 소프트웨어 개발이 쉽게 확장되지 않는다.
지속성과 확장성이 핵심이며, 이를 통해 장기적으로 개발 속도를 유지할 수 있다.

좋은 테스트와 좋지 않은 테스트를 가르는 요인

단위 테스트가 프로젝트 성장에 도움이 되는 것은 맞지만, 테스트를 작성하는 것만으로는 충분하지 않다. 잘못 작성한 테스트는 여전히 같은 결과를 낳는다.
잘못 작성한 테스트도 초반에 코드가 나빠지는 것을 늦출 수 있다. 즉, 테스트가 전혀 없는 상황에 비해 개발 속도가 덜 느려진다.
그러나 거시적인 관점에서는 큰 차이가 없다. 이러한 프로젝트가 침체 단계에 진입하는 데 시간이 더 걸릴 수 있지만, 피할 수는 없다.

모든 테스트가 똑같이 작성되지는 않는다. 일부 테스트는 아주 중요하고 소프트웨어 품질에 매우 많은 기여를 한다. 그 밖에 다른 테스트는 그렇지 않다.
잘못된 경고가 발생하고, 회귀 오류를 알아내는 데 도움이 되지 않으며, 유지 보수가 어렵고 느리다. 프로젝트에 도움이 되는지 여부를 명확하게 파악하지 않고 단위 테스트를 작성하는 데만 빠져들기 쉽다.

프로젝트에 테스트를 더 많이 실행하더라도 단위 테스트의 목표를 달성할 수 없다. 테스트의 가치와 유지 비용을 모두 고려해야 한다.
비용 요소는 다음과 같은 다양한 활동에 필요한 시간에 따라 결정된다.

  • 기반 코드를 리팩터링할 때, 테스트도 리팩터링하라.
  • 각 코드 변경 시 테스트를 실행하라.
  • 테스트가 잘못된 경고를 발생시킬 경우 처리하라.
  • 기반 코드가 어떻게 동작하는지 이해하려고 할 때는 테스트를 읽는 데 시간을 투자하라.

높은 유지 보수 비용으로 인해 순가치가 0에 가깝거나 심지어 0보다 작은 테스트를 만들기 쉽다. 지속 가능한 프로젝트 성장을 위해서는 고품질 테스트에만 집중해야 한다. 고품질 테스트만이 테스트 스위트에 남을 만한 테스트 유형이다.

[제품 코드 vs 테스트 코드]

사람들은 종종 제품 코드와 테스트 코드가 다르다고 생각한다. 테스트는 제품 코드에 추가된 것으로 간주되며, 소유 비용이 없다. 또한 사람들은 종종 테스트가 많으면 많을수록 좋다고 생각한다. 하지만 그렇지 않다. 코드는 자산이 아니라 책임이다. 코드가 더 많아질수록, 소프트웨어 내의 잠재적인 버그에 노출되는 표면적이 넓어지고 프로젝트 유지비가 증가한다. 따라서 가능한 한 적은 코드를 문제를 해결하는 것이 좋다. 

테스트도 역시 코드다. 특정 문제를 해결하는 것, 즉 애플리케이션의 정확성을 보장하는 것을 목표로하는 코드베이스의 일부로 봐야 한다.
다른 코드와 마찬가지로 단위 테스트도 버그에 취약하고 유지보수가 필요하다. 

좋은 단위 테스트와 나쁜 단위 테스트를 구별하는 방법을 익히는 것은 중요하다. (4장에서 다룬다)

테스트 스위트 품질 측정을 위한 커버리지 지표

이 절에서는 가장 널리 사용되는 두 가지 커버리지 지표(코드 커버리지와 분기 커버리지)를 어떻게 계산하고 어떻게 사용하는지 살펴보고 관련된 문제점도 알아본다. 프로그래머가 특정 커버리지 숫자를 목표로 하는 것이 해로운 이유와 테스트 스위트 품질을 결정하는 데 커버리지 지표에 의존할 수 없는 이유를 알아본다.

커버리지 지표는 테스트 스위트(suit)가 소스 코드를 얼마나 실행하는지를 백분율로 나타낸다.

커버리지 지표는 각기 다른 유형이 있으며, 테스트 스위트의 품질을 평가하는 데 자주 사용된다. 일반적으로 커버리지 숫자가 높을수록 더 좋다.
안타깝게도 그렇게 간단하지만은 않다. 커버리지 지표는 중요한 피드백을 주더라도 테스트 스위트 품질을 효과적으로 측정하는 데 사용될 수 없다.
코드를 단위 테스트하는 것과 같은 상황이다. 즉, 커버리지 지표는 괜찮은 부정 지표이지만 좋지 않은 긍정 지표다.

코드 커버리지가 너무 적을 때는 테스트가 충분치 않다는 좋은 증거다. 그러나 반대의 경우는 그렇지 못하다. 100% 커버리지라고 해서 반드시 양질의 테스트 스위트라고 보장하지는 않는다. 높은 커버리지의 테스트 스위트도 품질이 떨어질 수 있다.

코드 커버리지 지표에 대한 이해

가장 많이 사용되는 커버리지 지표로 코드 커버리지가 있으며, 테스트 커버리지로도 알려져 있다. 이 지표는 하나 이상의 테스트로 실행된 코드 라인 수와 제품 코드베이스의 전체 라인 수의 비율을 나타낸다.

코드 커버리지(= 테스트 커버리지) = 실행 코드 라인 수 / 전체 라인 수

분기 커버리지 지표에 대한 이해

또 다른 커버리지 지표는 분기 커버리지(branch coverage)다. 분기 커버리지는 코드 커버리지의 단점을 극복하는 데 도움이 되므로 코드 커버리지보다 더 정확한 결과를 제공한다. 분기 커버리지 지표는 원시 코드 라인 수를 사용하는 대신 if문switch 문과 같은 제어 구조에 중점을 둔다.

분기 커버리지 = 통과 분기 / 전체 분기 수

분기 커버지리 지표를 계산하려면 코드베이스에서 모든 가능한 분기를 합산하고 그 중 테스트가 얼마나 많이 실행되는지 확인해야 한다.

커버리지 지표에 관한 문제점

분기 커버리지로 코드 커버리지보다 더 나은 결과를 얻을 수 있지만, 테스트 스위트의 품질을 결정하는 데 어떤 커버리지 지표도 의존할 수 없는 이유는 다음과 같다.

  • 테스트 대상 시스템의 모든 가능한 결과를 검증한다고 보장할 수 없다.
  • 외부 라이브러리의 코드 경로를 고려할 수 있는 커버리지 지표는 없다.

단지 코드 경로를 통과하는 것이 아니라 실제로 테스트하려면, 단위 테스트에는 반드시 적절한 검증이 있어야 한다.
즉, 테스트 대상 시스템이 낸 결과가 정확히 예상하는 결과인지 확인해야 한다. 더구나 결과가 여러 개 있을 수 있다.
따라서 커버리지 지표가 의미가 있으려면, 모든 측정 지표를 검증해야 한다.
커버리지 지표는 기반 코드를 테스트했다고 보장할 수 없으며 일부 실행된 것만 보장한다.
검증이 전혀 없는 테스트의 경우 이보다 더 극단적인 상황이다.

그렇다면 테스트 대상 코드에 대해 각각의 결과를 철저히 검증한다면 분기 커버리지 지표와 함께 신뢰할 수 있는 구조라고 할 수 있을까? 또한 테스트 스위트 품질을 결정하는 데 사용할 수 있는가?
안타깝게도 아니다!

두 번째 문제는 모든 커버리지 지표가 테스트 대상 시스템이 메서드 호출할 때 외부 라이브러리가 통과하는 코드 경로를 고려하지 않는다는 것이다.

수 많은 예외 상황(edge case)에 빠질 수 있지만, 테스트에서 모든 예외 상황을 다루는지 확인할 방법이 없다.
이는 커버리지 지표가 외부 라이브러리의 코드 경로를 고려해야 한다는 것이 아니라(고려하면 안 된다), 해당 지표로는 단위 테스트가 얼마나 좋은지 나쁜지를 판단할 수 없다는 것을 보여준다. 커버리지 지표로 테스트가 철저한지 또는 테스트가 충분한지 알 수는 없다.

특정 커버리지 숫자를 목표로 하기

테스트 스위트 품질을 결정하기에 커버리지 지표만으로는 충분하지 않다는 것을 깨달았을 것이다.
특정 커버리지 숫자를 목표로 삼기 시작하면 위험 영역으로 이어질 수 있다. 커버리지 지표를 보는 가장 좋은 방법은 지표 그 자체로 보는 것이며, 목표로 여겨서는 안 된다.

특정 커버리지를 목표로 하는 것은 단위 테스트의 목표와 반대되는 그릇된 동기 부여가 된다. 사람들은 중요한 것을 테스트하는 데 집중하는 대신 인공적인 목표를 달성하기 위한 방법을 찾기 시작한다. 적절한 단위 테스트는 이미 충분히 어렵다. 커버리지 숫자를 강요하면 개발자들은 테스트 대상에 신경쓰지 못하고, 결국 적절한 단위 테스트는 더욱 달성하기 어려워진다.

시스템의 핵심 부분은 커버리지를 높게 두는 것이 좋다. 하지만 이 높은 수준을 요구 사항으로 삼는 것은 좋지 않다. 그 차이는 미미하지만 매우 중요하다.

다시 말하지만, 커버리지 지표는 좋은 부정 지표이지만 나쁜 긍정 지표다. 커버리지 숫자가 낮으면 문제 징후라 할 수 있다. 코드베이스에 테스트되지 않은 코드가 많다는 뜻이다. 그러나 높은 숫자도 별 의미는 없다. 그러므로 코드 커버리지를 측정하는 것은 품질 테스트 스위트로 가는 첫걸음일 뿐이다.

무엇이 성공적인 테스트 스위트를 만드는가?

제대로 하려면 어떻게 해야할까? 테스트 스위트의 품질을 어떻게 측정해야 하는가? 믿을 만한 방법은 스위트 내 각 테스트를 하나씩 따로 평가하는 것뿐이다.
물론 한 번에 모든 것을 평가할 필요는 없다. 꽤 큰 작업이 될 수 있고, 이에 앞서 노력을 상당히 들여야 할 수도 있다. 평가를 하나씩 늘려가며 수행할 수도 있다. 요점은 테스트 스위트가 얼마나 좋은지 자동으로 확인할 수 없다는 것이다. 개인 판단에 맡겨야 한다.

전체적으로 어떻게 테스트 스위트를 성공할 수 있는지 더 넓게 살펴보자.
성공적인 테스트 스위트는 다음과 같은 특성을 갖고 있다.

  • 개발 주기에 통합돼 있다.
  • 코드베이스에서 가장 중요한 부분만을 대상으로 한다.
  • 최소한의 유지비로 최대의 가치를 끌어낸다.

개발 주기에 통합돼 있음

자동화된 테스트를 할 수 있는 방법은 끊임없이 하는 것 뿐이다. 모든 테스트는 개발 주기에 통합돼야 한다. 이상적으로 코드가 변경될 때마다 아무리 작은 것이라도 실행해야 한다.

코드베이스에서 가장 중요한 부분만을 대상으로 함

모든 테스트가 똑같이 작성되지 않은 것처럼 단위 테스트 측면에서 코드베이스의 모든 부분에 똑같이 주목할 필요는 없다. 테스트가 주는 가치는 테스트 구조뿐만 아니라 검증하는 코드에도 있다.

시스템의 가장 중요한 부분에 단위 테스트 노력을 기울이고, 다른 부분은 간략하게 또는 간접적으로 검증하는 것이 좋다. 대부분의 애플리케이션에 가장 중요한 부분은 비즈니스 로직(도메인 모델)이 있는 부분이다. 비즈니스 로직 테스트가 시간 투자 대비 최고의 수익을 낼 수 있다.

다른 모든 부분은 세 가지 범주로 나눌 수 있다.

  • 인프라 코드
  • 데이터베이스나 서드파티 시스템과 같은 외부 서비스 및 종속정
  • 모든 것을 하나로 묶는 코드

그러나 이 중 일부는 단위 테스트를 철저히 해야 할 수 있다. 예를 들어 인프라 코드에 복잡하고 중요한 알고리즘이 있을 수 있으므로, 테스트를 많이 하는 것이 좋다. 그러나 일반적으로 도메인 모델에 관심을 더 많이 갖는 것이 옳다.

통합 테스트와 같이 일부 테스트는 도메인 모델을 넘어 코드베이스의 중요하지 않은 부분을 포함해 시스템이 전체적으로 어떻게 작동하는지 확인할 수 있다.
이 방법도 괜찮으나 초점은 도메인 모델에 머물러 있어야 한다.

이 지침을 따르려면 도메인 모델을 코드베이스 중 중요하지 않은 부분과 분리해야 한다. 도메인 모델을 다른 애플리케이션 문제와 분리해야 단위 테스트에 대한 노력을 도메인 모델에만 집중할 수 있다.

최소 유지비로 최대 가치를 끌어냄

단위 테스트에서 가장 어려운 부분은 최소 유지비로 최대 가치를 달성하는 것이다. 이것이 이 책에서 말하려는 핵심이다.
테스트를 빌드 시스템에 통합하는 것만으로는 충분하지 않으며, 도메인 모델에 높은 테스트 커버리지를 유지하는 것도 충분하지 않다.
또한 가치가 유지비를 상회하는 테스트만 스위트에 유지하는 것이 중요하다.

이 마지막 속성은 두 가지로 나눌 수 있다.

  • 가치 있는 테스트(더 나아가, 가치가 낮은 테스트) 식별하기
  • 가치 있는 테스트 작성하기

이러한 기술은 비슷해 보일지 모르지만, 선천적으로 다르다. 가치가 높은 테스트를 식별하려면 기준틀(frame of reference)이 필요하다. 반면에 가치 있는 테스트를 작성하려면 코드설계 기술도 알아야 한다. 단위 테스트와 기반 코드는 서로 얽혀 있으므로 코드베이스에 노력을 많이 기울이지 않으면 가치 있는 테스트를 만들 수 없다.

728x90