2022.03.19
좋은 단위 테스트에는 다음과 같은 네 가지 특성이 있다.
회귀는 소프트웨어 버그다. 코드를 수정한 후(일반적으로 새 기능을 출시한 후) 기능이 의도한 대로 작동하지 않는 경우다.
프로그램의 코드베이스가 커질수록 잠재적인 버그에 더 많이 노출된다. 그렇기 때문에 회귀에 대해 효과적인 보호를 개발하는 것이 중요하다.
이러한 보호가 없다면 프로젝트가 오랫동안 성장할 수 없으며 점점 더 많은 버그가 쌓인다.
회귀 방지 지표에 대한 테스트 점수를 평가하려면 다음 사항을 고려해야 한다.
일반적으로 실행되는 코드가 많을수록 테스트에서 회귀가 나타날 가능성이 높다.
코드의 양뿐 아니라 복잡도와 도메인 유의성도 중요하다.
복잡한 비지니스 로직을 나타내는 코드가 보일러플레이트 코드보다 훨씬 더 중요하다. 비지니스에 중요한 기능에서 발생한 버그가 가장 큰 피해를 입기 때문이다.
프레임워크, 라이브러리 등 외부 코드들도 소프트웨어 작동에 영향을 많이 미치기 때문에 테스트 범주에 포함시켜
소프트웨어가 이러한 의존성에 대해 검증이 올바른지 확인해야 한다.
회귀 방지 지표를 극대화하려면 테스트가 가능한 한 많은 코드를 실행하는 것을 목표로 해야 한다.
리팩터링하고 나서 실제 기능은 잘 동작하지만 테스트가 실패하는 경우가 있다.
이러한 상황을 거짓 양성(false positive)이라고 한다.
거짓 양성은 일반적으로 코드를 리팩터링할 때, 구현을 수정하지만 식별할 수 있는 동작은 유지할 때 발생한다.
리팩터링 내성 지표에서 테스트 점수가 얼마나 잘 나오는지 평가하려면 얼마나 많이 거짓 양성이 발생하는지 살펴봐야 한다. 적을수록 좋다.
거짓 양성은 아래와 같은 이유로 좋지 않다.
테스트에서 발생하는 거짓 양성의 수는 테스트 구성 방식과 직접적인 관련이 있다.
테스트와 테스트 대상 시스템(SUT)의 구현 세부 사항이 많이 결합할수록 허위 경보가 더 많이 생긴다.
거짓 양성이 생길 가능성을 줄이는 방법은 해당 구현 세부 사항에서 테스트를 분리하는 것뿐이다.
[Fact]
public void MessageRenderer_uses_correct_sub_renderers()
{
var sut = new MessageRenderer();
IReadOnlyList<IRenderer> renderers = sut.SubRenderers;
Assert.Eqaul(3, renderers.Count);
Assert.IsAssignableFrom<HeaderRenderer>(renderers[0]);
Assert.IsAssignableFrom<BodyRenderer>(renderers[1]);
Assert.IsAssignableFrom<FooterRenderer>(renderers[2]);
}
위 테스트는 하위 렌더링 클래스가 예상하는 모든 유형이고 올바른 순서로 나타나는지 여부를 확인한다.
처음에는 테스트가 좋아 보이지만, MessageRenderer
의 식별할 수 있는 동작을 실제로 확인하거나, 하위 렌더링 클래스를 재배열하거나 그중 하나를 새 것으로 교체하면 어떻게 될까?
버그로 이어지는가? 반드시 그렇지는 않다.
최종 결과가 바뀌지 않을지라도 테스트를 수행하면 빨간색으로 변할 것이다.
테스트가 SUT가 생성한 결과가 아니라 SUT의 구현 세부 사항과 결합했기 때문이다.
MessageRenderer
클래스의 상당 부분을 리팩터링하면 테스트가 실패한다.
리팩터링 과정은 애플리케이션의 식별할 수 있는 동작에 영향을 주지 않으면서 구현을 변경하는 것이기 때문이다.
이는 테스트가 SUT의 구현 세부사항과 결합되어 있어 리팩터링에 내성이 없는 것이다.
[Fact]
public void Rendering_a_message()
{
var sut = new MessageRenderer();
var message = new Message
{
Header = "h",
Body = "b",
Footer = "f"
}
string html = sut.Render(message);
Assert.Eqaul("<h1>h</h1><b>b</b><i>f</i>", html);
}
위 테스트는 MessageRenderer
를 블랙박스로 취급하고 식별할 수 있는 동작에만 신경 쓴 것이다.
결과적으로 테스트는 리팩터링 내성이 부쩍 늘어났다.
HTML 출력을 똑같이 지키는 한, SUT의 변경 사항은 테스트에 영향을 미치지 않는다.
하지만 이와 같은 테스트도 거짓 양성이 있을 수 있다.
예를 들어 Render()
메서드에 새 매개변수를 도입하면 컴파일 오류가 발생할 수 있다.
기술적으로 이러한 오류도 거짓 양성으로 간주한다.
하지만 이러한 거짓 양성은 해결하기가 쉽다. 좋지 않은 거짓 양성은 컴파일 오류를 내지 않는 것이다.
좋은 단위 테스트의 두 요소(회귀 방지, 리팩터링 내성) 사이에는 본질적인 관계가 있다.
둘 다 정반대의 관점에서도 테스트 스위트의 정확도에 기여한다.
이 두 가지 특성은 시간이 흐르면서 프로젝트에 영향을 다르게 미치는 경향이 있다.
프로젝트가 시작된 직후에는 회귀 방지를 훌륭히 갖추는 것이 중요한 데 반해, 리팩터링 내성은 필요하지 않다.
코드 정확도와 테스트 결과에 대해서는 아래와 같이 네 가지 결과가 있을 수 있다.
거짓 양성과 거짓 음성의 확률은 테스트의 수준을 나타낸다. 즉, 확률이 낮을 수록 테스트가 더 정확하다.
회귀 방지와 리팩터링 내성은 테스트 스위트의 정확도를 극대화하는 것을 목표로 한다. 정확도 지표는 다음 두 가지 요소로 구성된다.
거짓 양성과 거짓 음성을 생각해보는 다른 방법으로 소음 대비 신호 비율 측면에서 볼 수 있다.
테스트 정확도 = 신호(발견된 버그 수) / 소음(허위 경보 발생 수)
프로젝트 초기에는 거짓 양성이 중요하지 않다. 초기에는 리팩터링(코드 정리)을 할 필요가 없기 때문이다.
시간이 흐름에 따라 리팩터링이 필요해지고, 이에 따라 리팩터링 내성도 점점 더 중요해진다.
프로젝트가 길어지고 사이즈가 비대해질수록 거짓 음성과 거짓 양성에 대해 똑같이 주의를 기울여야 한다.
위의 네 가지 특성을 곱하면(수학적으로) 테스트의 가치가 결정된다. ( 어떤 특성이라도 0이 되면 전체가 0이 된다.)
가치가 있으려면 테스트는 네 가지 범주 모두에서 점수를 내야 한다.
물론, 이러한 특성을 정확하게 측정하는 것은 불가능하다. 관련된 코드 분석 도구도 없다.
그러나 네 가지 특성과 관련해서 테스트가 어디쯤 있는지는 비교적 정확하게 평가할 수 있다.
이 평가는 테스트의 가치 추정치를 제시하며, 이 추정치로 테스트 스위트에 테스트를 계속 둘지 여부를 결정할 수 있다.
테스트 코드를 포함한 모든 코드는 책임이다.
최소 필수값에 대해 상당히 높은 임계치를 설정하고 이 것을 충족하는 테스트만 테스트 스위트에 남겨라.
소수의 매우 가치 있는 테스트는 다수의 평범한 테스트보다 프로젝트가 계속 성장하는 데 훨씬 효과적이다.
네 가지 특성에서 최대 점수를 가지는 이상적인 테스트를 만드는 것은 불가능하다.
회귀 방지, 리팩터링 내성, 빠른 피드백은 상호 베타적이기 때문이다.
셋 중 하나를 희생해야 나머지 둘을 최대로 할 수 있다.
엔드 투 엔드 테스트는 많은 코드를 테스트하므로 회귀 방지를 훌륭히 해낸다.
또한, 거짓 양성에 면역이 돼 리팩터링 내성도 우수하다.
하지만 느린 속도로 인해 빠른 피드백을 받기가 어려운 단점이 있다.
이는 엔드 투 엔드 테스트만으로 코드 베이스를 다루기가 불가능한 이유이기도 하다.
public class User
{
public string Name { get; set; }
}
[Fact]
public void Test()
{
var sut = new User();
sut.Name = "John Smith";
Assert.Eqaul("John Smith", sut.Name);
}
간단한 테스트는 매우 빠르게 실행되고 거짓 양성이 생길 가능성이 상당히 낮기 때문에 리팩터링 내성도 우수하다.
그러나 기반 코드에 실수할 여지가 많지 않기 때문에 간단한 테스트는 회귀를 나타내지 않을 것이다.
실행이 빠르고 회귀를 잡을 가능성이 높지만 거짓 양성이 많은 테스트를 깨지기 쉬운 테스트(brittle test)라고 한다.
[Fact]
public void GetByid_executes_correct_SQL_code()
{
var sut = new UserRepository();
User user = sut.GetById(5);
Assert.Eqaul(
"SELECT * FROM dbo.[USER] WHERE UserID = 5", sut.LastExecutedSqlStatement);
)
}
위의 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
기능은 계속 작동하지만 테스트가 SUT의 구현 세부 사항에 결합되어 있기 때문에 리팩터링 내성이 전혀 없다.
좋은 단위 테스트의 처음 세가지 특성(회귀 방지, 리팩터링 내성, 빠른 피드백)은 상호 베타적이다.
두 가지를 극대화하는 테스트를 만들기는 매우 쉽지만, 나머지 특성 한 가지를 희생해야 한다.
리팩터링 내성을 포기할 수 없는 이유는 테스트가 이 특성을 갖고 있는지의 여부는 대부분 이진 선택이기 때문이다. 즉, 테스트에 리팩터링 내성이 있거나 없거나 둘 중 하나다.
따라서 리팩터링 내성을 조금만 인정할 수는 없다.
테스트 피라미드 상단의 테스트는 회귀 방지에 유리한 반면, 하단은 실행 속도를 강조한다.
또한 어느 계층도 리팩터링 내성을 포기하지 않는다.
테스트 유형 간의 정확한 비율은 팀과 프로젝트마다 다를 것이다. 그러나 일반적으로 피라미드 형태를 유지해야 한다.
엔드 투 엔드 테스트가 가장 적고, 단위 테스트가 가장 많으며, 통합 테스트는 중간 어딘가에 있어야 한다.
엔드 투 엔드 테스트는 피드백 점수가 매우 낮고, 유지 보수성이 결여되어 있다.
따라서 가장 중요한 기능에 적용하는 것이 좋다.
테스트 피라미드에는 예외가 있다. 예를 들어 비지니스 규칙이나 복잡도가 거의 없는 기본적인 CRUD 작업이라면, 단위 테스트와 통합 테스트의 수가 같고 엔드 투 엔드 테스트가 없는 직사각형처럼 보일 수 있다.
이 두 가지 방법 모두 아래와 같은 장단점이 있다.
회귀 방지 | 리팩터링 내성 | |
---|---|---|
화이트박스 테스트 | 좋음 | 나쁨 |
블랙박스 테스트 | 나쁨 | 좋음 |
리팩터링 내성은 타협할 수 없기 때문에, 블랙박스 테스트를 기본으로 선택하라.
테스트를 작성할 때는 블랙박스 테스트가 바람직하지만, 테스트를 분석할 때는 화이트박스 방법을 사용할 수 있다.
코드 커버리지 도구를 사용해서 어떤 코드 분기를 실행하지 않았는지 확인한 다음 코드 내부 구조에 대해 전혀 모르는 것처럼 테스트하라.
이와 같이 조화롭게 사용하는 것이 가장 효과적이다.