Search

단위 테스트 2부 - 4장 좋은 단위 테스트의 4대 요소

단위 테스트 - 생산성과 품질을 위한 단위 테스트 원칙과 패턴 책을 바탕으로 정리를 진행했습니다. 하단 북마크에 책 구매 링크를 연결해두었습니다.

 가치 있는 테스트 식별 방법

좋은 단위 테스트는 4가지 특성을 지니고 있습니다.
회귀 방지
리팩토링 내성
빠른 피드백
유지 보수성

 회귀 방지

회귀 방지에서 회귀는 소프트웨어 버그를 뜻합니다. 즉, 좋은 단위 테스트는 버그를 방지하는 특성을 가지고 있습니다. 버그를 그대로 방치할 순 있지만, 코드의 양이 증가할 수록 더 많은 버그에 노출되게 됩니다.
따라서, 버그를 방지하도록 개발하는 것이 중요합니다.
현재 작성한 테스트가 버그를 잘 방지하는지 알 수 있는 지표가 3가지 있습니다.
첫번째, 테스트 시에 실행되는 코드의 양입니다.
일반적으로 테스트에서 실행되는 코드가 많을 수록 더 많은 버그를 잡을 수 있습니다. 하지만, 작업된 테스트가 유효한 결과를 내는 테스트가 아니라면 무의미한 테스트가 작성될 수 있습니다.
두번째, 복잡도가 높은 코드에 대한 테스트가 작성되었는지에 대한 여부입니다.
단순한 코드는 복잡도가 높은 코드에 비해서 테스트 가치가 거의 없습니다. 실수할 여지가 적고, 버그가 발생할 확률이 낮기 때문에 해당 테스트를 작성하지 않아도 버그 방지에 큰 영향을 미치지 않습니다.
세번째, 도메인과 관련된 로직에 대한 테스트가 작성되었는지에 대한 여부입니다.
간단한 로직에 대한 테스트보다는 복잡한 비즈니스 로직에 대한 테스트를 작성하는 것이 버그 발생 시에 큰 피해를 줄일 수 있습니다. 비즈니스에 중요한 기능에서 발생한 버그가 가장 큰 피해를 입히기 때문이죠.
버그를 잘 방지할 수 있도록 프로젝트 내에 테스트를 작성했다고 하더라도, 우리가 작성하지 않은 부분에서 버그가 발생할 수도 있습니다. 따라서, 최상의 보호를 위해서는 우리가 작성한 코드의 라이브러리, 프레임워크, 외부 시스템을 테스트 범주에 포함시켜 테스트가 최대한 많은 코드를 실행할 수 있도록 해야 합니다.

 리팩토링 내성

