4. 좋은 단위 테스트의 4대 요소
원제 : The four pillars of a good unit test
좋은 단위 테스트의 특성을 다시 리마인드해보자.
- 개발 주기에 통합되어 있어, 실제로 사용되는 테스트이다.
- 코드베이스의 가장 중요한 부분만을 대상으로 한다.
- 최소한의 유지비로 최대 가치를 끌어낸다.
여기서 3번. 최소 비용 최대 효과의 원칙을 위해 가치 있는 테스트의 식별과 작성이 뒷받침되어야 한다.
이번 포스팅에서는 무엇이 가치 있는 테스트인지를 알아보도록 하자.
4.1. 좋은 단위 테스트의 4개 요소 살펴보기
원제 : Diving into the four pillars of a good unit test
좋은 단위 테스트에는 아래와 같은 네 가지 특성이 있다.
- 회귀 방지(Protection against regressions)
- 리팩토링 내성(Resistance to refactoring)
- 빠른 피드백(Fast feedback)
- 유지 보수성(Maintainability)
4.1.1. 첫 번째 특성 : 회귀 방지
원제 : The first pillar: Protection against regressions
먼저 회귀 방지에 대해서 알아보자.
회귀는 일종의 소프트웨어에 존재하는 버그로, 코드를 수정한 뒤에 의도대로 동작하지 않는 경우를 뜻한다.
개발할 기능이 많을수록 회귀가 나타날 가능성도 높아지며, 코드베이스의 크기가 거질 수록 잠재적인 버그가 더 많이 발생할 수 있다.
따라서 회귀를 방지하는 것은 매우 중요하다.
회귀 방지 지표에 대해 테스트 점수를 평가하려면 아래와 같은 항목을 고려해야 한다.
- 테스트 중에 실행되는 코드의 양(The amount of code that is executed during the test)
- 코드 복잡도(The complexity of that code)
- 코드의 도메인 유의성(The code’s domain significance)
일반적으로 실행되는 코드가 많을 수록 회귀의 출현 가능성이 높으며, 복잡한 비즈니스 로직을 나타내는 코드가 보일러 플레이트보다 훨씬 더 중요하다.
반면 단순한 코드를 테스트하는 일은 가치없는 일로, 대부분 코드의 길이가 짧고 비즈니스 로직을 많이 포함하고 있지도 않기 때문이다.
한 마디로, 단순한 코드를 다루는 테스트는 실수할 여지가 거의 없기에 회귀가 발생할 가능성도 적다.
아래 예제는 가장 단순한 코드의 예시이다.
1 | public class User { |
물론 작성한 코드 외에도 라이브러리나 프레임워크 등도 매우 중요하며, 최상의 회귀 방지를 위해선 외부 시스템도 테스트 범주에 포함시켜 의존성에 대한 검증을 수행해야 한다.
4.1.2. 두 번째 특성 : 리팩토링 내성
원제 : The second pillar: Resistance to refactoring
두 번째 특성은 리팩토링 내성이다.
리팩토링 내성은 테스트에 대한 변경 없이도 기본 애플리케이션 코드를 수정할 수 있는 가에 대한 척도다.
새로운 기능을 개발한 뒤, 모든 테스트가 통과되는 상황을 가정해보자.
이후 코드를 정리하기 위해 리팩토링을 수행 한 뒤, 모든 의도된 동작이 정상적으로 수행됨을 확인하였다.
이때 테스트가 실패하는 경우가 있는데 이 상황을 거짓 양성(false positive) 이라고 한다.
거짓 양성은 실제로 기능은 의도한대로 동작하지만 테스트는 실패했음을 나타내며, 테스트로 관찰하는 동작은 보장하면서 내부 구현을 수정할 때 발생할 수 있다.
이 거짓 양성은 적게 발생할 수록 좋으며, 많이 발생할 경우 전체 테스트 스위트에 치명적인 영향을 줄 수 있다.
단위 테스트를 통해 지속 가능한 프로젝트의 성장을 목표로 한다면, 이 거짓 양성이 주는 경보를 주의깊게 파악할 필요가 있다.
4.1.3. 거짓 양성의 원인은 무엇인가?
원제 : What causes false positives?
거짓 양성을 피하려면 발생하는 원인을 알고 회피해야할 것이다.
테스트에서 발생하는 거짓 양성의 수는 테스트의 구성 방식과 직접적인 관련이 있다.
테스트와 테스트 대상 시스템(SUT)의 구현 세부 사항간의 커플링이 심할 수록 허위 경보가 더 많이 쏟아질 것은 자명하다.
따라서 거짓 양성이 생길 가능성을 줄이려면 이 커플링을 완화하는 것이 중요하다.
즉 세부 구현과 테스트를 분리하는 데 힘을 쏟아야 한다.
테스트를 통해 SUT가 제공하는 최종 결과를 검증하는 지 확인하고, 최종 사용자의 관점에서 검증해야 함을 명심하자.
테스트를 구성하기에 가장 좋은 방법은 문제 영역을 명시하고 이에 집중하는 것이다.
아래 예제를 보자.
MessageRenderer
클래스는 머리글, 본문, 바닥글을 포함하는 HTML 코드를 생성한다.
1 | public class Message { |
MessageRenderer
클래스에는 여러 하위 렌더링 클래스가 존재하고, 메시지의 일부에 대한 실제 작업을 위임한 뒤
그 결과를 HTML 문서로 결합하는 동작을 가지고 있다.
하위 렌더링 클래스는 아래와 같이 원본 문자열을 HTML 태그로 변환한다.
1 | public class BodyRenderer : IRenderer { |
이러한 구조에서 MessageRenderer
클래스를 테스트하려면 어떻게 해야할까?
먼저 이 클래스가 따르는 알고리즘을 분석하는 방법이 있다.
1 | [ ] |
위의 테스트는 하위 렌더링 클래스가 올바른 순서로 HTML태그를 생성한 것인지 검증하고 있다.
이때 이 테스트가 메시지를 처리하는 방법도 검증하고 있다고 가정해보자.
그럼 이 테스트는 가치 있는 테스트인가 가치 없는 테스트인가?
하위 렌더링 클래스를 다시 정렬하거나 새로운 렌더링 클래스로 교체하는 경우 버그가 발생하는가?
이 경우 반드시 버그가 발생하진 않는다.
하위 렌더링 클래스의 구성을 변경하더라도 HTML 문서가 동일하게 유지될 수 있기 때문이다.
예를 들어 BodyRenderer
대신 BoldRenderer
로 바꾸던가, 하위 렌더링 클래스를 모두 제거하고 MessageRenderere
에서 직접 렌더링을 구현하는 경우가 해당 된다.
다만 최종 결과가 바뀌진 않더라도 테스트는 실패할 것이다.
이는 테스트가 SUT의 결과가 아닌 구현의 세부 사항과 결합되었기 때문이다.
이렇게 구현의 세부 사항과 결합된 테스트는 똑같이 적용할 수 있는 다른 구현이 아닌 특정 구현만 예상해서 검증하게 된다.
이를 그림으로 나타내면 아래와 같이 된다.
위에 예제로 알아보았뜻 SUT의 구현 세부사항과 결합된 테스트는 리팩토링 내성이 없다.
리팩토링 내성의 부재는 아래와 같은 문제점을 야기한다.
- 회귀 발생시 조기 경고를 제공하지 않는다. 대부분 잘못된 것이므로 이러한 경고는 무시하게 된다.
- 리팩토링에 대한 능력과 의지를 방해한다. 테스트가 버그를 찾기 위한 길잡이가 되지 못하기 때문이다.
이번엔 다른 예제를 살펴보자.
1 | [ ] |
위 테스트는 매우 극단적인 예시로, MessageRenderer
클래스에서 어떠한 수정이라도 발생하면 실패한다.
위에서 다룬 두 예제는 모두 SUT의 식별할 수 있는 동작이 아닌 특정 구현만 고집하고, 구현을 변경하면 실패하는 테스트이다.
4.1.4. 구현 세부 사항 대신 최종 결과를 목표로 하기
원제 : Aim at the end result instead of implementation details
상술했듯이, 테스트의 가치를 보존하고 리팩토링 내성을 높이는 방법은 SUT의 구현 세부 사항과 테스트 간의 결합도를 낮추는 것 뿐이다.
먼저 위의 에졔를 리팩토링 해보자.
리팩토링을 하기 전 MessageRenderer
클래스에서 얻고자 하는 최종 결과에 대해 생각해보자.
먼저 최종 결과의 포맷은 HTML로 작성된 결과물이고, 이는 클래스에서 관찰 가능한 결과이므로 검증하는 것이 마땅하다.
이 HTML 포맷이 유지되는 한 정확히 어떻게 생성되는지는(=구현 세부 사항) 고려사항이 되지 않는다.
아래와 같이 테스트 코드를 리팩토링해보자.
1 | [ ] |
이 테스트 코드는 MessageRenderer
클래스를 일종의 블랙박스로 취급하고, 식별할 수 있는 동작에만 신경을 쓴 것이다.
그 결과 리팩토링 내성이 상승하였고, 출력 포맷을 지키는 이상 SUT의 세부 구현 사항은 테스트의 실패에 영향을 주지 않게 되었다.
그림으로 표현하면 아래와 같다.
이제 이 테스트 코드는 최종 사용자에게 의미있는 유일한 결과, 브라우저에 메시지가 표시되는 방식을 검증하는 로직이 되어 비즈니스 로직의 요구사항에 맞게 되었다.
또한 이 테스트의 실패는 거짓 양성이 아니므로 가치있는 테스트 코드가 되었다.
4.2. 회귀 방지와 리팩토링 내성 사이의 본질적인 관계
원제 : The intrinsic connection between the first two attributes
좋은 단위 테스트의 특성 중 회귀 방지와 리팩토링 내성에 대해서 알아보았다.
이 두 특성의 본질적인 관계에 대해서 알아보자.
두 특성은 정반대의 관점에서 프로젝트에 기여하는데,
프로젝트가 막 시작된 직후에는 회귀 방지를 갖추는 것이 중요한 데 반해, 리팩토링 내성은 시작 직후에는 필요하지 않다.
이 두 특성을 테스트 정확도 극대화, 거짓 양성과 거짓 음성의 중요성 측면에서 살펴보도록 하자.
4.2.1. 테스트 정확도 극대화
원제 : Maximizing test accuracy
잠시 테스트 결과를 넓은 관점으로 살펴보도록 하자.
코드의 정확도와 테스트 결과에 대해서는 아래와 같이 도식할 수 있다.
테스트가 통과하고 기본 기능이 의도한 대로 동작한다면 올바른 추론으로 버그가 없는 상태를 말한다. (true negatives)
테스트가 실패하고 기본 기능이 의도한 대로 동작하지 않는 것도 올바른 추론으로 버그가 있는 상태를 말한다. (true positives)
나머지 케이스인 1종 오류(Type I Error) 는 기능이 동작하는 데, 테스트가 실패한 것을 말하며 이를 거짓 양성(false positive) 이라고 볼 수 있다.
이 거짓 양성은 리팩토링 내성의 상승으로 방어할 수 있다.
2종 오류(Type II error) 는 기능이 고장났음에도 테스트가 통과된 상태를 말하며 이를 거짓 음성(false negative) 라고 본다.
이 거짓 음성은 회귀 방지를 통해 최소화할 수 있다.
4.2.2. 거짓 양성과 거짓 음성의 중요성 : 역학 관계
원제 : The importance of false positives and false negatives: The dynamics
단기적으로는 거짓 양성도 거짓 음성만큼 나쁘지 않다.
프로젝트 시작시 아예 경고가 없는 상황보다는 낫기 때문이다.
다만 아래 그림처럼 프로젝트가 성장함에 따라 거짓 양성은 테스트 스위트에 점점 더 큰 영향을 미치게 된다.
이는 프로젝트 초기엔 리팩토링의 중요성이 떨어지지만 진행될수록 그 중요성이 상승하기 때문이다.
4.3. 세 번째 특성과 네 번째 특성 : 빠른 피드백과 유지 보수성
원제 : The third and fourth pillars: Fast feedback and maintainability
회귀 방지와 리팩토링에 대해서 살펴보았으니 남은 특성인 빠른 피드백과 유지 보수성에 대해서 알아보자.
앞선 포스팅에서 다루었듯이 빠른 피드백은 단위 테스트의 필수적인 속성이다.
만약 빠른 피드백이 보장되어서, 코드에 버그가 발생하자마자 경고를 줄 수 있다면 버그를 수정하는 비용을 0에 수렴시킬 수도 있다.
반면 느린 테스트는 피드백도 느리게하고 잠재적으로 버그를 뒤늦게 경고해주므로 버그 수정 비용이 증가하게 된다.
또한 오래 걸리는 테스트는 자주 수행할 수 없기에 더욱 시간을 낭비하게 된다.
마지막으로 유지 보수성 지표는 유지비를 평가한다.
이 지표는 아래 두 가지 요소로 구성된다.
- 테스트가 얼마나 이해하기 어려운가?
테스트 코드의 라인 수가 적을 수록 더욱 읽기 쉬운 테스트가 된다.
물론 인위적으로 압축하지 않은 순수한 테스트 코드를 뜻한다.
이 테스트 코드의 품질을 코드 베이스의 품질만큼 중요하다.
- 테스트가 얼마나 실행하기 어려운가?
테스트가 프로세스 외부 종속성으로 작동하면, 데이터베이스 서버를 재부팅하고 네트워크 연결 문제를 해결하는 등 의존성을 상시 운영하는 데 리소스를 투자해야 한다.
4.4. 이상적인 테스트를 찾아서
원제 : In search of an ideal test
좋은 단위 테스트의 4대 특성을 다시 나열해보자.
- 회귀 방지(Protection against regressions)
- 리팩토링 내성(Resistance to refactoring)
- 빠른 피드백(Fast feedback)
- 유지 보수성(Maintainability)
이 4대 특성이 각각의 가중치를 가지고 있고, 각 가중치가 0부터 1까지의 범주안에 속한다고 할때,
결국 이상적인 테스트란 위의 4대 특성이 가진 가중치의 곱이다.
즉 하나의 특성이라도 0의 가중치를 가지는 이상 모든 테스트의 가치도 0으로 귀결되게 된다.
즉 미약하게라도 각각의 가중치를 가지고 있어야만 가치 있는 테스트가 되며, 결과값이 1에 가까울 수록 이상적인 테스트라고 볼 수 있다.
4.4.1. 이상적인 테스트는 만들 수 있는가?
원제 : Is it possible to create an ideal test
이상적인 테스트는 말 그대로 “이상적”이기때문에 정량적으로 정밀하게 측정하는 것은 불가능하다.
거기다가 회귀 방지, 리팩토링 내성, 빠른 피드백은 상호 배타적인 특성들이기에 세 특성이 모두 최대의 가중치를 가질 수는 없다.
이 중 두 개의 특성만을 최대치로 올릴 수 있게 되는 것이다.
결론적으로 우리가 만들어야할 “그나마 현실적이고 이상적인 테스트” 는 이 특성들간의 균형을 유지하고,
어느 특성이 0의 가중치를 가지지 않도록 관리하는 것이다.
이제부터 가치가 0에 가까워진 테스트들의 예제를 살펴보도록 하자.
4.4.2. 극단적인 경우 #1 : End-to-end 테스트
원제 : Extreme case #1: End-to-end tests
첫 번째 예시는 엔트 두 엔드 테스트이다.
엔드 투 엔드 테스트는 최종 사용자의 관점으로 시스템을 살펴보는 것으로 UI, 데이터베이스, 외부 애플리케이션 등 모든 시스템의 구성 요소를 거치게 된다.
즉 엔드 투 엔드 테스트는 많은 코드를 테스트하게 되므로 필연적으로 회귀 방지에 강한 면모를 보인다.
도한 거짓 양성에 면역이 되기때문에 리팩토링 내성도 우수하다고 볼 수 있다.
올바른 리팩토링은 식별할 수 있는 동작에 영향을 끼치지않으므로 엔드 투 엔드 테스트에도 영향을 주지 않기 때문이다.
이처럼 장점만 가득해보이는 엔드 투 엔드 테스트의 가장 큰 단점은 바로 테스트에 매우 많인 시간이 든다는 것이다.
한 마디로, 엔드 투 엔드 테스트는 느리다.
빠른 피드백을 얻지 못하는 프로젝트의 단점은 위에서 언급했듯 유지 보수성을 계속해서 추락시키는 원인이 된다.
따라서 가중치는 아래와 같이 부여할 수 있다.
구분 | 회귀 방지 | 리팩토링 내성 | 빠른 피드백 |
---|---|---|---|
엔드 투 엔드 테스트 | 1 | 1 | 0 |
4.4.3. 극단적인 경우 #2 : 너무 사소한 테스트(=간단한 테스트)
원제 : Extreme case #2: Trivial tests
두 번째 예시는 너무 단순해서 버그가 발생하기 힘든 코드를 다루는 테스트 코드이다.
아래 예제를 보자.
1 | public class User { |
이 테스트 코드는 라인 수가 적어 이해하기 쉽고 실행 속도도 매우 빠를 것이다.
또한 거짓 양성이 생길 가능성이 현저히 적기에 리팩토링 내성도 우수하다.
하지만 코드 자체가 너무 간단하여, 실수할 여지가 없다면 회귀 방지를 할 영역 조차 없는 것이 문제가 된다.
구분 | 회귀 방지 | 리팩토링 내성 | 빠른 피드백 |
---|---|---|---|
엔드 투 엔드 테스트 | 1 | 1 | 0 |
간단한 테스트 | 0 | 1 | 1 |
4.4.4. 극단적인 경우 #3 : 깨지기 쉬운 테스트
원제 : Extreme case #3: Brittle tests
실행 속도고 빠르고, 회귀 방지도 잘 하지만 거짓 양성이 많은 테스트를 깨지기 쉬운 테스트라고 볼 수 있다.
이러한 코드는 리팩토링 내성이 전무하여, 해당 기능이 고장 났는지 여부에 관계없이 테스트가 실패하게 된다.
아래 예제를 보자.
1 | public class UserRepository { |
이 테스트 코드는 데이터베이스에서 사용자를 가져올때 올바른 쿼리를 생성하는 지 검증한다.
개발자가 쿼리를 잘못 만들거나 UserID가 아닌 다른 값을 사용하는 경우에 대해서는 테스트가 실패하므로 버그에 대한 경고를 수행하기도 한다.
하지만 이 테스트 코드의 리팩토링 내성은 매우 낮은 편이다.
예를 들어 아래와 같이 쿼리를 생성하면 테스트는 모두 실패하게 된다.
1 | SELECT * FROM dbo.[User] WHERE UserID = 5 |
기능이 정상적으로 동작하더라도 테스트는 실패하는 이유는 테스트가 SUT의 세부 구현 사항에 강하게 결합하고 있기 때문이다.
따라서 가중치는 아래와 같이 부여할 수 있다.
구분 | 회귀 방지 | 리팩토링 내성 | 빠른 피드백 |
---|---|---|---|
엔드 투 엔드 테스트 | 1 | 1 | 0 |
간단한 테스트 | 0 | 1 | 1 |
깨지기 쉬운 테스트 | 1 | 0 | 1 |
4.5. 대중적인 테스트 자동화 개념 살펴보기
원제 : Exploring well-known test automation concepts
마지막으로 기존에 잘 알려진 테스트 자동화 개념에 대해서 짚고 넘어가보자.
4.5.1. 테스트 피라미드 분해
원제 : Breaking down the Test Pyramid
테스트 피라미드는 테스트 스위트에서 테스트 유형간의 일정한 비율을 일컫는 개념이다.
테스트 유형은 크게 3가지로 구분한다.
- 단위 테스트 (Unit tests)
- 통합 테스트 (Integration tests)
- 엔드 투 엔드 테스트 (End-to-end tests)
위 테스트 유형들을 아래 그림의 피라미드처럼 일정한 비율을 차지하게 된다.
피라미드에서 각 층의 너비는 테스트 스위트 내에서 각 테스트 유형이 얼마나 보편적인지를 나타낸다.
또한 층의 높이는 이 테스트 유형이 최종 사용자의 동작을 얼마나 모방하는지를 나타내는 척도가 된다.
따라서 사용자의 동작과 제일 유사한 엔드 투 엔드 테스트가 최상위에 위치하고 있는 것을 알 수 있다.
이 테스트 피라미드에서는 피라미드 내 테스트 유형에 따라 빠른 피드백과 회귀 방지 사이에서 선택을 해야한다.
피라미드에서 상위 계층일 수록 회귀 방지에 유리하고, 하위 계층일수록 빠른 피드백에 유리하다.
참고 테스트 피라미드 직사각형을 그리는 경우
반면 복잡도가 거의없는 CRUD 등의 작업이나 비즈니스 규칙 등의 경우 테스트 피라미드는 직사각형을 그리게 될 것이다.
좀 더 구체적으로 단위 테스트와 통합 테스트의 수가 동일하고, 엔드 투 엔드 테스트가 없기 때문이다.
4.5.2. 블랙박스 테스트 대 화이트박스 테스트
원제 : Choosing between black-box and white-box testing
잘 알려진 테스트 자동화 개념으로 블랙박스 테스트와 화이트박스 테스트가 있다.
블랙박스 테스트(black-box testing) 은 시스템의 내부 구조를 몰라도 시스템의 기능을 검사하는 테스트 방법으로 일반적으로 명세와 요구 사항을 중심으로 구축된다.
따라서 애플리케이션의 동작보다는 사용자의 동작, “어떻게” 보다는 “무엇을”에 집중하는 경향이 있다.
화이트박스 테스트(white-box testing) 은 블랙박스 테스트와 정반대이다.
애플리케이션의 내부 작업을 검증하는 테스트 방식으로, 명세나 요구 사항이 아니 소스 코드를 기반으로 작성된다.
두 가지 방법 모두 장단이 있는데, 먼저 표로 간단하게 나타내보자.
구분 | 회귀 방지 | 리팩토링 내성 |
---|---|---|
화이트박스 테스트 | 좋음 | 나쁨 |
블랙박스 테스트 | 나쁨 | 좋음 |
화이트박스 테스트의 경우 소스 코드를 기반으로 파생된 테스트이기에 블랙박스 테스트에 비해 좀 더 철저한 테스트를 할 수 있다.
다만, 세부 구현 명세와의 강결합이 발생하기도 쉬워 깨지기 쉬운 테스트가 될 수도 있다.
이러한 상황은 거짓 양성을 많이 발생시키고 리팩토링 내성에 대한 지표도 부족하다는 단점도 존재한다.
반면, 블랙박스 테스트는 사용자 입장에서 보기에 비즈니스 로직에서 의미있는 테스트를 수행할 수 있다.
그럼 둘 중 무엇을 선택해서 테스트를 작성해야할까?
다행히 추상적인 테스트 선택의 세계에서 이것만은 정답이 있다.
블랙박스 테스트를 기본으로 선택해야 한다. 이는 리팩토링 내성을 기준으로 판정할 수 있는데, 테스트는 리팩토링 내성을 가지거나 안 가지거나의 양자택일이지 타협의 대상이 아니다.
따라서 블랙박스 테스트를 기본으로 하여 리팩토링 내성을 가지게 하는 것이 권장된다.
모든 테스트가 시스템을 블랙박스로 보게 만들고, 문제 영역내에 의미있는 동작을 확인하도록 구축해야한다.
테스트를 통해 비즈니스 로직까지 검증할 수 없다면 이는 깨지기 쉬운 테스트이므로, 재구성을 해야하는 신호로 볼 수도 있다.
테스트 작성을 블랙박스로 진행했다면 이를 분석할때는 화이트박스로 진행하는 것이 좋다.
코드 커버리지 도구를 사용해서 어떤 코드 분기를 실행하지 않았는지 검증하고 코드 내부 구조에 대해 알 필요없는 상태로 테스트를 작성하는 것이다.
이렇게 두 테스트를 메시업해서 쓴다면 가장 효과적이라고 볼 수 있다.