온라인 북 “소프트웨어 테스팅: 이론부터 실무까지(Software Testing: From Theory to Practice)”에서 테스트 코드의 품질 관련 이슈를 다루는 섹션 3.5 Test code quality and engineering의 내용 발췌
출처: https://sttp.site/chapters/pragmatic-testing/test-code-quality.html
Lehman의 진화 법칙(즉, “누군가 막으려 적극적으로 노력하지 않는 한 코드가 점차 썩는 경향이 있다”)이 테스트 코드에도 적용된다. 개발자는 생산 코드에서와 마찬가지로 양질의 테스트 코드 베이스(test code base)를 만들고 이를 유지하는 데 추가적인 노력을 쏟아야 한다.
좋은 테스트 코드의 속성(The FIRST Properties)
책 “Pragmatic Unit Testing”에서 저자는 개발자가 테스트 코드를 작성할 때 가이드가 될 수 있는 좋은 테스트 코드 속성을 앞 글자를 딴 ‘FIRST 원칙’으로 설명한다.
Fast: 테스트는 개발자의 안전망 같은 것이다. 개발자가 소스 코드의 유지보수나 개선을 수행할 때마다 시스템이 여전히 예상대로 작동하는지 확인하기 위해 테스트스위트(test suite)의 피드백을 참고하게 되며, 따라서 개발자가 테스트 코드에서 피드백을 더 빨리 받을수록 더 좋다. 테스트스위트가 느린 경우 개발자가 이를 자주 실행하기를 꺼리게 되며, 결과적으로 그 효과성이 낮아질 수밖에 없다. 따라서 좋은 테스트는 빨라야 한다. 느린 테스트와 빠른 테스트를 구분하는 절대적인 기준은 없으며, 상식을 적용하는 것이 기본이다.
Isolated: 테스트는 최대한 응집력 있고, 독립적이며, 외떨어져 있어야 한다. 이상적으로는 하나의 테스트 메쏘드가 시스템의 단일 기능 또는 단일 동작만 테스트하는 게 좋다. 여러 기능을 테스트하는 “팻(fat) 테스트”는 구현 측면에서 종종 복잡한 경우가 많다. 복잡한 테스트 코드는 개발자가 테스트 중인 내용을 한 눈에 이해하는 걸 어렵게 하고, 향후 유지 관리도 더 어렵게 만든다. 이런 테스트와 마주하고 있다면 여러 개의 작은 테스트로 나누는 것을 권장한다. 더 간단하고 더 짧은 코드가 항상 더 좋다. 또한 테스트의 실행이 다른 테스트에 의존해서는 안 된다. 테스트가 개별적으로 실행되든 아니면 테스트스위트의 나머지 테스트들과 함께 실행되든 테스트 결과는 동일해야 한다.
Repeatable: 반복 가능 테스트는 실행 횟수에 관계없이 동일한 결과를 제공하는 테스트이다. 일관성 없는 행동을 보이는 테스트(시스템 또는 테스트 코드에 아무 변경이 없음에도 때때로 통과하고 때로는 실패함)는 개발자의 신뢰를 잃게 된다. 이런 불안정한(flaky) 테스트가 여러 이유로 발생할 수 있고, 일부 원인은 식별하기가 까다로울 수 있다. 일반적 원인으로 외부 리소스에 대한 종속성, 외부 리소스가 작업을 완료할 때까지 충분히 오래 기다리지 않음, 동시성(concurrency) 등이 있다.
Self-validating: 테스트는 그 결과를 자체적으로 검증(validate)/확인(assert)해야 한다. 이게 따로 언급이 필요 없는 당연한 원칙처럼 보일 수 있지만, 개발자가 테스트에 어써션을 전혀 작성하지 않아 테스트가 항상 통과(pass)하는 사례를 드물지 않게 볼 수 있다. 또한 복잡한 상황에서는 어써션을 작성하는 것(즉, 예상 동작을 확인하는 것)이 가능하지 않을 수도 있다. 동작의 결과를 관찰하는 것이 쉽지 않은 경우, 개발자가 그 관찰가능성(observability)을 높이기 위해 테스트 대상 클래스 또는 메쏘드를 리팩토링하기를 권한다.
Timely: 개발자가 가능한 한 자주 테스트를 작성하고 실행해야 한다. 이게 다른 원칙에 비해 기술적인 것은 아니지만, 개발 팀의 행동 양식을 자동화된 테스트 코드를 작성하는 방향으로 바꾸는 것이 쉬운 일이 아니다. 과거에 일반적으로 그랬던 것처럼 테스트 단계를 개발 프로세스의 맨 끝까지 미루는 것은 불필요한 비용을 초래할 수 있으며, 결국 그 시점이 되면 시스템이 테스트하기에 너무 버거울 수 있다. 게다가 테스트는 개발자를 위한 안전망 역할을 하는 것인데, 그러한 안전망 없이 대규모의 복잡한 시스템을 개발하는 것은 매우 비생산적이며 실패할 가능성이 높다.
테스트 코드 스멜(Test code smells)
모범 관행(best practices)에 대해 다루었으니 이제 동전의 다른 면인 ‘테스트 코드 스멜’에 대해 알아보자.
잘 알려진 용어 ‘코드 스멜'은 시스템의 소스 코드에서 잠재적으로 심각한 문제가 될 수도 있는 징후를 의미한다. 예를 들면, Long Method, Long Class, God Class 같은 것이 있다. 많은 연구 논문에 따르면 코드 스멜이 소프트웨어 시스템을 이해하고 유지보수 하는데 부정적 영향을 미친다고 한다.
이 용어가 생산 코드에 오랫동안 적용되어 왔으며, 테스트 코드의 부상과 함께 테스트 코드에 특정한 스멜의 목록을 개발하는 연구도 활발하다. 아래는 몇 가지 잘 알려진 테스트 코드 스멜에 대해 설명한다.
코드 중복(Code Duplication):
코드 중복은 생산 코드에서도 매우 일반적이므로 테스트 코드에서 발생하는 것이 놀라운 일은 아니다. 테스트는 종종 구조(structure)가 유사한데, 주의 깊지 못한 개발자는 더 나은 솔루션을 구현하는 데 노력을 쏟는 대신 중복된 코드를 작성하는 데 그친다(즉, 복사 및 붙여넣기).
중복된 코드는 소프트웨어 테스터의 생산성을 저하시킬 수 있다. 중복된 코드 조각에 변경이 필요한 경우 개발자는 코드가 복제된 모든 위치에 동일한 변경 사항을 적용해야 하는데, 현실에서는 이러한 위치 중 하나를 잊어버리기 쉽기 때문에 문제가 있는 테스트 코드가 나타날 수 있다.
개발자가 자신의 테스트 코드를 무자비하게 리팩토링할 것을 조언한다. 복제된 코드 조각을 private 메쏘드 또는 external 클래스로 추출하는 것이 종종 이 문제의 좋은 솔루션이 된다.
어써션 룰렛(Assertion Roulette):
테스트가 실패할 때 개발자가 가장 먼저 확인하는 것이 어써션이다. 어써션은 테스트 대상 컴포넌트에서 무엇이 잘못된 건지를 명확하게 전달해야 한다. 어써션 자체가 이해하기 어렵거나 또는 그게 왜 실패하는지 이해하기 어려울 때 이 테스트 스멜이 나타난다.
이 스멜이 발생하는 데에는 여러 이유가 있다. 일부 기능 또는 비즈니스 규칙은 너무 복잡하여 그 동작을 확인하기 위해 복잡한 어써션 세트가 필요하다. 이러한 상황에서 개발자는 이해하기 쉽지 않은 복잡한 어써션 명령(assert instructions)을 작성하게 된다. 이러한 상황을 개선하기 위해 개발자가 1) 어써션 코드 자체의 복잡성 일부를 추상화하는 맞춤형 어써션 명령을 작성하거나, 2) 어써션에 대한 설명을 자연어로 기술한 코드 주석을 작성할 것을 권장한다.
개발자가 단일 테스트 메쏘드에 하나 이상의 테스트케이스를 작성하는 경향이 있기 때문에 테스트에 포함된 어써션의 수가 많아지는 사례도 종종 볼 수 있다. 절제는 기본 원칙이다. 다수의 테스트케이스를 포함하는 하나의 커다란 테스트 메쏘드를 분할하는 것이 개발자가 이를 이해하는 데 필요한 인지 부하(cognitive load)를 줄일 수 있다.
리소스에 대한 낙관(Resource Optimism):
이 스멜은 테스트가 그 실행 시작 시에 필요한 자원(예, 데이터베이스)을 항상 쉽게 사용할 수 있다고 가정할 때 발생한다. 리소스 낙관주의를 피하기 위해서는 리소스가 이미 올바른 상태에 있다고 가정해서는 안되며, 테스트가 그 상태 자체를 설정하는 책임을 져야 한다. 즉, 테스트가 데이터베이스 채우기(populating a database), 디스크에 필요한 파일 쓰기, Tomcat 서버 시작하기 등을 수행할 책임을 진다. 이런 설정에는 복잡한 코드가 필요할 수 있으며, 개발자는 이 복잡성을 추상화를 통해 단순하게 만드는 데 최선을 다해야 한다. 예를 들면, 이러한 셋업 코드를 DatabaseInitialization 또는 TomcatLoader와 같은 다른 클래스로 이동하여 테스트 코드가 테스트케이스 자체에 집중할 수 있도록 만든다.
우리가 제어할 수 없는 이유로 다운될 수 있는 웹 서비스와 상호작용하는 테스트 메쏘드를 상상해보자. 이 테스트 스멜을 피하기 위해 개발자가 아래 두 가지의 선택을 할 수 있다.
1. 스터브(stubs)와 모의 프로그램(mocks) 사용을 통해 외부 리소스(external resources)를 사용하는 것을 피한다.
2. 만일 테스트가 외부 의존성을 피할 수 없는 경우라면, 테스트를 충분히 견고(robust)하게 만든다. 즉, 리소스가 가용하지 않을 때는 테스트스위트가 테스트를 건너 뛰도록 만들고, 더불어 왜 그렇게 되었는지 설명하는 메시지도 제공하도록 만든다.
테스트 실행 전쟁(Test Run War):
여기서 전쟁은 두 개의 테스트가 같은 리소스를 차지하기 위해 "싸우는" 상황을 비유한 것이다. 둘 이상의 개발자가 테스트스위트를 실행하자마자 테스트가 실패하기 시작한다면, 테스트 실행 전쟁을 목도할 수 있다. 예를 들어, 중앙 집중식 데이터베이스를 사용하는 테스트스위트를 상상해보자. 개발자 A가 이를 실행하면 이 테스트가 데이터베이스의 상태를 변경한다. 동시에 개발자 B가 같은 테스트를 실행한다고 가정하자. 이제 두 테스트 모두 동일한 데이터베이스를 동시에 사용하고 있으며, 이 예기치 않은 상황으로 인해 테스트가 실패할 수 있다.
분리(Isolation)가 이 테스트 스멜을 피하기 위한 열쇠이다. 중앙 집중식 데이터베이스 예에서 한 가지 솔루션은 각 개발자가 자신의 고유한 데이터베이스 인스턴스를 갖도록 만드는 것이다. 그렇게 하면 동일한 리소스에 대한 싸움을 피할 수 있다.
보편적인 픽스쳐(General Fixture):
픽스쳐(fixture)는 테스트 대상 컴포넌트를 실행하는 데 사용되는 입력 값들의 집합이며, 테스트의 준비(arrange) 부분에서 설정된다. 복잡한 컴포넌트를 테스트할 때 개발자가 여러 다른 픽스쳐를 사용할 필요가 있을 수 있으며(즉, 실행하고자 하는 각 파티션에 하나씩 매핑됨), 따라서 이러한 픽스쳐가 복잡해질 수 있다. 또한 설상가상으로 테스트는 서로 다른 반면 그것들의 픽스쳐가 어떤 교점(intersection)을 가질 수도 있다.
여러 다른 픽스쳐 사이에 교점이 존재하고 이 복잡한 엔터티 및 픽스쳐 구축이 어려운 상황에서, 주의 깊지 못한 개발자가 여러 테스트에서 작동하는 하나의 “대형" 픽스쳐를 선언하기로 결정할 수도 있다. 즉, 각 테스트가 이 커다란 픽스쳐의 작은 부분을 사용하게 되는 것이다.
이 접근 방식이 여러 테스트케이스를 올바르게 구현할 수도 있지만, 유지 관리가 어렵다는 문제가 있다. 일단 테스트가 실패하면, 실패 원인을 찾고자 하는 개발자가 많은 부분 자신의 작업과 관련 없는 커다란 픽스쳐와 마주하게 된다. 현실에서 개발자는 실패한 테스트에 의해 실행되지 않는 픽스쳐의 부분을 수동으로 “걸러 내야(filter out)” 하는데, 이것이 불필요한 비용이다. 테스트의 픽스쳐를 가능한 한 구체적이고 응집력이 있도록 만드는 것이 개발자가 테스트의 핵심을 이해하는 데 도움이 된다.
간접 테스트 및 과도한 테스트(Indirect tests and eager tests):
테스트는 최대한 응집력 있고 집중적이어야 한다. 이 스멜은 테스트 클래스가 한 번에 많은 클래스를 테스트하는 데 그 노력을 집중할 때 나타난다.
단위 테스트 클래스 및 메쏘드는 명확한 초점(focus)을 가져야 하고, 단일 단위(single unit)를 테스트해야 한다. 만약 이들이 다른 클래스에 의존하는 경우라면, 개발자는 모의 프로그램(mocks) 및 스터브(stubs) 사용을 통해 해당 테스트를 분리시키고 간접 테스트(indirect testing)를 피할 수 있다. 만일 모의 프로그램 및 스터브 사용이 불가능하다면 어써션이 반드시 실제 테스트 대상 클래스에 집중하도록 해야 하며, 테스트 대상 클래스가 아니라 종속성(dependencies)으로 인해 발생한 실패를 테스트 메쏘드의 결과에 분명히 구분해 표시해야 한다.
과도한 테스트(eager tests)도 피하는 것이 바람직하다. 한 번에 여러 동작을 실행하는 테스트 메쏘드는 지나치게 길고 복잡한 경향이 있어서, 개발자가 그걸 빠르게 이해하기 어렵게 만든다.
민감도 균형(Sensitive Equality):
테스트케이스에서 좋은 어써션은 필수적이다. 나쁜 어써션은 테스트가 실패해야 할 때 실패하지 않게 만들 수 있고, 또는 테스트가 실패하지 않아야 할 때 실패하게 만들 수도 있다. 좋은 어써션 명령(assertion statement)을 구축하는 것이 어려운 일이며, 컴포넌트가 불안정한 출력(즉, 자주 변경되는 경향이 있는 출력)을 생성하는 경우에는 더욱 그렇다. 테스트 코드는 테스트 대상 컴포넌트의 구현 세부사항에 대해 가능한 한 탄력적(resilient)이어야 한다. 어써션 또한 내부 변화에 지나치게 민감하지 않아야 한다.
쇼핑 카트에 들어 있는 항목을 나타내는 클래스 Item을 상상해 보자. 항목은 이름(name), 수량(quantity), 개별 가격(individual price)으로 구성되며, 항목의 최종 가격은 개별 가격에 그 수량을 곱한 값이다. 이 클래스가 다음과 같은 구현되었다.
주의 깊지 못한 개발자가 finalAmount 동작을 실행하기 위해 다음 테스트를 작성했다고 가정하자.
위의 테스트가 최종 금액 계산을 실행하는데, 개발자가 지름길을 사용한 것을 볼 수 있다. 즉, 개발자가 클래스의 toString 메쏘드를 사용하여 전체 동작을 확인(assert)하는 방법을 취했다. 아마도 개발자는 이것이 최종 가격뿐만 아니라 제품명과 그 수량까지 확인하기 때문에 더 엄격한 어써션이라고 느꼈을지도 모르겠다.
이 어써션이 처음에는 잘 작동하는 것처럼 보일지 몰라도 toString 구현의 변경에 민감하다. 테스터는 toString 메쏘드가 변경되는 경우가 아니라 finalAmount 메쏘드가 변경되는 경우에 테스트가 실패하기를 원했을텐데, 실제는 그렇지 못하다. 다른 개발자가 toString의 출력 길이를 줄이기로 결정했다고 가정해보자.
이제 qtyTimesIndividualPrice 테스트가 갑자기 실패하게 될 것이다.
더 좋은 어써션은 해당 동작에서 정확하게 원하는 것만을 체크하는 것이다. 이 경우 항목의 최종 금액이 올바르게 계산되었는지를 확인하고 싶은 것이고, 따라서 이 테스트 구현이 아래와 같으면 더 좋을 것이다.
부적절한 어써션(Inappropriate assertions):
어써션 작성을 위한 올바른 구현 전략을 선택하는 것이 장기적으로 테스트의 유지 관리에 영향을 미친다. 어써션 인스트럭션을 잘못 선택하는 경우 개발자에게 실패에 대한 더 적은 정보를 제공하게 만들고, 따라서 디버깅 프로세스를 더 어렵게 만들 수 있다.
제품을 쇼핑 카트에 추가하는 Cart 구현을 상상해 보자. 같은 제품이 반복 추가되는 것은 불가능하며, 간단한 구현은 다음과 같다.
개발자가 numberOfItems 동작을 테스트하기로 결정했으며, 다음과 같은 테스트케이스를 작성했다.
세심하지 못한 개발자가 어써션 명령어로 assertTrue를 선택한 것에 주의하자. 이 테스트가 예상대로 작동하지만, 이게 실패하는 경우(Cart 구현에서 Set 대신 List 로 교체하면 강제로 실패하게 만들 수 있음) 아래와 같은 어써션 에러 메시지가 나타난다.
이 에러 메시지는 결과 값에서 차이가 발생한 것을 명시적으로 보여주지 않는다. 이런 간단한 예에서는 이게 그다지 중요하지 않은 것처럼 보일 수 있지만, 더 복잡한 테스트케이스에서는 개발자가 이 메쏘드에 의해 생성된 실제 값을 인쇄하기 위한 디버깅 코드(System.out.println)를 추가해야만 할 수도 있다.
테스트는 가능한 한 많은 정보를 제공함으로써 개발자에게 도움이 될 수 있다. 이러한 목적을 위해서 올바른 어써션을 선택하는 것이 중요하다(그래야 더 많은 정보 제공이 가능하기 때문에). 이 예의 경우 assertEquals을 사용하는 것이 더 적절하다.
어떻게 어써션 명령(assertion statements)을 작성할지 개발자가 현명하게 선택할 것을 권한다. 좋은 어써션은 실패 이유를 명확하게 드러내고, 읽기 쉽고, 최대한 구체적이며, 최대한 변경에 둔감해야 한다.
비밀스러운 게스트(Mystery Guest):
통합 테스트는 종종 외부 종속성(external dependencies)에 의존한다. 이러한 종속성(또는 "게스트")의 예로 데이터베이스, 디스크상의 파일, 웹 서비스 같은 것들이 있다. 이러한 테스트 타입에서 외부 종속성을 피할 수는 없지만, 이를 테스트 코드에 명시적으로 표시하면 이런 테스트가 갑자기 실패하기 시작하는 경우 개발자에게 도움이 된다. 게스트를 사용하지만 이를 개발자에게 숨기는(즉, "미스터리 게스트"로 만드는) 테스트는 이해하기가 더 어렵다.
예상 동작(expected behavior)에서의 실패와 게스트에서의 문제로 인한 실패, 이 둘을 구분 짓는 적절한 에러 메시지를 우리 테스트가 제공하는지 확인해야 한다. 테스트를 실행하기 전에 게스트가 올바른 상태인지를 확인하는 데 전념하는 어써션을 만드는 것이 종종 이 스멜의 해결책이 된다.
테스트 코드 가독성(Test code readability)
아래 영상은 가독성이 높은 테스트 코드를 작성하는 방법에 대한 조언을 제공한다(영상 길이: 3분).
- 테스트 구조(structure): 테스트가 기본적으로 AAA(Arrange, Act, Assert) 구조를 취하는데, 이 세부분이 분명하게 구분되도록 코드를 작성하면 개발자가 테스트 코드를 이해하는데 도움이 된다.
- 정보: 테스트 코드는 정보로 가득 차 있다(즉, 테스트 대상 클래스로 넘겨주는 입력 값, 정보가 어떻게 테스트 대상 메쏘드로 흘러 가는지, 어떻게 실행 동작으로부터 출력이 나오는지, 예상 결과가 무엇인지 등등). 변수명 사용 등을 통해 테스트에 존재하는 중요한 정보(의미)를 이해하기 쉽도록 만들어야 한다.
- 어써션: 테스트 어써션을 단순 명확하고 읽기 쉽게 작성해야 한다.
플레이키 테스트 (flaky tests)
아래 영상은 자동화된 테스트 실행에서 자주 목격할 수 있는 ‘불안정한 테스트(flaky tests)’와 이를 야기하는 원인에 대해 설명한다(영상 길이: 3분).
플레이키 테스트(flaky tests)는 "불규칙한/변덕스러운" 동작을 보이는 테스트를 의미한다(즉, 개발자가 소프트웨어 시스템을 전혀 변경하지 않은 경우에도 테스트가 때때로 통과하고 또 때때로 실패함). 대표적 원인으로 아래와 같은 것들이 있다.
- 테스트가 외부 리소스 또는 공유 리소스에 의존하기 때문에 불안정할 수 있다.
- 부적절한 시간 초과(타임아웃)로 인해 테스트가 불안정해질 수 있다.
- 서로 다른 테스트 메쏘드 간의 숨겨진 상호 작용으로 인해 테스트가 불안정할 수 있다.
'개발생명주기단계별 > 구현_단위 테스팅' 카테고리의 다른 글
테스트 휴리스틱 컨닝 페이퍼 by Quality Tree Software (0) | 2024.07.08 |
---|---|
책 발췌 – 크롬 DevTools by Gayathri Mohan (0) | 2024.06.24 |
영상자료 – 초보자를 위한 JUnit과 Mockito 튜토리얼 by Thippireddy (0) | 2019.07.24 |
페이퍼요약 - 단위 테스팅 중단 기준으로서의 코드 커버리지에 대한 조사 by Smith (0) | 2019.07.22 |
배치 시스템 테스팅 기본 정리 (0) | 2019.07.15 |