리팩토링 내성은 쉽게 이름 그대로 이해하면 됩니다. 테스트가 리팩토링에 내성이 있느냐, 즉 테스트 실패를 내지 않으면서 리팩토링을 할 수 있는가를 나타내는 요소입니다.
리팩토링으로 인해 테스트 실패가 발생하는 것을 거짓 양성(false positive)이라고 부릅니다. 거짓 양성은 허위 경보로, 실제로 테스트 자체가 실패한 것은 아니나 내용이 변경되게 되면서 발생하는 테스트 실패입니다.
거짓 양성이 다수 발생하게 되면 테스트에 대한 신뢰가 낮아지고, 실패 발생 시에 이를 대응하고자 하는 의지가 떨어져 실패가 발생하는 테스트 스위트를 주석 처리하거나 제거하는 문제가 발생합니다. 또한, 테스트 실패를 줄이기 위해서 최소한으로 리팩토링을 하는 태도가 생기게 됩니다.
반대로 거짓 양성이 없는 경우에는 어떨까요.
거짓 양성이 발생하지 않기 때문에, 실제 버그가 발생할 경우 조기 경고가 가능해져 빠르게 문제를 해결할 수 있게 됩니다. 또한, 리팩토링으로 인해서 버그가 발생하지 않는다는게 확실하기 때문에 주기적으로 리팩토링하고 새로운 기능을 추가할 수 있게 됩니다.
어떤 테스트를 작성했을 때, 거짓 양성이 잘 발생하게 될까요?
테스트가 테스트 대상 시스템의 세부 구현 사항을 많이 아는 경우에 거짓 양성이 잘 발생하게 됩니다. 더 명확하게 설명하기 위해서 예시를 들어볼게요.
HTML 코드를 생성해주는 HTMLRender 클래스가 있습니다.
struct Message { let header: String let body: String let footer: String } protocol Render { func render(message: Message) -> String } class HTMLRender: Render { var subRenders: [Render] = [ HeaderRender(), BodyRender(), FooterRender() ] func render(message: Message) -> String { return subRenders.map { $0.render(message: message) }.joined() } } class HeaderRender: Render { func render(message: Message) -> String { return "<h1>\(message.header)</h1>" } } ...
Swift
복사
해당 클래스에서 render 메서드를 통해서 Header, Body, Footer를 잘 생성했는지 확인하는 테스트를 작성하려고 합니다.
struct HTMLRenderTests { @Test func test_render_proper_html() async throws { // given let sut = HTMLRender() // when let renders = sut.subRenders // then #expect(renders.count == 3) #expect(renders[0] is HeaderRender) #expect(renders[1] is BodyRender) #expect(renders[2] is FooterRender) } }
Swift
복사
위의 테스트는 SUT에 3개의 Render가 생성되었으며, 각 Render가 순서대로 타입에 맞게 들어왔는지 확인하고 있습니다. 작성된 코드에서 틀어진 점은 없기에 테스트는 성공할 겁니다.
하지만, 사용자에게 의미있는 결과를 주는 테스트는 아닙니다.
만약, HeaderRender가 아닌 리팩토링을 통한 새로운 Render 타입이 HeaderRender 대신 들어가게 된다면 테스트가 실패하게 될 겁니다. 실제로 동작에 문제가 있진 않지만 말이죠.
이러한 거짓 양성을 생성하는 테스트를 만들지 않기 위해선, 최종 결과를 테스트하는 것을 목표로 해야 합니다.
위의 로직을 예로 든다면 Render의 타입이 아닌 Render를 통해서 도출되는 HTML String를 테스트해보는 일이겠죠. 아래 테스트처럼 말입니다.
struct HTMLRenderTests { @Test func test_render_proper_html() async throws { // given let sut = HTMLRender() let header = "Hello World" let body = "Body Part" let footer = "BYE World" // when let renders = sut.render(message: Message(header: header, body: body, footer: footer)) // then #expect(renders == "<h1>\(header)</h1><b>\(body)</b><p>\(footer)</p>") } }
Swift
복사
최종 결과를 목표로 하는 테스트는 결과가 변경되지 않기에 SUT 세부 사항이 변경된다고 하더라도 영향을 받지 않습니다. 개발자는 해당 테스트의 실패가 거짓 양성이 아닌 온전한 실패라는 것을 믿을 수 있습니다.
위의 두 가지 요소인 회귀 방지와 리팩토링 내성을 사용해서 테스트 유형을 넓은 관점에서 봐봅시다.
테스트 유형은 테스트 결과와 기능에 따라서 아래처럼 4가지의 형태로 나타날 수 있습니다.
그 중에서 (기능이 작동하는 상황, 테스트 통과) / (기능이 고장난 상황, 테스트 실패) 하는 경우는 테스트가 올바르게 동작한 상황이라는 걸 알 수 있습니다. 반대로 (기능이 작동하는 상황, 테스트 실패) / (기능이 고장난 상황, 테스트 통과) 하는 경우는 무언가 잘못된 상황이겠죠.
기능이 작동하지만 테스트가 실패하는 상황을 거짓 음성이라고 합니다. 거짓 음성은 회귀 방지를 통해서 잡아낼 수 있습니다. 기능이 고장났지만 실제로는 동작하는 상황은 거짓 양성이라고 합니다. 이는 리팩토링 내성으로 방지할 수 있죠. 테스트의 정확도를 측정할 때는 거짓 음성과 거짓 양성을 사용해서 구하게 됩니다.
거짓 음성을 더 많이 잡아내고, 거짓 양성이 덜 발생하는 테스트일 수록 더 정확한 테스트라고 볼 수 있기 때문이죠.
명확한 식으로 나타내면 아래 수식으로 나타낼 수 있습니다.
테스트정확도=발견된버그수허위경보발생수테스트 정확도 = \frac{발견된버그수}{허위 경보 발생 수}
즉, 테스트를 통해서 잡아낸 버그의 수가 많고 잘못된 테스트 실패가 적을 수록 정확도가 높아지게 됩니다.
거짓 음성과 거짓 양성 모두 잘 잡아야 높은 정확도를 가질 수 있다는 뜻이죠.
그렇다면, 프로젝트 시작부터 두 가지 모두를 염두해두고 테스트를 작성해야 하는 걸까요?
회귀 방지는 프로젝트의 시작부터 중요한 요소입니다. 버그가 남아 있는 프로젝트를 만들면 안되니깐요.
반대로, 리팩토링 내성은 시작부터 중요하지 않습니다. 자신이 작성한 코드가 따끈따끈하게 머릿속에 남아 있고 리팩토링을 할 시기가 아니기 때문이죠.
하지만, 프로젝트가 커지기 시작하면 거짓 양성이 큰 영향을 미치게 됩니다.
프로젝트가 커지면서 정기적으로 리팩토링을 진행하게 되는 상황이 생기고, 예전에 작성된 코드에 새로운 기능을 붙이게 되면서 새로운 기능을 추가하는데 드는 비용이 커지게 됩니다. 그에 따라서, 이전에 작동했던 코드가 실제로는 동작을 하지만 테스트 실패를 발생시키는 경우도 생기게 됩니다.
점점 더 리팩토링 내성에 대한 관심이 필요해지는 겁니다.
리팩토링 내성을 고려하지 않고 코드를 작성한다면 언젠가 테스트에 대한 신뢰를 잃어버릴 수 있습니다.
실제로는 버그가 있지만 거짓 양성에 익숙해져 실제 문제도 허위 경보로 인식해버리는거죠.
우리가 중대형 프로젝트를 이어나가기 위해선 거짓 음성, 거짓 양성 모두에 주의를 기울여야 합니다. 두 가지 요소를 챙기는 경우에 가치 있고 정확한 테스트 스위트를 구축할 수 있고, 프로젝트를 성장시킬 수 있습니다.

