4.1 좋은 단위 테스트의 4대 요소
•
회귀 방지
•
리팩터링 내성
•
빠른 피드백
•
유지 보수성
4.1.1 회귀 방지
회귀는 소프트웨어 버그다. 즉, 코드를 수정한 후 기능이 의도한 대로 작동하지 않는 경우이다.
회귀 방지 지표에 대한 테스트 점수가 얼마나 잘 나오는지 평가하려면 다음 사항을 고려해야 한다.
•
테스트 중에 실행되는 코드의 양 : 실행되는 코드가 많을 수록 테스트에서 회귀가 나타날 가능성이 높다.
•
코드 복잡도
•
코드의 도메인 유의성 : 복잡한 비즈니스 로직을 나타내는 코드가 보일러플레이트 코드보다 훨씬 더 중요하다. 비즈니스에 중요한 기능에서 발생한 버그가 가장 큰 피해를 입히기 때문이다.
단순한 코드를 테스트하는 것은 가치가 거의 없다. 단순한 코드의 예로 다음과 같이 속성 한 줄이 있다.
data class User(val name: String)
Kotlin
복사
4.1.2 리팩터링 내성
좋은 단위 테스트의 두 번째 특성은 리팩터링 내성이다. 이는 테스트를 “빨간색(실패)”로 바꾸지 않고 애플리케이션 코드를 리팩터링 할 수 있는지에 대한 척도이다.
이런 상황을 상상해보자. 새로운 기능을 개발했고 잘 작동한다. 기능이 제 역할을 하고 있으며, 모든 테스트가 통과했다. 이제 코드를 깨끗하게 정리하려고 리팩터링을 조금 한 뒤, 살짝 고치면 이전 보다 코드가 더 좋아 보인다고 생각한다. 단, 테스트가 실패하고 있다는 것만 빼면 말이다. 리팩터링으로 정확히 무엇이 문제인지 살펴봤지만, 알고 보니 아무것도 고장나지 않았다. 기능은 예전과 같이 완벽하게 작동한다. 문제는 베이스 코드를 수정하면 테스트가 빨간색으로 바뀌게끔 작성됐다는 것이다.
이런 상황을 거짓 양성(false positive)이라고 한다. 실제로 기능은 의도한대로 작동하지만 테스트는 실패를 나타내는 결과이다.
리팩터링 내성 지표에서 테스트 점수가 얼마나 잘 나오는지 평가하려면 테스트에서 얼마나 많이 거짓 양성이 발생하는지 살펴봐야 한다.
거짓 양성은 테스트 스위트에 치명적인 영향을 줄 수 있다. 테스트가 지속 가능한 성장을 하게 하는 메커니즘은 회귀 없이 주기적으로 리팩터링 하고 새로운 기능을 추가할 수 있는 것이다. 여기에는 두 가지 장점이 있다.
•
기존 기능이 고장 났을 때 테스트가 조기 경고를 제공한다.
•
코드 변경이 회귀로 이어지지 않을 것이라고 확신하게 된다.
거짓 양성은 이 두 가지 이점을 모두 방해한다.
•
테스트가 타당한 이유 없이 실패하면, 코드 문제에 대응하는 능력과 의지가 희석된다. 실패에 익숙해지고 신경쓰지 않는다.
•
거짓 양성이 빈번하면 테스트 스위트에 대한 신뢰가 떨어지며 이렇게 신뢰가 부족해지면 리팩토링이 줄어든다. 회귀를 피하려고 코드 변경을 최소화하기 때문이다.
4.1.3 무엇이 거짓 양성의 원인인가?
테스트에서 발생하는 거짓 양성의 수는 테스트 구성 방식과 관련이 있다. 테스트와 테스트 대상 시스템(SUT)의 구현 세부 사항이 많이 결합할수록 허위 경보가 더 많이 생긴다. 거짓 양성이 생길 가능성을 줄이는 방법은 해당 구현 세부 사항에서 테스트를 분리하는 것 뿐이다.
테스트는 최종 사용자의 관점에서 SUT를 검증해야하고 최종 사용자에게 의미 있는 결과만 확인해야 한다. 다른 모든 것은 무시해야 한다.
다음 예를 살펴보자. (Message.kt)
처음에는 위 테스트가 좋아 보이지만, MessageRenderer의 식별할 수 있는 동작을 실제로 확인하는가? 하위 렌더링 클래스를 재배열하거나 그 중 하나를 새 것으로 교체하면 어떻게 될까? 버그일까?
반드시 그렇지는 않다. 하위 렌더링 클래스의 구성을 변경해도 HTML 문서가 동일하게 유지될 수 있다. 예를 들어 BodyRenderer를 동일한 작업을 수행하는 BoldRenderer로 바꿀 수 있다. 또는 모든 하위 렌더링 클래스를 제거하고 MessageRenderer에서 직접 렌더링을 구현할 수도 있다.
최종 결과가 바뀌지 않을지라도, 테스트를 수행하면 빨간색으로 변할 것이다. 이는 테스트가 SUT가 생성한 결과가 아니라 SUT의 구현 세부 사항과 결합했기 때문이다.
위 테스트는 똑같이 적용할 수 있는 다른 구현을 고려하지 않고 특정 구현만 예상해서 알고리즘을 검사한다.
SUT 알고리즘과 결합된 테스트. 이런 테스트는 특정 구현을 예상하므로 깨지기 쉽다.
아래 테스트는 MessageRenderer 클래스의 소스 코드를 읽고 ‘올바른’ 구현과 비교한다. 겪어본 테스트 중 가장 심각하게 깨지기 쉬운 예시이다.
4.1.4 구현 세부 사항 대신 최종 결과를 목표로 하기
이제 더 안정되게 테스트 코드를 리팩터링 해보자.
먼저 다음 사항을 확인해야 한다. MessageRenderer에서 얻는 최종 결과는 무엇인가? 메시지의 HTML 표현이다.
위 테스트는 MessageRenderer를 블랙박스로 취급하고 식별할 수 있는 동작에만 신경쓴다. 결과적으로 테스트는 리팩터링 내성이 부쩍 늘었다.
이 테스트는 최종 사용자에게 의미 있는 유일한 결과, 즉 브라우저에 메시지가 표시되는 방식을 검증하였다. 이 테스트에 거짓 양성은 거의 없을 것이다.
왜 완전히가 아니고 거의 없는 것일까? MessageRenderer에 여전에 테스트가 실패하는 변경 사항이 있을 수 있기 때문이다. 예를 들어 render() 메소드에 새 파라미터를 추가하면 컴파일 오류가 발생할 수 있다. 하지만 이런 종류의 거짓 양성은 해결하기 쉽다. 좋지 않은 거짓 양성은 컴파일 오류를 내지 않는 것이다.
4.2 첫 번째 특성과 두 번째 특성의 본질적인 관계
회귀와 리팩터링 내성 사이에는 본질적인 관계가 있다. 이 두 가지 특성은 시간이 흐르면서 프로젝트에 영향을 다르게 미치는 경향이 있다.
4.2.1 테스트 정확도 극대화
테스트 결과를 넓은 관점으로 바라본 그림이다. 코드 정확도와 테스트 결과에 대해서는 네 가지 결과가 있을 수 있다.
•
거짓 음성을 피하는 데 좋은 테스트의 첫 번째 특성인 회귀 방지가 도움이 된다.
•
거짓 양성을 피하는 데 두 번째 특성인 리팩터링 내성이 도움이 된다.
여기서 언급되는 모든 용어는 통계학에 뿌리를 두지만, 테스트 스위트를 분석하는데도 적용할 수 있다.
테스트 스위트의 정확도 지표는 다음 두 가지 요소로 구성된다.
•
테스트가 버그 있음을 얼마나 잘 나타내는가
•
테스트가 버그 없음을 얼마나 잘 나타내는가
•
분기\ 커버리지 = \frac{통과\ 분기}{전체\ 분기\ 수}
분자를 증가시키고(회귀를 더 잘 찾아내는 테스트 작성), 소음을 줄여야(허위 경보를 발생시키지 않는 테스트를 작성) 정확도가 올라간다.
4.2.2 거짓 양성과 거짓 음성의 중요성 : 역학 관계
단기적으로는 거짓 양성도 거짓 음성만큼 나쁘지 않지만, 프로젝트가 성장함에 따라 거짓 양성은 테스트 스위트에 점점 더 큰 영향을 미치기 시작한다.
초기에는 리팩터링이 바로 중요하지는 않으며, 시간이 지나며 중요해지기에, 거짓 양성은 갈수록 중요해진다. 개발자의 기억 속에 코드가 생생하기 때문에 테스트에서 잘못된 경보가 발생하더라도 쉽게 리팩터링할 수 있다.
특히 프로젝트 후반에, 거짓 양성으로부터 코드를 보호하는 것이 중요해도, 이렇게 거짓 양성을 신경쓰는 개발자는 거의 없다. 대부분의 좋은 단위 테스트의 첫 번째 특성인 회귀 방지에만 중점을 두는 경향이 있는데, 회귀 방지는 프로젝트 성장을 유지하는 데 도움이 되고 가치가 있으며, 매우 정확한 테스트 스위트를 구축하기에 충분하지 않다.
중대형 프로젝트에서 작업하면, 거짓 음성과 거짓 양성에 대해 똑같이 주의를 기울여야 한다.
4.3 세 번째 요소와 네 번째 요소 : 빠른 피드백과 유지 보수성
빠른 피드백은 단위 테스트의 필수 속성이다. 테스트 속도가 빠를 수록 테스트 스위트에서 더 많은 테스트를 수행할 수 있고, 더 자주 실행할 수 있다.
좋은 단위 테스트의 네 번째 특성인 유지 보수성 지표는 유지비를 평가한다. 이 지표는 다음 두 가지 요소로 구성된다.
•
테스트가 얼마나 이해하기 쉬운가?
•
테스트가 얼마나 실행하기 어려운가?
4.4 이상적인 테스트를 찾아서
좋은 단위 테스트의 4대 특성은 다음과 같다는 것을 우리는 알게 되었다.
•
회귀 방지
•
리팩터링 내성
•
빠른 피드백
•
유지 보수성
위 특성 중 하나라도 0이 되면 전체가 0이 된다. 즉, 이 네 가지 특성의 곱이 테스트의 가치가 된다. 물론 이런 특성을 정확히 측정하는 것은 불가능하다.
테스트 코드를 포함한 모든 코드는 책임이다. 최소한으로 필요한 가치로 임계치를 높게 설정하고 이 임계치를 충족하는 테스트만 테스트 스위트에 남겨라.
4.4.1 이상적인 테스트를 만들 수 있는가?
이상적인 테스트는 4대 특성을 모두 1을 얻는 것이지만, 이는 불가능하다. 하지만 다른 특성에 집중하려고 하나의 특성을 그냥 버릴 수는 없다. 따라서 특성 중 어느 것도 크게 줄이지 않는 방식으로 최대한 크게 해야 한다. 두 특성을 최대로 하는 것을 목표로 해서 한 가지 특성을 희생해 결국 가치가 0에 가까워진 테스트를 몇 가지 예로 살펴보자.
4.4.2 극단적인 사례 1 : 엔드 투 엔드 테스트
일반적으로 엔드 투 엔드 테스트는 UI, 데이터베이스, 외부 애플리케이션을 포함한 모든 시스템 컴포넌트를 거치게 된다.
E2E 테스트는 많은 코드를 테스트하므로 회귀 방지를 훌륭하게 해내며, 거짓 양성에 면역이 되어 리팩터링 내성도 우수하다. 올바르게 리팩터링으르 했다면 식별할 수 있는 동작을 변경하지 않으므로 E2E 테스트에 영향을 미치지 않는다. 또 다른 장점으로 어떤 특정 구현도 강요하지 않는다. 최종 사용자의 관점에서 어떻게 기능이 동작하는지만 볼 수 있으며, 구현 세부 사항을 최대한 제거하게 된다.
하지만 느린 속도가 큰 단점이 된다. E2E 테스트에만 의존하는 시스템은 피드백을 빨리 받기가 어렵고, 느린 속도는 E2E 테스트로만으로 코드 베이스를 다루기가 불가능한 이유이기도 하다.
4.4.3 극단적인 사례 2 : 간단한 테스트
세 번째 특성을 희생하여 나머지 두 속성을 극대화하는 또 다른 예로 간단한 테스트(trivial test)가 있다. 아래 예시를 보자.
간단한 테스트는 매우 빠르게 실행되고 빠른 피드백을 제공하며, 거짓 양성이 생길 가능성이 상당히 낮기 때문에 리팩터링 내성도 우수하다.
하지만 베이스 코드에 실수할 여지가 많지 않기 때문에 간단한 테스트는 회귀를 나타내지 않을 것이다. 우수한 리팩터링 내성과 빠른 피드백을 제공하지만, 회귀 방지가 없다.
4.4.4 극단적인 사례 3 : 깨지기 쉬운 테스트
마찬가지로 실행이 빠르고 회귀를 잡을 가능성이 높지만 거짓 양성이 많은 테스트를 작성하기가 매우 쉽다. 이러한 테스트를 깨지기 쉬운 테스트(brittle test)라고 한다.
위 테스트는 데이터베이스에서 사용자를 가져올 때 UserRepository 클래스가 올바른 SQL 문을 생성하는지 확인한다. 이 테스트는 버그를 잡을 수는 있지만, 리팩터링 내성이 좋지는 않다. 아래와 같이 SQL 문을 여러 가지 형태로 변경해도 결과는 모두 같다.
SELECT * FROM dbo.[User] WHERE UserID = 5
SELECT * FROM dbo.User WHERE UserID = 5
SELECT UserID, Name, Email FROM dbo.User WHERE UserID = 5
SELECT * FROM dbo.[User] WHERE UserID = @UserID
SQL
복사
기능은 계속 동작하지만 SQL 스크립트를 변경하면 테스트는 빨간색으로 바뀐다. 이 테스트는 무엇보다 ‘어떻게’에 중점을 두고 있기 때문에 더 이상의 리팩터링은 막으면서 SUT의 구현 세부 사항에 스며들고 있다.
3가지 극단적인 테스트를 그림으로 살펴보면 다음과 같다.
4.4.5 이상적인 테스트를 찾아서 : 결론
좋은 단위 테스트의 처음 세 가지 특성 (회귀 방지, 리팩터링 내성, 빠른 피드백)은 상호 배타적이다. 안타깝게도 세 가지 특성 모두 완벽한 점수를 얻어서 이상적인 테스트를 만드는 것은 불가능하다.
네 번째 특성인 유지 보수성은 E2E 테스트를 제외하고 처음 세 가지 특성과 상관관계가 없다.
좋은 테스트를 만드는 특성 간에 균형을 이뤄내는 것은 쉽지 않기에 부분적, 전략적으로 희생해야 한다. 실제로는 리팩터링 내성을 포기할 수 없다. 우리는 E2E 테스트만 쓰거나 테스트가 빠르지 않은 한, 리팩터링 내성을 많이 가지는 것을 목표로 해야 한다. 따라서 테스트가 얼마나 버그를 잘 찾아내는지와 얼마나 빠른지 사이의 선택으로 절충이 귀결된다.
리팩터링 내성을 포기할 수 없는 이유는 테스트가 이 특성을 갖고 있는지 여부는 이진 선택이기 때문이다. (리팩터링 내성이 있거나 없거나)
다음 절에서는 어느 한 가지를 선택할 때 어떤 절충이 가능한지 살펴본다.
팁 : 테스트 스위트를 탄탄하게 만들려면 테스트의 거짓 양성을 제거하는 것이 최우선 과제다.
4.5 대중적인 테스트 자동화 개념 살펴보기
이 절에서는 테스트 피라미드와 화이트박스 테스트 대 블랙박스 테스트라는 두 가지 개념을 살펴본다.
4.5.1 테스트 피라미드 분해
테스트 피라미드는 테스트 스위트에서 테스트 타입 간의 일정한 비율을 일컫는 개념이며 넓을 수록 테스트는 많아지고, 높을 수록 최종 사용자의 동작을 얼마나 유사하게 흉내내는지를 나타낸다.
피라미드 상단의 테스트는 회귀 방지에 유리한 반면 하단은 실행 속도를 강조한다.
어느 계층도 리팩터링 내성을 포기하지 않으며 모든 테스트는 거짓 양성을 최대한 적게 하는 것을 목표로 해야 한다. 테스트 타입 간의 정확한 비율은 각 팀과 프로젝트마다 다르겠지만, 일반적으로 피라미드 형태를 유지해야 한다.
E2E 테스트는 빠른 피드백 지표에서 매우 낮은 점수를 받으며 유지 보수성이 결여 되어 있는데, 이는 크기가 큰 편이라 관련 프로세스 외부 의존성을 유지하는데 더 노력을 들여야하기 때문이다. 따라서 E2E 테스트는 가장 중요한 기능에 적용할 때와 단위 테스트나 통합 테스트와 동일한 수준으로 보호할 때만 적용된다. E2E 테스트를 최소한으로 필요한 가치의 임계치를 넘어서 다른 용도로 사용하면 안 된다.
테스트 피라미드에는 예외가 있다. 예를 들어 모든 애플리케이션이 기본적인 CRUD 작업이라면 테스트 피라미드는 단위 테스트와 통합 테스트의 수가 같고 E2E가 없는 직사각형처럼 보일 것이다.
또 다른 예외는 프로젝트 외부 의존성 하나만 연결하는 API이다. E2E 테스트를 더 많이 두는 것이 애플리케이션에 적합한 옵션일 수 있다. 사용자 인터페이스가 없으므로 E2E 테스트가 빠르게 실행된다.
4.5.2 블랙박스 테스트와 화이트박스 테스트 간의 선택
•
블랙박스 테스트 : 시스템의 내부 구조를 몰라도 시스템의 기능을 검사할 수 있는 방법이다. 애플리케이션이 어떻게 해야 하는지가 아니라 무엇을 해야 하는지를 중심으로 구축된다.
•
화이트박스 테스트 : 정반대이다. 애플리케이션 내부 작업을 검증하는 방식이며, 테스트는 요구 사항이나 명세가 아닌 소스 코드에서 파생된다.
두 방법 모두 장단점이 존재하는데, 화이트박스 테스트가 더 철저한 편이다. 소스 코드를 분석하면 외부 명세에만 의존할 때 놓칠 수 있는 많은 오류를 발견할 수 있다. 반면 화이트박스 테스트는 코드의 구현과 결합되어 있기에 깨지기 쉽다. 이런 테스트는 거짓 양성을 많이 내고 리팩터링 내성 지표가 부족하다.
우리는 앞서 리팩터링 내성은 타협할 수 없다는 사실을 알았다. 즉, 기본적으로 블랙박스 테스트를 선택해야 한다. 모드느 테스트가 시스템을 블랙박스로 보게 만들고 문제 영역에 의미 있는 동작을 확인하게 해야 한다.
유일한 예외는 알고리즘 복잡도가 높은 유틸리티 코드를 다루는 경우이다.(7장 참고)
테스트를 작성할 때는 블랙박스 테스트가 바람직하지만, 테스트를 분석할 때는 화이트박스 테스트 방법을 사용할 수 있다. 코드 커버리지 도구를 사용하여 어떤 코드 분기를 실행하지 않았는지 확인한 후, 코드 내부 구조에 대해 전혀 모르는 것처럼 테스트해라. 이런 화이트박스 방법과 블랙박스 방법의 조합이 가장 효과적이다!