이 글은 블라디미르 코리코프 - 단위 테스트 (opens in a new tab) 초반을 요약한 글입니다.
읽기 전에
이 요약본은 단위 테스트 저자의 의도를 최대한 반영하려고 노력했습니다. 그러나 TypeScript 환경에서 Jest를 사용한 테스트에 대한 개인적인 의견이 일부 포함되어 있습니다.
따라서 단위 테스트에 대한 보다 정확한 본질적인 내용은 해당 도서를 참고하시기 바랍니다.
단위 테스트는 단순히 테스트를 작성하는 것보다 더 큰 범주입니다. 단위 테스트에 시간을 투자할 때는 항상 최대한 이득을 얻도록 노력해야 하며, 테스트에 드는 노력을 가능한 줄이고 그에 따른 이득을 얻도록 노력해야합니다.
단위 테스트의 목표
단위테스트를 적용해야 하는지는 더 이상 논쟁이 되지 않습니다. 그냥 쓰고 버리는 프로젝트가 아니면, 단위테스트는 늘 적용해야 합니다.
흔히 단위 테스트가 더 나은 설계로 이어진다고 말합니다. 이는 사실입니다. 코드 베이스에 대해 단위 테스트 작성이 필요하면 일반적으로 더 나은 설계로 이어집니다. 하지만 이는 주 목표가 아닌 부수효과일 뿐입니다.
단위 테스트와 코드 설계의 관계
코드 조각을 단위테스트 하는 것은 훌륭한 리트머스 시험이지만 한 방향으로만 작동한다. 이는 괜찮은 부정 지표다. 즉, 비교적 높은 정확도로 저품질 코드를 가려낸다. 코드를 단위 테스트 하기 어렵다면 코드 개선이 반드시 필요하다는 것을 의미한다. 보통 강결합에서 저품질이 발생한다.
안타깝게도 코드 조각을 단위 테스트 할 수 있다는 것은 좋지 않은 긍정지표다. 코드 베이스를 쉽게 단위테스트 할 수 있다고 해도, 반드시 품질이 좋은 것을 의미하지는 않는다.
그럼 단위 테스트의 목표는 무엇일까요? 소프트웨어 프로젝트의 지속 가능한 성장을 가능하게 하는 것입니다.
단위 테스트란?
단위 테스트는 개별적인 코드 단위(하나의 기능을 하는 코드)가 의도한 대로 작동하는지 확인하는 과정입니다. 단위 테스트에 대한 해석의 차이가 생겼고, 단위 테스트에 접근하는 방법이 두 가지 견해로 나뉘었습니다.
두 견해는 각각 고전파와 런던파로 알려져있습니다.
- 고전파 : 모든 사람이 단위테스트와 테스트 주도 개발에 원론적으로 접근하는 방식
- 런던파 : 런던의 프로그래밍 커뮤니티에서 파생
단위 테스트의 정의
단위 테스트에는 많은 정의가 있습니다. 중요하지 않은 것을 제외하면 일반적으로 다음과 같습니다.
- 작은 코드 조각을 검증한다.
- 빠르게 수행한다.
- 격리된 방식으로 자동화한다.
여기서 처음 두 속성은 논란의 여지가 없습니다. 마지막 격리 문제는 단위테스트의 고전파와 런던파를 구분하는 근원적인 차이에 속합니다.
단위 테스트의 고전파와 런던파
고전적 접근법은
디트로이트(Detroit)
라고도 하며, 때로는 단위 테스트에 대한 고전 주의(classicist)적 접근법이라고도 한다. 아마도 고전파의 입장에서 가장 고전적인 책은 켄트 백(Kent Beck)이 지은 테스트 주도 개발일 것이다.런던 스타일은 때때로
목 추종자(mockist)
로 표현된다. 목 추종자라는 용어가 널리 퍼져 있지만, 런던 스타일을 따르는 사람들은 보통 그렇게 부르는 것을 좋아하지 않으므로 이 책에서는 런던 스타일이라고 소개한다. 이 방식의 가장 유명한 지지자는 스티브 피리먼(Steve Freeman)과 냇 프라이스(Nat Pryce)다. 이. 주제에 대한 좋은 자료로 이들이 저술한 Growing Object-Oriented Software, Guided by Tests를 추천한다.
격리 문제에 대한 두 문파에 접근
런던파
런던파는 테스트 대상 시스템을 협력자(collaborator)에게서 격리하는 것을 코드 조각을 격리된 방식으로 검증한다고 말합니다. 즉, 하나의 클래스가 다른 클래스 또는 여러클레스에 의존하면, 이 모든 것을 테스트 대역(Test Double)으로 대체해야 합니다.
테스트 대역(Test Double)
테스트 대역은 릴리스 목적의 대응으로 보일 수 있지만, 실제로는 복잡성을 줄이고 테스트를 용이하게 하는 단순화된 버전이다. 제라드 메스자로스(Gerard Meszaros)가 그의 저서 xUnit 테스트 패턴에서 이 용어를 처음 소개했다. 이름 자체는 영화 산업의 스턴트 대역 이라는 개념에서 유래됐다.
이 방법은 다음과 같은 이점이 있습니다.
- 테스트가 실패하면 코드베이스의 어느 부분이 고장 났는지 확실히 알 수 있다. 테스트 더블을 제외한 테스트 스위트만 관리하면 된다.
- 객체 그래프를 분할할 수 있다. 테스트 대역을 사용하면 객체 그래프를 다시 만들지 않아도 된다. 또한 의존성의 의존성을 다룰 필요가 없다. 그래프를 효과적으로 분해해 단위 테스트에서 준비를 크게 줄일 수 있다.
- 전체 단위 테스트 스위트를 간단한 구조로 할 수 있다. 프로젝트 전반적으로 한 번에 한 클래스만 테스트하라는 지침을 도입하면, 더이상 코드베이스를 테스트하는 방법을 고민할 필요가 없다. 클래스가 존재하면, 클래스에 해당하는 단위 테스트 클래스를 만들면 된다.
테스트 스위트(Test Suite)
테스트 묶음(Test Suite)은 여러 테스트 케이스, 테스트 묶음, 또는 둘 다의 집합이다. 이는 서로 같이 실행되어야 할 테스트를 종합하는 데 사용된다.
고전파
고전적인 방법에서 코드를 꼭 격리하는 방식으로 테스트를 해야하는 것은 아닙니다. 대신 단위 테스트는 서로 격리해서 실행해야 합니다. 이렇게 하면 테스트를 어떤 순서(병렬, 순차)로든 가장 적합한 방식으로 실행할 수 있으며 결과에 영향을 미치지 않습니다.
공유 의존성, 비공개 의존성, 프로세스 외부 의존성
공유 의존성(shared dependency)은 테스트 간에 공유되고 서로 결과에 영향을 미칠 수 있는 수단을 제공하는 의존성이다. 공유 의존성의 전형적인 예는 정적 가변 필드(static mutable field)다. 데이터베이스도 공유 의존성의 예가 될 수 있다.
비공개 의존성(private dependency)은 공유하지 않는 의존성이다.
프로세스 외부 의존성(out-of-process dependency)은 애플리케이션 실행 프로세스 외부에서 실행 되는 의존성이며, 아직 메모리에 없는 데이터에 대한 프록시(proxy)다. 프로세스 외부 의존성은 대부분 공유 의존성에 해당하지만 모두 그런 것은 아니다.
예를 들어 데이터베이스는 프로세스 외부이면서 공유 의존성이다. 그러나 각 테스트 실행 전에 도커 컨테이너로 데이터베이스를 시작하면 테스트가 더이상 동일한 인스턴스로 작동하지 않기 때문에 프로세스 외부이면서 공유하지 않는 의존성이 된다. 이러한 데이터베이스 환경에서는 테스트가 데이터를 변경할 수 없으므로 결과에 서로 영향을 미칠 수 없다.
격리 문제에 대한 이러한 견해는 Mock과 기타 Test Double 사용에 대한 훨씬 더 평범한 견해를 수반합니다. 테스트 대역을 사용할 수 있지만, 보통 테스트 간에 공유 상태를 일으키는 의존성에 대해서만 사용합니다.
단위 테스트의 런던파와 고전파
런던파와 고전파의 분리 원인은 격리특성에 있습니다. 런던파는 SUT에서 협력자를 격리하는 것으로 보는 반면, 고전파는 단위테스트끼리 격리하는 것으로 봅니다.
종합해보면 두 분파는 세가지 주요 주제에 대해 의견 차이를 보입니다.
격리주체 | 단위의 크기 | 테스트 대역 사용 대상 | |
---|---|---|---|
런던파 | 단위 | 단일 클래스 | 불변 의존성 외 모든 의존성 |
고전파 | 단위 테스트 | 단일 클래스 또는 클래스 세트 | 공유 의존성 |
SUT (System Under Test, Software Under Test)
테스트 대상이 되는 시스템이나 소프트웨어를 의미한다. 즉, 현재 테스트를 수행하고 있는 프로그램, 모듈, 컴포넌트 등을 지칭하는 용어.
저자는 개인적으로 단위 테스트 고전파를 선호한다고 언급합니다. 이는 고품질의 테스트를 만들고 단위 테스트의 궁극적 목표인 프로젝트 지속 가능한 성장을 달성하는 데 더 적합하기 때문입니다. 그 이유는 취약성에 있습니다. 목을 사용하는 테스트는 고전적인 테스트보다 불안정한 경향이 있기 때문입니다.
런던파의 주요 장점을 하나씩 살펴 보겠습니다:
- 입자성이 좋다. 테스트가 세밀해서 한번에 한 클래스만 확인한다.
- 서로 연결된 클래스의 그래프가 커져도 테스트하기 쉽다. 모든 협력자는 테스트 대역으로 대체되기 때문에 테스트 작성 시 걱정할 필요가 없다.
- 테스트가 실패하면 어떤 기능이 실패했는지 확실히 알 수 있다. 클래스의 협력자가 없으면 테스트 대상 클래스 외에 다른 것을 의심할 여지가 없다. 물론 테스트 대상 시스템이 값 객체를 사용하는 상황이 있을 수 있으며, 이 값 객체의 변경으로 인해 테스트가 실패하게 된다. 그러나 테스트 내 다른 의존성을 모두 제거했기 때문에 이러한 경우는 흔하지 않다.
런던파는 클래스를 단위로 간주합니다. OOP 경력을 가진 개발자들은 보통 클래스를 모든 코드베이스의 기초로 간주합니다. 이로 인해 자연스럽게 클래스를 테스트에서 검증할 원자 단위로 취급
합니다. 이런 경향은 오해의 소지를 불러올 수 있습니다.
좋은 코드 입자성을 목표로 하는 것은 도움이 되지 않습니다. 테스트가 단일 동작 단위를 검증하는 한 좋은 테스트입니다. 이보다 적은 것을 목표로 삼는다면 사실 단위 테스트를 훼손하는 결과를 가져올 수 있습니다. 이 테스트가 무엇을 검증하는지 정확히 이해하기가 더 어려워지기 때문입니다. 테스트는 해결하는 데 도움이 되는 문제에 대한 이야기를 들려줘야 하며, 이 이야기는 프로그래머가 아닌 일반 사람들에게 응집도가 높고 의미가 있어야 합니다.
단위 테스트의 단위
테스트는 코드의 단위를 검증해서는 안 된다. 오히려 동작의 단위, 즉 문제 영역에 의미가 있는 것, 이상적으로는 비즈니스 담당자가 유용하다고 인식할 수 있는 것을 검증해야 한다. 동작 단위를 구현하는 데 클래스가 얼마나 필요할지는 상관 없다. 단위는 여러 클래스에 걸쳐 있거나 한 클래스에만 있을 수 있고, 심지어 아주 작은 메서드가 될 수도 있다.
예를 들어 응집도가 높은 이야기의 예시를 들어보겠습니다.
우리집 강아지를 부르면, 바로 나에게 온다.
이를 다음과 비교해 보겠습니다.
우리집 강아지를 부르면 먼저 왼쪽 앞다리를 움직이고, 이어서 오른쪽 앞다리를 움직이고, 머리를 돌리고, 꼬리를 흔들기 시작한다.
두 번째 이야기는 훨씬 직관적으로 이해하기 어렵습니다. 실제 동작(개가 주인에게 오는 것) 대신 개별 클래스(다리, 머리, 꼬리)를 목표로 할때 테스트가 이렇게 보이기 시작합니다.
또한 모킹을 사용해서 객체 그래프를 대체하는 것은 사실 잘못된 문제에 초점을 맞추는 작업입니다. 상호 연결된 클래스의 크고 복잡한 그래프를 테스트할 방법을 찾는 대신, 먼저 이러한 클래스 그래프를 갖지 않는 데 집중해야 합니다. 대개 클래스 그래프가 커진 것은 설계 문제의 결과입니다.
코드 조각을 단위 테스트하는 능력은 좋은 부정 지표입니다. 즉, 비교적 높은 정확도로 저품질을 예측할 수 있습니다. 클래스를 단위 테스트 하려면 테스트 준비 단계를 적정선을 넘게 늘려야 해서 이는 틀림없이 문제 징후가 있습니다. 목을 사용하는 것은 이 문제를 감추기만 할 뿐, 원인을 해결하지 못합니다.
단위 테스트 구조
단위 테스트를 구성하는 방법
AAA(혹은 3A)패턴은 각 테스트를 준비, 실행, 검증이라는 세 부분으로 나눌 수 있습니다.
const cal = (a: number, b: number) => a + b;
test('두 숫자를 더한다.', () => {
//준비
const first = 1;
const second = 2;
//실행
const result = cal(first, second);
//단언
expect(result).toBe(3);
});
AAA는 스위트 내 모든 테스트가 단순하고 균일한 구조를 갖는 데 도움을 줍니다. 이러한 일관성이 이패턴의 가장 큰 장점중 하나입니다. 일단 익숙해지면 모든 테스트를 쉽게 읽을 수 있고 이해할 수 있습니다. 결국 전체 테스트 스위트의 유지 보수 비용이 줄어들게 됩니다.
구조는 다음과 같습니다:
- 준비 구절에서는 SUT와 해당 의존성을 원하는 상태로 만든다.
- 실행 구절에서는 SUT에서 메서드를 호출하고 준비된 의존성을 전달하며(출력이 있으면) 출력 값을 캡처한다.
- 검증 구절에서는 결과를 검증한다. 결과는 반환 값이나 SUT와 협력자의 최종 상태, SUT가 협력자에 호출한 메서드 등으로 표시될 수 있다.
Given-When-Then
AAA와 유사한 패턴이다. 이 패턴도 테스트를 세 부분으로 나눈다.
- Given : 준비 구절
- When : 실행 구절
- Then : 검증 구절
테스트 구성 측면에서 두 가지 패턴 사이에 차이는 없다. 유일한 차이점은 프로그래머가 아닌 사람에게는 GWT 패턴이 더 읽기 쉽다는 것이다,
그러므로 비기술자들과 공유하는 테스트에 더 적합하다.
단위 테스트 안티 패턴
- 여러개의 준비, 실행, 검증 구절 피하기. 이런 구조는 피하는 것이 좋습니다. 실행이 하나면 테스트가 단위테스트 범주에 있게끔 보장하고, 간단하고, 빠르며, 이해하기 쉽습니다. 일련의 실행과 검증이 포함된 테스트를 보면 리팩터링해야 합니다. 각 동작을 고유의 테스트로 도출하도록 테스트를 만들어야 합니다.
- 테스트 내 if문 피하기. 분기가 있는 단위테스트도 안티패턴입니다. 이는 테스트가 한 번에 너무 많은 것을 검증한다는 표시입니다. 그러므로 이러한 테스트는 반드시 여러 테스트로 나눠야 합니다. 이는 통합 테스트에서도 예외는 없습니다.
테스트 간 테스트 픽스처 재사용
테스트 픽스처(Test Fixture)
테스트 픽스처는 테스트 실행 대상 객체다. 이 객체는 정규 의존성, 즉 SUT로 전달되는 인수다. 데이터베이스에 잇는 데이터나 하드 디스크의 파일일 수도 있다. 이러한 객체는 각 테스트 실행 전에 알려진 고정 상태로 유지하기 때문에 동일한 결과를 생성한다. 따라서 픽스처라는 단어가 나왔다.
준비 구절에서 코드를 재사용하는 것이 테스트를 줄이면서 단순화 하기 좋은 방법입니다. 재사용하는 방법에는 두 가지가 있는데 그 중 하나만 유용합니다. 다른 하나는 유지비를 증가시킵니다.
테스트 픽스처를 재사용하는 첫 번째(올바르지 않은)방법은 다음과 같이 테스트 생성자에서 픽스처를 초기화하는 것입니다.
let calculator: Calculator;
beforeEach(() => {
calculator = new Calculator();
});
이 방법으로 테스트 코드의 양을 크게 줄일 수 있으며, 테스트에서 테스트 픽스처 구성을 전부 또는 대부분 제거할 수 있습니다. 그러나 이 기법은 두 가지 중요한 단점이 있습니다:
- 테스트 간 결합도가 높아진다.
- 테스트 가독성이 떨어진다.
위와 같은 방법으로 테스트 준비 구절을 재사용하면, 모든 테스트가 서로 결합됩니다. 즉, 테스트의 준비 로직을 수정하면 클래스의 모든 테스트에 영향을 미칩니다.
이는 중요한 지침을 위반합니다. 테스트를 수정해도 다른 테스트에 영향을 주어서는 안됩니다. 이 지침은 2장에서 다룬 바와 같이, 테스트는 서로 격리돼 실행해야 한다는 것과 완전히 같지는 않지만, 비슷합니다. 여기서는 테스트의 독립적인 수정이지, 독립적인 실행이 아니며, 둘 다 잘 설계된 테스트의 중요한 특성입니다.
이 지침을 따르려면 테스트 클래스에 공유 상태를 두지 말아야 합니다.
또 다른 단점은 테스트 가독성을 떨어뜨리는 것입니다. 테스트만 보고는 더 이상 전체 그림을 볼 수 없습니다. 테스트 메서드가 무엇을 하는지 이해하려면 클래스의 다른 부분도 봐야 합니다.
준비 로직이 별로 없더라도, 테스트 메서드로 바로 옮기는 것이 좋습니다. 그렇지 않으면 단순히 인스턴스를 만드는 것일까? 아니면 다른 무언가가 환경 설정을 하는 것일까?와 같은 불필요한 추론 과정이 발생합니다. 독립적인 테스트는 이러한 불확실성을 두지 않아야 합니다.
두 번째 방법은 테스트 클래스에 비공개 팩토리 메서드를 두는 것입니다.
저자는 비공개 팩터리 메서드를 테스트 대상 class 내부에 선언하도록 제시합니다. 글을 요약하는 저의 입장에서는 Typescript를 사용하고 있기 때문에 다음과 같은 방법을 더 선호할 것 같습니다.
- 텍스트 픽스처를 위한 빌더 class
- 텍스트 픽스처를 만드는 유틸 함수
더 자세한 예시는 향로님의 테스트 픽스처 올바르게 사용하기 (opens in a new tab)를 참고해 주세요.
이 규칙에 한 가지 예외가 있습니다. 테스트 전부 또는 대부분에 사용되는 생성자에 픽스처를 인스턴스화 할 수 있습니다. 이는 데이터베이스와 작동하는 통합 테스트에 종종 해당합니다. 이러한 모든 테스트는 데이터베이스 연결이 필요하며, 이 연결을 한 번 초기화한 다음 어디에서나 재사용할 수 있습니다. 그러나 기초 클래스를 뒤서 개별테스트 클래스가 아니라 클래스 생성자에서 데이터베이스 연결을 초기화 하는 것이 더 합리적일 수 있습니다.
단위 테스트 명명법
테스트에 표현력이 있는 이름을 붙이는 것이 중요합니다. 올바른 명칭은 테스트가 검증하는 내용과 기본 시스템의 동작을 이해하는 데 도움이 됩니다.
저자가 지난 10년간 시도해본 명명규칙 중 가장 유명하지만 가장 도움이 되지 않는 방법은 다음과 같습니다.
[테스트 대상 메서드]_[시나리오]_[예상결과]
동작 대신 구현 세부사항에 집중하게끔 부추기기 때문에 테스트 설계에 도움이 되지 않습니다.
간단하고 쉬운구문이 훨씬 더 효과적입니다. 이는 엄격한 명명 구조에 얽메이지 않고 표현력이 뛰어납니다. 간단한 문구로 고객이나 도메인 전문가에게 의미 있는 방식으로 시스템 동작을 설명할 수 있습니다.
Sum_of_tow_numbers // good
Sum_TwoNumbers_ReturnsSum // bad
수수께끼 같은 이름은 프로그래머든 아니든 모두가 이해하는 데 부담이 됩니다. 테스트가 정확히 무엇을 검증하는지, 비즈니스 요구사항과 어떤 관련이 있는지 파악하려면 머리를 더 써야 합니다. 별것 아닌 것처럼 보일지도 모르지만, 시간이 지날수록 정신적으로 부담이 가중됩니다. 기능의 구체적인 내용을 잊어버린 채 테스트를 작성하거나 동료가 작성한 테스트를 이해하려고 할 때 특히 이런 경우가 발생합니다. 다른 사람의 코드를 읽는 것은 이미 충분히 어렵습니다.
단위 테스트 명명 지침
-
엄격한 명명 정책을 따르지 않는다. 복잡한 동작에 대한 높은 수준의 설명을 이러한 정책의 좁은 상자안에 넣을 수 없다. 표현의 자유를 허용하자.
-
문제 도메인에 익숙한 비개발자들에게 시나리오를 설명하는 것처럼 테스트 이름을 짓자. 도메인 전문가나 비즈니스 분석가가 좋은 예이다.
-
단어를 밑줄(_)표시로 구분한다. 그러면 특히 긴 이름에서 가독성을 향상 시키는 데 도움이 된다.
Jest에서는 테스트 함수에서 문자열로 테스트를 명명할 수 있기 때문에 밑줄 대신 띄어쓰기를 사용할 수 있습니다.
단위테스트에서 단위는 동작의 단위지, 클래스의 단위가 아닌 것을 명심해야 합니다. 이 단위는 하나 이상의 클래스에 걸쳐 있을 수 있으며, 실제 테스트 크기에 영향을 주지 않습니다.
IsDeliveryValid_InvalidDate_ReturnsFalse
Delivery_with_invalid_date_should_ve_considered_invalid
Delivaery_with_past_date_should_be_confidered_invalid
Delivery_with_past_date_should_be_invalid
Delivery_with_past_dete_is_invalid
Delivery_with_a_past_date_is_invalid
테스트명 내 테스트 대상 메서드
테스트 이름에 SUT의 메서드 이름을 포함하지 말라
코드를 테스트하는 것이 아니라 애플리케이션 동작을 테스트하는 것이라는 점을 명심하자. 따라서 테스트 대상 메서드의 이름이 중요하지 않다. 앞에서 언급했듯이 SUT는 단지 진입점, 동작을 호출하는 수단일 뿐이다. 테스트 대상 메서드의 이름을 isDeliveryCorrect로 변경할 수 있으며, SUT의 동작에는 아무런 영향을 미치지 않는다. 반면 원래 명명 규칙을 따르면 테스트 이름을 바꿔야한다. 동작 대신 코드를 목표로 하면 해당 코드의 구현 세부 사항과 테스트 간의 결합도가 높아진다는 것을 다시 한번 보여주는데, 이는 테스트 스위트의 유지 보수성에 부정적인 영향을 미친다. 이 문제를 더 자세히 살펴보려면 5장을 참조하자.
이 지침의 유일한 예외는 유틸리티 코드를 작업할 때다. 유틸리티 코드는 비즈니스 로직이 없고, 코드의 동작이 단순한 보조 기능에서 크게 벗어나지 않으므로 비즈니스 담당자에게는 아무런 의미가 없다. 여기는 SUT메서드 이름을 사용해도 괜찮다.
좋은 단위 테스트의 4대 요소
좋은 단위 테스트에는 다음 네가지 특성이 있습니다:
- 회귀 방지
- 리팩터링 내성
- 빠른 피드백
- 유지 보수성
이 네가지 특성이 테스트의 기본입니다. 이 특성으로 어떤 자동화된 테스트(단위 테스트, 통합 테스트, e2e테스트 등)도 분석할 수 있습니다.
회귀 방지
회귀는 소프트웨어 버그입니다. 코드를 수정한 후 (일반적으로 새 기능을 출시한 후) 기능이 의도한 대로 작동하지 않는 경우를 말합니다.
이러한 회귀는 (아무리 좋게 봐도) 귀찮습니다. 그렇지만 최악은 아닙니다. 최악은 개발할 기능이 많을 수록, 새로운 릴리스에서 기능이 하나라도 고장날 가능성이 높다는 점입니다. 프로그래밍을 하는 삶에 있어 불행한 사실은 코드가 자산이 아니라 책임
이라는 점입니다. 코드베이스가 커질수록 개발자는 잠재적인 버그에 더 많이 노출됩니다. 그렇기 때문에 회귀에 대해 효과적인 보호를 개발하는 것이 중요합니다. 이러한 보호가 없다면 프로젝트가 오랫동안 성장할 수 없으며 점점 더 많은 버그가 쌓일 것입니다.
회귀 방지 지표에 대한 테스트 점수가 얼마나 잘 나오는지 평가하려면 다음 사항을 고려해야 합니다:
- 테스트 중 실행되는 코드의 양
- 코드의 복잡도
- 코드의 도메인 유의성
회귀 방지 지표를 극대화하려면 테스트가 가능한 한 많은 코드를 실행하는 것을 목표로 해야 한다.
리팩터링 내성
좋은 단위 테스트의 두 번째 특성은 리팩터링 내성입니다. 이는 테스트를 “빨간색”으로 바꾸지 않고 기본 애플리케이션 코드를 리팩터링할 수 있는지에 대한 척도를 말합니다.
기능은 정상 동작하지만 테스트가 실패하는 것을 거짓 양성
이라고 합니다. 거짓 양성은 허위 경보입니다. 이러한 거짓 양성은 일반적으로 코드를 리팩터링할 때, 즉 구현을 수정하지만 식별할 수 있는 동작은 유지할 때 발생합니다. 따라서 좋은 단위 테스트의 한 가지 특성으로 이름 붙이자면 리팩터링 내성
이라 할 수 있습니다.
왜 거짓 양성을 신경써야 할까요? 이는 전체 테스트 스위트에 치명적인 영향을 주기 때문입니다. 계속 언급하지만 단위 테스트의 궁극적인 목표는 프로젝트의 성장을 지속 가능하게 만드는 것입니다. 테스트가 지속 가능한 성장을 하게하는 메커니즘은 회귀 없이 주기적으로 리팩터링하고 새로운 기능을 추가할 수 있게 하는 것입니다. 여기에는 두 가지 장점이 있습니다:
- 기존 기능이 고장 났을 때 테스트가 조기 경고를 제공한다. 이러한 조기 경고 덕분에 결함이 있는 코드가 운영 환경에 배포되기 훨신 전에 문제를 해결할 수 있다. 운영 환경이었으면 문제를 처리하는 데 훨씬 더 많은 노력이 필요했을 것이다.
- 코드 변경이 회귀로 이어지지 않을 것이라고 확신하게 된다. 이러한 확신이 없으면 리팩터링을 하는 데 주저하게 되고 코드베이스가 나빠질 가능성이 훨씬 높아진다.
거짓 양성은 이 두 가지 이점을 모두 방해합니다:
- 테스트가 타당한 이유 없이 실패하면, 코드 문제에 대응하는 능력과 의지가 희석된다. 시간이 흐르면서 그러한 실패에 익숙해지고 그만큼 신경을 많이 쓰지 않는다. 이내 타당한 실패도 무시하기 시작해 기능이 고장 나도 운영 환경에 들어가게 된다.
- 반면에 거짓 양성이 빈번하면 테스트 스위트에 대한 신뢰가 서서히 떨어지며, 더 이상 믿을 만한 안전망으로 인식하지 않는다. 즉, 허위 경보로 인식이 나빠진다. 이렇게 신뢰가 부족해지면 리팩터링이 줄어든다. 회귀를 피하려고 코드 변경을 최소한으로 하기 때문이다.
그렇다면 거짓 양성의 원인은 무엇일까요? 테스트에서 발생하는 거짓 양성의 수는 테스트 구성 방식과 직접적인 관련이 있습니다. 테스트와 SUT의 구현 세부 사항이 많이 결합할수록 허위 정보가 더 많이 생깁니다. 거짓 양성이 생길 가능성을 줄이는 방법은 해당 구현 세부사항에서 테스트를 분리하는 것 뿐입니다. 테스트를 통해 SUT가 제공하는 최종 결과를 검증하는지 확인해야 합니다.
첫 번째 특성과 두 번째 특성 간의 본질적인 관계
좋은 단위 테스트의 두 요소(회귀 방지와 리팩터링 내성) 사이에는 본질적인 관계가 있습니다. 둘 다 정반대의 관점에서도 테스트 스위트의 정확도에 기여합니다. 이 두가지 특성은 시간이 흐르면서 프로젝트에 영향을 다르게 미치는 경향이 있습니다. 프로젝트가 시작된 직후에는 회귀 방지를 훌륭히 갖추는 것이 중요한 데 반해, 리팩터링 내성은 바로 필요하지 않을 수 있습니다.
테스트에서 오류가 발생하지 않으면 문제가 됩니다. 이는 오른쪽 상단 사분면에 속하고 거짓 음성
입니다. 거짓 음성을 피하는 데 좋은 테스트의 첫 번째 특성인 회귀 방지가 도움이 됩니다. 회귀 방지가 훌륭한 테스트는 2종 오류인 거짓 음성의 수를 최소화 하는 데 도움이 됩니다.
반면에 기능은 올바르지만 테스트가 여전히 실패로 표시되는 대칭적인 상황이 있습니다. 이는 거짓 양성
, 즉 허위 경보를 말합니다. 거짓 양성을 피하는 데 두 번째 특성인 리팩터링 내성이 도움이 됩니다.
이해하기 좋은 한 가지 방법은 독감 검사를 생각해보는 것입니다. 독감 검사는 받는 사람이 독감에 걸렸을 때 긍정입니다. 독감에 걸리는 것과 관련해서 긍정적인 것은 없기 때문에 긍정이라는 용어가 약간 혼란스럽긴 하지만, 테스트는 전체적으로 상황을 보지 않습니다. 테스트의 맥락에서 긍정은 어떤 조건이 이제 사실임을 의미합니다. 이러한 조건은 테스트가 반응하도록 작성자가 설정한 조건입니다. 여기서는 독감의 존재가 조건에 해당합니다. 반대로 독감이 없으면 독감 검사는 부정이 됩니다.
정확도는 좋은 단위 테스트의 처음 두 개의 특성에 대한 것입니다. 회귀 방지와 리팩터링 내성은 테스트 스위트의 정확도를 극대화하는 것을 목표로 합니다. 정확도 지표는 다음 두 가지 요소로 구성됩니다:
- 테스트가 버그 있음을 얼마나 잘 나타내는가(거짓 음성(회귀방지 영역) 제외).
- 테스트가 버그 없음을 얼마나 잘 나타내는가(거짓 양성(리팩터링 내성 영역) 제외).
거짓 양성과 거짓 음성을 생각해보는 다른 방법으로 소음 대비 신호 비율 측면에서 볼 수 있습니다. 테스트 정확도를 향상시키는 방법은 두 가지가 있습니다. 첫 번째는 신호를 증가시키는 것입니다. 이는 회귀를 더 잘 찾아내는 테스트로 개선하는 것을 말합니다. 두 번째는 소음을 줄이는 것입니다. 이는 허위 경보를 발생시키지 않는 테스트로 개선하는 것을 말합니다.
둘 다 매우 중요합니다. 정보가 허위로 울리지 않더라도 버그를 찾을 수 없는 테스트는 소용이 없습니다. 마찬가지로 코드에서 모든 버그를 찾을 수 있더라도 소음이 많이 발생하면 테스트의 정확도는 0에 가까워집니다. 이렇게 찾아도 온통 쓸데 없는 정보가 돼버립니다.
빠른 피드백과 유지 보수성
오래 걸리는 테스트(특히 단위테스트에서)는 자주 실행하지 못하기 때문에 잘못된 방향으로 가면서 시간을 더 많이 낭비하게 됩니다.
좋은 단위테스트의 네 번째 특성인 유지보수성 지표는 유지비를 평가합니다. 지표는 다음 두가지 주요 요소로 구성됩니다:
- 테스트가 어마나 이해하기 어려운가 : 이 구성 요소는 테스트의 크기와 관련이 있다. 테스트는 코드 라인이 적을수록 더 읽기 쉽다. 작은 테스트는 필요할 때 변경하는 것도 쉽다. 물론 단지 라인 수를 줄이려고 테스트 코드를 인위적으로 압축하지 않는다고 가정할 때다. 테스트 코드의 품질은 제품 코드만큼 중요하다. 테스트를 작성할 때 절차를 생략하지 말라. 테스트 코드를
일급 시민
으로 취급하라. - 테스트가 얼마나 실행하기 어려운가 : 테스트가 프로세스 외부 종속성으로 작동하면, 데이터베이스 서버를 재부팅하고 네트워크 연결 문제를 해결하는 등 의존성을 상시 운영하는 데 시간을 들여야한다.
이상적인 테스트를 찾아서
좋은 단위 테스트의 4가지 특성을 다시 살펴보면 다음과 같습니다:
- 회귀 방지
- 리팩터링 내성
- 빠른 피드백
- 유지 보수성
이 네 가지 특성을 곱하면 테스트의 가치가 결정됩니다. 여기서 곱셈은 수학적인 의미의 곱셈을 말합니다. 즉, 어떤 특성이라도 0이 되면 전체가 0이 됩니다.
테스트가 가치가 있으려면 해당 테스트는 네 가지 범주 모두에서 점수를 내야 한다.
물론 이러한 특성을 정확하게 측정하는 것은 불가능 합니다. 그러나 네 가지 특성과 관련해서 테스트가 어디쯤 있는지는 비교적 정확하게 평가할 수 있습니다.
그렇다면 이상적인 테스트는 무엇일까요? 이상적인 테스트는 네 가지 특성 모두에서 최대 점수를 받는 테스트를 말합니다. 각 속성마다 최소, 최대를 0과 1로 정했을 때 모두 1을 얻어야 합니다.
안타깝게도 그런 이상적인 테스트를 만드는 것은 불가능합니다. 처음 세 가지 특성인 회귀 방지, 리팩터링 내성, 빠른 피드백은 상호 배타적이기 때문입니다. 세 가지 특성 모두 최대로 하는 것은 불가능합니다. 더욱이 곱셈 원리 때문에 군형을 유지하는 것이 더욱 까다롭습다. 따라서 특성중 어느 것도 크게 줄지 않는 방식으로 최대한 크게 해야합니다.
결국 좋은 단위 테스트의 처음 3가지 특성 (회귀 방지, 리팩터링 내성, 빠른 피드백)은 상호 배타적이므로, 이 세가지 특성중 두 가지를 극대화 하는 테스트를 만들기는 쉽지만, 나머지 특성 한 가지를 희생해야 합니다.
그럼 어떻게 희생을 해야 할까요? 회귀 방지, 리팩터링 내성, 빠른피드백의 상호 배타성 때문에 세가지 특성 모두를 양보할 만큼 서로 조금씩 인정하는 것이 최선의 전략이라고 생각할 수 있습니다.
그러나 실제로는 리팩터링 내성을 포기할 수 없습니다. 리팩터링 내성은 이진 선택(테스트가 리팩터링에 내성이 있는가 없는가)이므로 개발자는 테스트에서 리팩터링 내성을 최대한 많이 갖는 것을 목표로 해야 합니다. 따라서 테스트가 얼마나 버그를 잘 찾아내는지와 얼마나 빠른지 사이의 선택으로 절충이 귀결됩니다.
테스트 스위트를 탄탄하게 만들려면 테스트의 불안정성(거짓 양성)을 제거하는 것이 최우선 과제다.
기존에 잘 알려진 모든 테스트 자동화 개념은 결국 이 네 가지 특성으로 거슬러 올라갈 수 있습니다.
블랙박스 테스트와 화이트박스 테스트 간의 선택
-
블랙박스 테스트는 시스템의 내부 구조를 몰라도 시스템의 기능을 검사할 수 있는 소프트웨어 테스트 방법이다. 일반적으로 명세와 요구사항, 즉 애플리케이션이 어떻게 해야 하는지가 아니라 무엇을 해야 하는지를 중심으로 구축된다.
-
화이트박스 테스트는 정반대다. 애플리케이션의 내부 작업을 검증하는 테스트 방식이며, 테스트는 요구사항이나 명세가 아닌 소스코드에서 파생된다.
이 두가지 방법은 모두 장단점이 있습니다. 화이트 박스 테스트가 더 철저한 편입니다. 반면에 이는 테스트 대상 코드의 특정 구현과 결합돼 있기 때문에 깨지기 쉽습니다. 이러한 테스트는 거짓 양성을 많이 내고 리팩터링 내성 지표가 부족합니다. 또한 비즈니스 담당자에게 의미가 있는 동작으로 유추할 수 없는데, 화이트박스 테스트가 취약하고 가치를 많이 부여하지 않는다는 강력한 신호를 나타냅니다. 블랙박스 테스트는 이와 정반대의 장단점을 제공합니다.
회귀 방지 | 리팩터링 내성 | |
---|---|---|
화이트박스 테스트 | 좋음 | 나쁨 |
블랙박스 테스트 | 나쁨 | 좋음 |
다시 한번 말하지만, 리팩터링 내성은 타협할 수 없습니다. 즉, 테스트는 리팩터링 내성이 있거나 아예없습니다. 따라서 화이트박스 테스트 대신 블랙박스 테스트를 기본으로 선택하는 것이 적절합니다. 유일한 예외는 알고리즘 복잡도가 높은 유틸리티 코드를 다루는 경우가 있습니다.
마치며
지금까지 고전파가 다루는 단위 테스트의 기본적인 정의와 좋은 단위테스트에 대해서 요약했습니다. 추후 책에서는 테스트 더블을 다루는 방법과 안티패턴들을 추가적으로 설명합니다.