 빠른 피드백

빠른 피드백도 이름 그대로, 테스트 속도가 빠른 것을 나타냅니다.
테스트 속도가 빠른게 왜 중요할까요?
테스트가 오래 걸리는 경우엔 테스트를 자주 실행할 수 없습니다. 한 번 테스트를 실행하면 많은 시간이 소요되기 때문에 자주 돌릴 수 없죠. 테스트를 자주 실행하지 않게 되면 버그를 찾아내는 주기가 길어져, 버그 해결에 드는 시간이 길어지고 잘못된 방향으로 시간을 더 많이 낭비하게 됩니다.
반면, 테스트 속도가 빠르면 더 많은 테스트를 더 자주 실행할 수 있게 됩니다. 많은 테스트가 빠르게 실행되면 빠르게 버그를 찾아낼 수 있기 때문에 버그 수정에 드는 비용을 줄일 수 있게 됩니다.

 유지보수성

유지보수성은 테스트가 유지보수하기에 좋은가를 나타내며 두 가지 주요 요소로 구성됩니다.
먼저, 테스트가 얼마나 이해하기 어려운지에 대한 지표입니다. 코드 라인이 적고 가독성이 좋으며 의도가 명확한 테스트라면 유지보수하기에 좋은 테스트입니다.
그리고 테스트가 얼마나 실행하기 어려운가를 봅니다. 예를 들어서 외부에 종속성으로 작동하는 테스트가 있다면 의존성을 상시 운영하기 위해서 시간을 들여야 합니다. 이러한 테스트는 유지보수하기에 좋지 못합니다.

 이상적인 테스트

위의 4가지 특성을 모두 곱한 수가 테스트의 가치를 나타나게 되는데, 그 값이 1이 되는 테스트가 가장 이상적이라고 봅니다.
하지만, 이는 불가능하다고 볼 수 있습니다. 회귀 방지, 리팩토링 내성, 빠른 피드백은 상호 배타적 관계를 가지고 있기 때문이죠.
그렇다고 해서, 하나를 버리고 나머지를 1로 만들어서 좋은 점수를 낼 순 없습니다. 하나라도 0점이 된다면 0점짜리 테스트가 되기 때문에 가치 없는 테스트가 되고 맙니다.
따라서, 우리는 목적에 따라 이 중에 무엇을 보호하고 무엇을 허용할 지 결정해야 합니다.
리팩토링 내성은 아쉽게도 선택지가 2개 뿐인 요소입니다. 테스트가 리팩토링 내성을 애매하게 가질 순 없습니다. 가지거나 가지지 않거나 둘 중 하나만 골라야 하죠.
그렇기 때문에, 우리는 회귀 방지와 빠른 피드백 사이에서 선택을 조절할 필요가 있습니다.
더 이해하기 쉽도록 예시를 봅시다.
엔드 투 엔드 테스트
엔드 투 엔드 테스트는 최종 사용자 관점에서 시스템을 보기 때문에, 모든 시스템 구성 요소를 거치게 됩니다.
회귀 방지 : 엔드 투 엔드 테스트는 많은 코드를 테스트할 수 있기 때문에 회귀 방지를 훌륭하게 해냅니다.
리팩토링 내성 : 거짓 양성에 면역이 되어 있어 리팩토링 내성이 좋습니다.
빠른 피드백 : 테스트에서 의존하는 시스템이 많기 때문에 테스트 속도가 현저하게 떨어집니다.
간단한 테스트
빠른 피드백 : 간단하게 작성된 테스트는 테스트를 진행하는 속도가 매우 빠릅니다.
리팩토링 내성 : 간단하기 때문에 거짓 양성이 생길 가능성이 적어서 리팩토링 내성이 좋습니다.
회귀 방지 : 간단한 테스트는 항상 테스트를 통과하고 검증이 무의미합니다. 나타내는 버그가 없기 때문입니다.
깨지기 쉬운 테스트
아까 예시로 들었던 HTMLRender는 리팩토링 내성이 없는 테스트입니다.
리팩토링 내성 : 테스트가 세부 사항에 접근하고 있기 때문에 리팩토링을 견디지 못하고, 허위 경보를 나타내게 됩니다.
결론적으로 이상적인 테스트를 만들기 위해서는 아래 두 가지를 기억하면 됩니다.
리팩토링 내성은 포기할 수 없다.
테스트가 버그를 잘 찾아내는지, 얼마나 빠른지 사이의 선택으로 결정되어야 한다.

 대중적인 테스트 자동화 개념

 테스트 피라미드

테스트 피라미드는 테스트 스위트에서 테스트 유형 간의 일정한 비율을 피라미드 형태로 표현합니다.
층에서 넓은 너비를 가진 테스트일수록 보편적인 테스트임을 나타냅니다. 너비가 넓을수록 해당 테스트의 양이 많아지게 됩니다.
층의 높이에 따라서 빠른 피드백과 회귀 방지의 비율이 달라지게 됩니다. 위로 올라갈 수록 회귀 방지의 비율이 높고, 아래로 내려갈 수록 빠른 피드백의 비율이 높아집니다.
따라서, 가장 높은 층에 있는 엔드 투 엔드 테스트는 회귀 방지 요소를 높게 가지지만 테스트 속도가 느리고, 유지 보수성이 결여되어 있는 경우가 많아 적은 양의 테스트가 진행되어야 합니다. 그렇기 때문에 가장 중요한 기능에서만 엔드 투 엔드 테스트를 진행해야 합니다.
하지만, 피라미드 형태에 예외가 생기는 경우도 존재합니다.
예를 들어서, 비즈니스 규칙이나 기타 복잡도가 거의 없는 기본적인 CRUD 작업만 하는 애플리케이션이라면 알고리즘 및 비즈니스 복잡도가 없기 때문에 테스트가 거의 필요하지 않습니다.
결국, 단위 테스트와 통합 테스트의 크기가 같고 엔드 투 엔드 테스트는 존재하지 않는 직사각형 모양의 테스트 피라미드가 완성되게 됩니다.
다음으로 단일 외부 의존성을 연결한 API입니다.
해당 테스트는 외부 의존성을 연결하여 테스트를 해야하기 때문에 엔드 투 엔드 테스트가 많을수록 좋습니다. 또한, 별도의 UI가 없어서 엔드 투 엔드 테스트의 실행 속도가 빠르고, 단일 외부 의존성 하나만으로 작동하기 때문에 유지비가 크지 않습니다.

 블랙박스 테스트 & 화이트박스 테스트

블랙박스 테스트는 무엇(What)을 해야하는지 검사하는 테스트요구사항이나 명세를 통해서 테스트를 진행하기 때문에 내부 구조를 몰라도 테스트를 진행할 수 있습니다.
반대로, 화이트박스 테스트는 요구사항이나 명세가 아닌 소스 코드에서 파생되어 앱 내부 작업을 검증하는 테스트 입니다. 앱 내부를 알기 때문에 테스트가 철저하게 진행되어 많은 오류를 발견할 수 있다는 장점이 있지만, 세부 사항과 결합되어 있기 때문에 리팩토링 내성이 부족합니다.
요소와 관련된 부분에서 설명했듯 리팩토링 내성은 반드시 있어야 합니다.
그렇기 때문에, 화이트박스 테스트가 아닌 블랙박스 테스트를 기본으로 선택해야 합니다. 블랙박스 테스트로 테스트를 작성해야만 비즈니스 요구 사항을 기반으로 리팩토링 내성을 가진 테스트를 작성할 수 있습니다.
만약, 테스트를 통해서 비즈니스 요구 사항으로 거슬러 올라갈 수 없다면 이는 깨지기 쉬운 테스트임으로 테스트를 재구성하거나 삭제할 필요가 있습니다.
테스트를 작성할 때는 블랙박스 테스트를 사용해서 코드 내부 구조에 대해 전혀 모르는 것처럼 테스트해야 합니다.