주변에서 괜찮다는 추천을 받아서 오랜만에 테스트 관련 책을 읽었다. 테스트를 열심히 작성하려고 하지만 최근에 테스트에 관해서 많이 연구하지는 못했다. 하던 관성에 따라 테스트를 작성하면서 어려운 부분을 만났을 때 고민하는 정도로만 작성하고 있었다. 아주 크고 복잡한 애플리케이션을 작성하지 않기도 하고...
가물가물하지만 이전에 봤던 책들은 기본적인 테스트 기법에 관해서 배웠던 거 같은데(물론 그 이상을 설명했지만, 그때는 기초적인 거 습득하기에도 벅차서 그랬을지도...) 이 책은 오랜 저자의 오랜 경험을 바탕으로 테스트에 대한 많은 조언이 담겨있다. 제목은 단위 테스트이지만 통합 테스트와 e2e 테스트에 대한 언급도 나온다. 하지만 책의 대부분은 단위 테스트에 집중한다.
예제 코드는 C#으로 되어 있는데 설명에 도움이 될 정도로만 최대한 간단히 예제를 구성했다는 느낌이 있어서 C#을 모르더라고 코드의 의도나 어떤 부분을 리팩터링 하는지 이해할 수 있다. 물론 C#의 기능이 약간은 나오긴 하지만 책의 설명을 헤친다는 느낌은 별로 받지 못했다.
테스트라는 개념이 꽤 어려운데 이 책은 실용적인 부분에 기반해서 설명을 잘했다고 생각한다. 설계에 경험이 많은 편이 아니라 책의 설명을 다 습득했다고 하기는 어렵지만, 막연히 생각하거나 접근하던 것을 분류해서 정리해 주고 있고 명확한 부분은 해라/하지 마라를 정확히 말해 주고 있어서 테스트할 때 이렇게 하는 게 맞나? 하는 부분에 꽤 도움이 된다고 생각한다. 물론 그런 부분에서 고려해야 하는 부분과 이유도 잘 설명하고 있다고 생각한다.
1부 더 큰 그림
1장 단위 테스트의 목표
모든 새로운 기술과 마찬가지로 단위 테스트도 계속 발전하고 있다. 논쟁은 '단위 테스트를 작성해야 하는가?'에서 '좋은 단위 테스트를 작성하는 것은 어떤 의미인가?'로 바뀌었다. 이는 여전히 매우 혼란스럽다.
코드베이스에 대해 단위 테스트 작성이 필요하면 일반적으로 더 나은 설계로 이어진다. 하지만 단위 테스트의 주목표는 아니다. 더 나은 설계를 단지 좋은 부수효과일 뿐이다.
처음엔 테스트를 작성하는 것조차도 어렵긴 한데 좋은 단위 테스트에 집중한다는 부분이 좋았다. 테스트를 작성하는 것 자체는 하다 보면 조금씩 익숙해질 수 있지만 좋은 테스트라는 더 어려웠고 좋은 설계만큼이나 테스트를 잘 작성하였는지에 대한 고민이 있었다.
단위 테스트와 코드 설계의 관계
코드 조각을 단위 테스트하는 것은 훌륭한 리트머스 시험이지만, 한 방향으로만 작동한다. 이는 괜찮은 부정 지표다. 즉, 비교적 높은 정확도로 저품질 코드를 가려낸다. 코드를 단위 테스트하기 어렵다면 코드 개선이 반드시 필요하다는 것을 의미한다. 보통 강결합(tight coupling)에서 저품질이 나타나는데, 여기서 강결합은 제품 코드가 서로 충분히 분리되지 않아서 따로 테스트하기 어려움을 뜻한다.
안타깝게도 코드 조각을 단위 테스트할 수 있다는 것은 좋지 않은 긍정 지표다. 코드 베이스를 쉽게 단위 테스트할 수 있다고 해도 반드시 코드 품질이 좋은 것을 의미하지는 않는다. 낮은 결합도를 보여도 프로젝트는 '대참사'가 될 수 있다.
좋은 부정 지표이지만 좋지 않은 긍정 지표라는 말이 인상적이었다. 이런 식으로 깊이 생각해 보진 않았는데 비슷한 상황에서 써먹을 수 있는 말이라고 생각한다. 뒤에도 나오지만, 테스트 커버리지도 비슷한 상황이기도 하고... 테스트 작성할 때 염두에 두어야겠다고 생각한다. 부정 지표로 좋게 써먹다 보면 자연스레 긍정 지표에서도 좋은 신호라고 안심하는 함정에 빠지기 쉽다고 생각한다.
단위 테스트의 목표는 무엇인가? 소프트웨어 프로젝트의 지속 가능한 성장을 가능하게 하는 것이다. 지속 가능하다는 것이 핵심이다.
지속 가능한 프로젝트 성장을 위해서는 고품질 테스트에만 집중해야 한다. 고품질 테스트만이 테스트 스위트에 남을 만한 테스트 유형이다.
앞의 좋은 테스트와 같은 맥락으로 고품질 테스트에 집중해야 한다는 게 좋았다. 난 좀 테스트를 마구 작성하는 경향이 있다.
사람들은 종종 테스트가 많으면 많을수록 좋다고 생각한다. 하지만 그렇지 않다. 코드는 자산이 아니라 책임이다. 코드가 더 많아질수록, 소프트웨어 내의 잠재적인 버그에 노출되는 표면적이 더 넓어지고 프로젝트 유지비가 증가한다. 따라서 가능한 한 적은 코드로 문제를 해결하는 것이 좋다.
실제로 내가 좀 그렇다. 예전에 테스트 작성하면서 어디까지 작성하는 게 맞는지 잘 모르다 보니 내가 안심할 때까지 테스트를 작성하다 보니 좀 많이 작성하게 된 거 같다. 이젠 쓸데없다고 느끼는 테스트는 더 이상 작성하지 않지만 내가 보는 오픈소스 프로젝트와 비교해 보면 복잡도가 훨씬 적음에도 내가 테스트를 많이 작성한다고 평소에도 느끼고 있었다. 그래서 이런 말이 더 인상적이었던 것 같다.
단위 테스트에서 가장 어려운 부분은 최소 유지비로 최대 가치를 달성하는 것이다. 이는 이 책에서 말하려는 핵심이다.
가치가 유지비를 상회하는 테스트만 스위트에 유지하는 것이 중요하다.
이 마지막 속성은 두 가지로 나눌 수 있다.
* 가치 있는 테스트(더 나아가, 가치가 낮은 테스트) 식별하기
* 가치 있는 테스트 작성하기
가치 있는 테스트를 작성하려면 뭐가 가치 있고 뭐가 아닌지를 알 수 있어야 가능하니 중요한 부분이다.
2장 단위 테스트란 무엇인가
단위 테스트는
* 작은 코드 조각(단위라고고 함)을 검증하고,
* 빠르게 수행하고,
* 격리된 방식으로 처리하는 자동화된 테스트다.
격리 주체 | 단위의 크기 | 테스트 대역 사용 대상 | |
---|---|---|---|
런던파 | 단위 | 단일 클래스 | 불변 의존성 외 모든 의존성 |
고전파 | 단위 테스트 | 단일 글래스 또는 클래스 세트 | 공유 의존성 |
단위 테스트 정의에 대해 해석의 차이가 생겼고, 단위 테스트에 접근하는 방법이 두 가지 뚜렷한 견해로 나뉘었다. 이러한 두 가지 견해는 각각 '고전파(classical school)와 런던파(London school)로 알려져 있다.
정확히 구분하기 어려웠지만 난 고전파에 가깝지 않나 생각이 들었다.
3장 단위 테스트 구조
AAA 패턴은 각 테스트를 준비(arrange), 실행(act), 검증(assert)이라는 세 부분으로 나눌 수 있다.
AAA와 유사한 Given-When-Then 패턴에 대해 들어봤을 것이다. 이 패턴도 테스트를 세 부분으로 나눈다.
AAA 패턴은 난 처음 들어봤고 Given-When-Then 패턴은 아주 좋아하는 패턴이다. 예전에는 항상 주석까지 남기면서 의식적으로 세 부분을 나누는 편이었고 요즘은 그렇게 하진 않지만 복잡해서 테스트가 잘 안된다 싶으면 정리할 겸 주석으로 나누어서 작성하고 있다.
준비, 실행, 검증 구절이 여러 차례 나타나는 것과 비슷하게, if 문이 있는 단위 테스트를 만날 수 있다. 이것도 안티 패턴이다. 단위 테스트든 통합 테스트든 테스트는 분기가 없는 간단한 일련의 단계여야 한다.
일반적으로 준비 구절이 세 구절 중 가장 크며, 실행과 검증을 합친 만큼 클 수도 있다. 그러나 이보다 훨씬 크면, 같은 테스트 클래스 내 비공개 메서드 또는 별도의 팩토리 클래스로 도출하는 것이 좋다. 준비 구절에서 코드 재사용에 도움이 되는 두 가지 패턴으로 오브젝트 마더(Object Mother)와 테스트 에디터 빌더(Test Data Builder)가 있다.
단위 테스트이 단위는 동작의 단위이지 코드의 단위가 아니다. 단일 동작 단위는 여러 결과를 낼 수 있으며, 하나의 테스트로 그 모든 결과를 평가하는 것이 좋다.
이건 기억해 두어야 할 말이다. 처음엔 코드 단위로 테스트하려다 보니 어려웠던 기억이 난다. 지금도 깔끔하게 작성한다고 할 수는 없지만...
텍스트 픽스처
텍스트 픽스처라는 단어는 다음과 같이 두 가지 공통된 의미가 있다.
1. 테스트 픽스처는 테스트 실행 대상 객체다. 이 객체는 정규 의존성, 즉 SUT로 전달되는 인수다. 데이터베이스에 있는 데이터나 하드 디스크의 파일일 수도 있다. 이러한 객체는 각 테스트 실행 전에 알려진 고정 상태로 유지하기 때문에 동일한 결과를 생성한다. 따라서 픽스처라는 단어가 나왔다.
텍스트 픽스처는 나도 많이 작성하는 말이지만 정확한 의미를 기억해 두려고 적어둔다. 책에서 계속 나오는 SUT는 테스트 대상 시스템(SUT, System Under Test)을 말한다.
표현력 있고 읽기 쉬운 테스트 이름을 지으려면 다음 지침을 따르자.
* 엄격한 명명 정책을 따르지 않는다. 복잡한 동작에 대한 높은 수준의 설명을 이러한 정책의 좁은 상자 안에 넣을 수 없다. 표현의 자유를 허용하자.
* 문제 도메인에 익숙한 비개발자들에게 시나리오를 설명하는 것처럼 테스트 이름을 짓자. 도메인 전문가나 비즈니스 분석가가 좋은 예다.
* 단어를 밑줄(_) 표시로 구분한다. 그러면 특히 긴 이름에서 가독성을 향상시키는 데 도움이 된다.
JavaScript의 테스트 프레임워크에 익숙해서인지 함수 이름보다는 그냥 문자열로 문장처럼 작성하는 게 더 익숙하긴 하다. 그렇지만 다른 언어에서는 함수 이름으로 테스트 이름을 짓는 경우가 많으니 고민은 해두어야 할 것 같다.
should be 문구는 또 다른 일반적인 안티 패턴이다. 이 장의 앞부분에서 하나의 테스트는 동작 단위에 대해 단순하고 원자적인 사실이라고 했다. 사실을 서술할 때는 소망이나 욕구가 들어가지 않는다. 여기에 따라 테스트 이름을 짓자. should be는 다음과 같이 is로 바꿔보자.
항상 should를 쓰는 편이었는데... ㅠㅠ 영어 네이티브가 아니라서 그런지 should가 소망이라고 생각하면서 작성하진 않았는데 담에 작성할 때는, 더 고민해 봐야겠다. 책에 매개 변수화된 테스트 얘기도 나오는데 익숙치 않아서 아직은 어색한 편인데 이 부분도 익숙해지려고 노력해 봐야겠다는 생각이 들었다.
2부 개발자에 도움이 되는 테스트 만들기
4장 좋은 단위 테스트의 4대 요소
좋은 단위 테스트에는 다음 네 가지 특성이 있다.
* 회귀 방지
* 리팩터링 내성
* 빠른 피드백
* 유지 보수성
첫 번째 요소: 회귀 방지
코드를 수정한 후 (일반적으로 새 기능을 출시한 후) 기능이 의도한 대로 작동하지 않는 경우다.
일반적으로 실행되는 코드가 많을수록 테스트에서 회귀가 나타날 가능성이 높다.
테스트의 주목적이기도 하므로 회귀 방지를 하는 것은 중요하다. 어찌 보면 테스트를 작성할 때 당연하다고 생각할 수도 있지만 이 책을 보면 작성하면서 의외로 회귀 방지가 안 되거나 회귀 방지 효과가 작은 경우도 있으니 의식적으로 신경을 써야겠다.
회귀 방지 지표를 극대화하려면 테스트가 가능한 한 많은 코드를 실행하는 것을 목표로 해야 한다.
두 번째 요소: 리팩터링 내성
이는 테스트를 '빨간색'(실패)으로 바꾸지 않고 기본 애플리케이션 코드를 리팩토링할 수 있는지에 대한 척도다.
테스트를 깨지지 않게 하고 리팩터링 내성을 높이는 방법은 SUT의 구현 세부 사항과 테스트 간의 결합도를 낮추는 것뿐이다. 즉, 코드의 내부 작업과 테스트 사이를 가능한 한 멀리 떨어뜨리고 최종 결과를 목표로 하는 것이다.
책을 읽으면서 내가 많이 신경 못 쓰는 부분이라는 생각을 많이 한 게 리팩터링 내성이다. 구현을 하다 보면 테스트 목적을 달성하기 위해서 이 부분을 생각 못하는 부분이 많은 것 같아서 좀 더 신경 써야겠다는 생각이 들었다.
테스트 정확도를 향상시키는 방법은 두 가지가 있다. 첫 번째는 분자, 즉 신호를 증가시키는 것이다. 이는 회귀를 더 잘 찾아내는 테스트로 개선하는 것이다. 두 번째는 분모, 즉 소음을 줄이는 것이다. 이는 허위 경보를 발생시키지 않는 테스트로 개선하는 것이다.
테스트 정확도 = 신호(발견된 버그 수) / 소음(허위 경보 발생 수)
프로젝트가 시작될 때, 잘못된 경고를 받는 것은 경고를 전혀 받지 않아 버그가 운영 환경에 들어갈 위험을 감수하는 것에 비해 별일이 아니다. 그러나 프로젝트가 성장함에 따라 거짓 양성은 테스트 스위트에 점점 더 큰 영향을 미치기 시작한다.
세 번째와 네 번째 요소: 빠른 피드백과 유지 보수성
빠른 피드백은 단위 테스트의 필수 속성이다. 테스트 속도가 빠를수록 테스트 스위트에서 더 많은 테스트를 수행할 수 있고 더 자주 실행할 수 있다.
좋은 테스트의 네 번째 특성인 유지 보수성 지표는 유지비를 평가한다.
* 테스트가 얼마나 이해하기 어려운가
* 테스트가 얼마나 실행하기 어려운가
테스트 속도는 빠를수록 좋으니 언제든 항상 신경 쓰던 부분이고 유지 보수성은 코드 복잡성과 섞여서 헷갈릴 때도 있지만 작성하면서 자연스레 신경 쓰게 되는 것 같다.
이 네 가지 특성을 곱하면 테스트의 가치가 결정된다. 여기서 곱셈은 수학적인 의미의 곱셈이다. 즉, 어떤 특성이라도 0이 되면 전체가 0이 된다.
이상적인 테스트를 만드는 것은 불가능하다. 처음 세 가지 특성인 회귀 방지, 리팩터링 내성, 빠른 피드백은 상호 배타적이기 때문이다. 세 가지 특성 모두 최대로 하는 것은 불가능하다. 셋 중 하나를 희생해야 나머지 둘을 최대로 할 수 있다.
좋은 단위 테스트의 처음 세 가지 특성(회귀 방지, 리팩터링 내성, 빠른 피드백)은 상호 배타적이다. 이 세 가지 특성 중 두 가지를 극대화하는 테스트를 만들기는 매우 쉽지만, 나머지 특성 한 가지를 희생해야만 가능하다. 이러한 테스트는 곱셈 규칙으로 인해 가치가 0에 가까워진다. 안타깝게도 세 가지 특성 모두 완벽한 점수를 얻어서 이상적인 테스트를 만드는 것은 불가능하다.
네 번째 특성인 유지 보수성은 엔드 투 엔드 테스트를 제외하고 처음 세 가지 특성과 상관관계가 없다.
리팩터링 내성을 최대한 많이 갖는 것을 목표로 해야 한다. 따라서 테스트가 얼마나 버그를 잘 찾아내는지(회귀 방지)와 얼마나 빠른지(빠른 피드백) 사이의 선택으로 절충이 귀결된다.
이 부분이 좋다고 생각했다. 유지 보수성은 항상 최대로 생각하고 나머지 세 가지 특정 중에 2가지를 골라야 하는데 리팩터링 내성은 최대로 하면서 회귀 방지와 빠른 피드백 중에서 절충을 생각해야 한다. 이 부분은 다음에 테스트 작성할 때 고민하면서 연습해봐야 할 것 같다. 이렇게 특성을 나누어서 명확하게 설명해주니 테스트 작성할 때 의식적으로 연습할 때 도움이 될 것 같다.
리팩터링 내성을 포기할 수 없는 이유는 테스트가 이 특성을 갖고 있는지 여부는 대부분 이진 선택이기 때문이다. 즉, 테스트에 리팩터링 내성이 있거나 없거나 둘 중 하나다.
5장 목과 테스트 취약성
제라드 메스자로스(Gerard Meszaros)에 따르면, 테스트 대역에서 더미(dummy), 스텁, 스파이(spy), 목, 페이크(fake)라는 다섯 가지가 있다. 여러 가지 유형에 겁먹을 수 있지만, 실제로는 목과 스텁의 두 가지 유형으로 나눌 수 있다.
이 두 유형의 차이점은 다음과 같다.
* 목은 외부로 나가는 상호 작용을 모방하고 검사하는 데 도움이 된다. 이러한 상호 작용은 SUT가 상태를 변경하기 위한 의존성을 호출하는 것에 해당한다.
* 스텁은 내부로 들어오는 상호 작용을 모방하는 데 도움이 된다. 이러한 상호 작용은 SUT가 입력 데이터를 얻기 위한 의존성을 호출하는 것에 해당한다.
목과 스텁의 차이점에도 유의하라. 목은 SUT와 관련 의존성 간의 상호 작용을 모방하고 검사하는 반면, 스텁은 모방만 한다. 이는 중요한 차이점이다.
예전 테스트 책에서도 테스트 대역의 많은 종류를 설명한 것을 본 적은 있는데 그냥 편하게 Mock으로 부르고 있긴 한데 이 둘의 차이는 처음(혹은 까먹었거나) 알게 되었다.
모든 제품 코드는 2차원으로 분류할 수 있다.
* 공개 API 또는 비공개 API
* 식별할 수 이는 동작 또는 구현 세부 사항
각 차원의 범주는 겹치지 않는다.
클래스 API를 잘 설계하려면 해당 멤버가 식별할 수 있는 동작이 되게 해야 한다. 이를 위해서는 다음 두 가지 중 하나를 해야 한다.
* 클라이언트가 목표를 달성하는 데 도움이 되는 작업을 노출하라.
* 클라이언트가 목표를 달성하는 데 도움이 되는 상태를 노출하라.
클래스가 구현 세부 사항을 유출하는지 판단하는 데 도움이 되는 유용한 규칙이 있다. 단일한 목표를 달성하고자 클래스에서 호출해야 하는 연산의 수가 1보다 크면 해당 클래스에서 구현 세부 사항을 유출할 가능성이 있다.
계속해서 증가하는 코드 복잡도에 대처할 수 있는 방법은 실질적으로 캡슐화 말고는 없다. 코드 API가 해당 코드로 할 수 있는 것과 할 수 없는 것을 알려주지 않으면 코드가 변경됐을 때 모순이 생기지 않도록 많은 정보를 염두에 둬야 한다.
좋은 단위 테스트와 잘 설계된 API 사이에는 본질적인 관계가 있다. 모든 구현 세부 사항을 비공개로 하면 테스트가 식별할 수 있는 동작을 검증하는 것 외에는 다른 선택지가 없으며, 이로 인해 리팩터링 내성도 자동으로 좋아진다.
코드와 테스트를 작성하면서 고민하는 부분이다. 잘 설계하고 테스트도 잘 작성한다면 좋겠지만 테스트가 어려울 때 설계를 고치면서 어떻게 테스트할지를 계속 오가야 하는데 고민할 때 지표로 삼을 수 있을 것 같다.
6장 단위 테스트 스타일
단위 테스트 스타일에 대해 동일한 기준틀을 적용한다. 출력 기반, 상태 기반, 통신 기반이라는 세 가지 테스트 스타일이 있다. 출력 기반 스타일의 테스트가 가장 품질이 좋고, 상태 기반 테스트는 두 번째로 좋은 선택이며, 통신 기반 테스트는 간헐적으로만 사용해야 한다.
하나의 테스트에서 하나 또는 둘, 심지어 세 가지 스타일 모두를 함께 사용할 수 있다.
출력 기반 스타일로, 테스트 대상 시스템(SUT)에 입력을 넣고 생성되는 출력을 점검하는 방식이다.
상태 기반 스타일은 작업이 완료된 후 시스템 상태를 확인하는 것이다. 이 테스트 스타일에서 상태라는 용어는 SUT나 협력자 중 하나, 또는 데이터베이스나 파일 시스템 등과 같은 프로세스 외부 의존성의 상태 등을 의미할 수 있다.
세 번째 단위 테스트 스타일은 통신 기반 테스트다. 이 스타일은 목을 사용해 테스트 대상 시스템과 협력자 간의 통신을 검증한다.
이 부분도 막연히 사용하던 걸 정리해 주니까 좋았다.
보통 실행하는 코드가 많든 적든 원하는 대로 테스트를 작성할 수 있다. 어떤 스타일도 이 부분에서 도움이 되지 않는다. 코드 복잡도와 도메인 유의성 역시 마찬가지다. 통신 기반 스타일에는 예외가 하나 있다. 남용하면 작은 코드 조각을 검증하고 다른 것은 모두 목을 사용하는 등 피상적인 테스트가 될 수 있다. 하지만 이는 통신 기반 테스트의 결정적인 특징이 아니라 기술을 남용하는 극단적인 사례다.
테스트 스타일과 테스트 피드백 속도 사이에는 상관관계가 거의 없다.
출력 기반 테스트는 테스트가 테스트 대상 메서드에만 결합되므로 거짓 양성 방지가 가장 우수하다.
상태 기반 테스트는 일반적으로 거짓 양성이 되기 쉽다. 이러한 테스트는 테스트 대상 메서드 외에도 클래스 상태와 함께 작동한다. 확률적으로 말하면, 테스트와 제품 코드간의 결합도가 클수록 유출되는 구현 세부 사항에 테스트가 얽매일 가능성이 커진다.
통신 기반 테스트가 허위 경보에 가장 취약하다.
리팩터링 내성을 잘 지키려면 통신 기반 테스트를 사용할 때 더 신중해야 한다.
유지 보수성 지표는 단위 테스트 스타일과 밀접한 관련이 있다. 그러나 리팩터링 내성과 달리 완화할 수 있는 방법이 많지 않다.
출력 기반 테스트가 가장 유지 보수하기 용이하다.
통신 기반 테스트는 유지 보수성 지표에서 출력 기반 테스트와 상태 기반 테스트보다 점수가 낮다.
함수형 프로그래밍의 목표는 부작용을 완전히 제거하는 것이 아니라 비즈니스 로직을 처리하는 코드와 부작용을 일으키는 코드를 분리하는 것이다.
7장 가치 있는 단위 테스트를 위한 리팩터링
모든 제품 코드는 2차원으로 분류할 수 있다.
* 복잡도 또는 도메인 유의성
* 협력자 수
코드 복잡도(code complexity)는 코드 내 의사 결정(분기) 지점 수로 정의한다. 이 숫자가 클수록 복잡도는 더 높아진다.
도메인 유의성(domain significance)은 코드가 프로젝트의 문제 도메인에 대해 얼마나 의미있는지를 나타낸다. 일반적으로 도메인 계층의 모든 코드는 최종 사용자의 목표와 직접적인 연관성이 있으므로 도메인 유의성이 높다. 반면에 유틸리티 코드는 그런 연관성이 없다.
복잡한 코드와 도메인 유의성을 갖는 코드가 단위 테스트에서 가장 이롭다. 해당 테스트가 회귀 방지에 뛰어나기 때문이다.
코드 복잡도, 도메인 유의성, 협력자 수의 조합으로 네 가지 코드 유형을 볼 수 있다.
* 도메인 모델과 알고리즘: 보통 복잡한 코드는 도메인 모델이지만, 100%는 아니다. 문제 도메인과 직접적으로 관련이 없는 복잡한 알고리즘이 있을 수 있다.
* 간단한 코드: 협렵자가 있는 경우가 거의 없고 복잡도나 도메인 유의성도 거의 없다.
* 컨트롤러: 이 코드는 복잡하거나 비즈니스에 중요한 작업을 하는 것이 아니라 도메인 클래스와 외부 애플리케이션 같은 다른 구성 요소의 작업을 조정한다.
* 지나치게 복잡한 코드: 이러한 코드는 두 가지 지표 모두 높다. 협력자가 많으며 복잡하거나 중요하다.
도메인 모델 및 알고리즘을 단위 테스트하면 노력 대비 가장 이롭다. 이러한 단위 테스트는 매우 가치 있고 저렴하다.
간단한 코드는 테스트할 필요가 전혀 없다.
지나치게 복잡한 코드는 피하고 도메인 모델과 알고리즘만 단위 테스트하는 것이 매우 가치 있고 유지 보수가 쉬운 테스트 스위트로 가는 길이다.
테스트 대상 코드의 로직을 테스트하려면, 테스트가 가능한 부분을 추출해야 한다. 결과적으로 코드는 테스트 가능한 부분을 둘러싼 얇은 험블 래퍼(humble wrapper)가 된다. 이 험블 래퍼가 테스트하기 어려운 의존성과 새로 유출된 구성 요소를 붙이지만, 자체적인 로직이 거의 없거나 전혀 없으므로 테스트할 필요가 없다.
험블 객체 패턴을 보는 또 다른 방법은 단일 책임 원칙을 지키는 것이다. 이는 각 클래스가 단일한 책임만 가져야 한다는 원칙이다.
비즈니스 로직과 오케스트레이션을 분리하는 경우다. 코드의 깊이와 코드의 너비 관점에서 이 두 가지 책임을 생각해볼 수 있다. 코드가 깊거나(복잡하거나 중요함) 넓을(많은 협력자와 작동함) 수 있지만, 둘 다 가능하지는 않다.
결국 도메인 모델은 직접적으로든 간접적으로든 (인터페이스를 통해) 프로세스 외부 협력자에게 의존하지 않는 것이 훨씬 더 깔끔하다. 이것이 바로 육각형 아키텍처에서 바라는 바다. 도메인 모델은 외부 시스템과의 통신을 책임지지 않아야 한다.
일반적으로 권장하는 지침은 도메인 유의성이 있는 모든 전제 조건을 테스트하라는 것이다.
컨트롤러 복잡도가 커지는 것을 완화하는 첫 번째 장법은 CanExecute/Execute 패턴을 사용해 비즈니스 로직이 도메인 모델에서 컨트롤러로 유출되는 것을 방지하는 것이다.
너무 복잡하지 않게 하는 방법은 도메인 이벤트를 사용하는 것뿐이다.
식별할 수 있는 동작과 구현 세부 사항을 양파의 여러 겹으로 생각하라. 외부 계층의 관점에서 각 계층을 테스트하고, 해당 계층이 기저 계층과 어떻게 통신하는지는 무시하라. 이러한 계층을 하나씩 벗겨가면서 관점을 바꾸게 된다. 이전에 구현 세부 사항이었던 것이 이제는 식별할 수 있는 동작이 되며, 이는 또 다른 테스트로 다루게 된다.
이번 장에서는 테스트를 작성하기 위한 설계에 대한 얘기가 많이 나왔다. 아마 테스트를 설명하기 어려운 부분 중 하나일 것이다.
3부 통합 테스트
8장 통합 테스트를 하는 이유
대부분의 애플리케이션은 목으로 대체할 수 없는 프로세스 외부 의존성이 있다. 대개 데이터베이스이며, 다른 애플리케이션에서 볼 수 없는 의존성이다.
통합 테스트는 코드(애플리케이션 코드와 애플리케이션에서 사용하는 라이브러리의 코드를 모두 포함)를 더 많이 거치므로 회귀 방지가 단위 테스트보다 우수하다. 또한 제품 코드와의 결합도가 낮아서 리팩터링 내성도 우수하다.
단위 테스트와 통합 테스트의 비율은 프로젝트의 특성에 따라 다를 수 있지만, 일반적인 경험에 비춰본 규칙은 다음과 같다. 단위 테스트로 가능한 한 많이 비즈니스 시나리오의 예외 상황을 확인하고, 통합 테스트는 주요 흐름(happy path)과 단위 테스트가 다루지 못하는 기타 예외 상황(edge case)을 다룬다.
통합 테스트에서 프로세스 외부 의존성과의 상호 작용을 모두 확인하려면 가장 긴 주요 흐름을 선택하라. 이렇게 모든 상호 작용을 거치는 흐름이 없으면, 외부 시스템과의 통신을 모두 확인하는 데 필요한 만큼 통합 테스트를 추가로 작성하라.
좋지 않은 테스트를 작성하는 것보다는 테스트를 작성하지 않는 것이 좋다. 가치가 별로 없는 테스트는 좋지 않은 테스트다.
좋지 않은 테스트여도 없는 것 보단 낫겠다고 생각한 게 많았는데 반성하게 되었다.
모든 프로세스 외부 의존성은 두 가지 범주로 나뉜다.
* 관리 의존성(전체를 제어할 수 있는 프로세스 외부 의존성): 이러한 의존성은 애플리케이션을 통해서만 접근할 수 있으며, 해당 의존성과의 상호 작용은 외부 환경에서 볼 수 없다. 대표적인 예로 데이터베이스가 있다.
* 비관리 의존성(전체를 제어할 수 없는 프로세스 외부 의존성): 해당 의존성과의 상호 작용을 외부에서 볼 수 있다. 예를 들어 SMTP 서버와 메시지 버드 등이 있다.
관리 의존성은 실제 인스턴스를 사용하고, 비관리 의존성은 목으로 대체하라.
관리 의존성을 목으로 대체하면 통합 테스트의 리팩터링 내성이 저하되기 때문이다. 게다가 이렇게 하면 테스트는 회귀 방지도 떨어진다.
인터페이스를 사용하는 일반적인 이유는 인터페이스가
* 프로세스 외부 의존성을 추상화해 느슨한 결합을 달성하고,
* 기존 코드를 변경하지 않고 새로운 기능을 추가해 공개 폐쇄 원칙(OCP)을 지키기 때문이다.
이 두 가지 이유 모두 오해다.
진정한 추상화는 발견하는 것이지, 발명하는 것이 아니다. 의미상 추상화가 이미 존재하지만 코드에서 아직 명확하게 정의되지 않았을 때 그 이후에 발견되는 것이다. 따라서 인터페이스가 진정으로 추상화되려면 구현이 적어도 두 가지는 있어야 한다.
두 번째 이유(기존 코드를 변경하지 않고 새로운 기능을 추가하는 것)는 더 기본적인 원칙인 YAGNI(You aren't gonna need it의 약자)를 위반하기 때문에 잘못된 생각이다. YAGNI는 현재 필요하지 않은 기능에 시간을 들이지 말라는 것이다.
각 인터페이스에 구현이 하나만 있다고 가정할 때 프로세스 외부 의존성에 인터페이스를 사용하는 이유는 무엇일까? 진짜 이유는 훨씬 더 실용적이고 현실적이다. 간단히 말하자면, 목을 사용하기 위함이다.
따라서 이러한 의존성을 목으로 처리할 필요가 없는 한, 프로세스 외부 의존성에 대한 인터페이스를 두지 말라. 비관리 의존성만 목으로 처리하므로, 결국 비관리 의존성에 대해서만 인터페이스를 쓰라는 지침이 된다.
코드베이스의 유지 보수성을 대폭 개선하고 테스트를 더 쉽게 할 수 있는 또 다른 방법으로 순환 의존성을 제거하는 것이 있다.
부작용을 고객이나 애플리케이션의 클라이언트 또는 개발자 이외의 다른 사람이 보는 경우라면, 로깅은 식별할 수 있는 동작이므로 반드시 테스트해야 한다. 하지만 보는 이가 개발자뿐이라면, 아무도 모르게 자유로이 수정할 수 있는 구현 세부 사항이므로 테스트해서는 안 된다.
9장 목 처리에 대한 모범 사례
목을 사용할 때 항상 다음 지침을 따르자. 시스템 끝에서 비관리 의존성과의 상호 작용을 검증하라.
비관리 의존성에 대한 호출은 애플리케이션을 떠나기 전에 몇 단계를 거친다. 마지막 단계를 선택하라. 외부 시스템과의 하위 호환성을 보장하는 가장 좋은 방법이며, 하위 호환성은 목을 통해 달성할 수 있는 목표다.
목이 통합 테스트만을 위한 것이며 단위 테스트에서 목을 사용하면 안 된다는 지침은 7장에서 설명한 기본 원칙인 비즈니스 로직과 오케스트레이션의 분리에서 비롯된다.
10장 데이터베이스 테스트
데이터베이스를 테스트하는 방법의 첫 번째 단계는 데이터베이스 스키마를 일반 코드로 취급하는 것이다. 일반 코드와 마찬가지로 데이터베이스 스키마는 Git과 같은 형상 관리 시스템에 저장하는 것이 최선이다.
데이터베이스 배포에는 상태 기반과 마이그레이션 기반이라는 두 가지 방식이 있다. 마이그레이션 기반 방식은 초기에는 구현하고 유지 보수하기가 어렵지만 장기적으로 상태 기반 방식보다 훨씬 효과적이다.
상태 기반 방식을 정확히 어떻게 하는지 모르겠지만 마이그레이션 기반 방식이 당연하다고 느낄 정도로 익숙하긴 하다.
Database 클래스를 리포지터리(repository)와 트랜잭션으로 나눠서 이러한 책임을 구분할 수 있다.
* 리포지터리는 데이터베이스의 데이터에 대한 접근과 수정을 가능하게 하는 클래스다.
* 트랜잭션은 데이터 업데이트를 완전히 커밋하거나 롤백하는 클래스다.
인메모리 데이터베이스는 일반 데이터베이스와 기능적으로 일관성이 없기 때문에 사용하지 않는 것이 좋다. 이는 또 다시 운영 환경과 테스트 환경이 일치하지 않는 문제이며, 일반 데이터베이스와 인메모리 데이터베이스의 차이로 인해 테스트에서 거짓 양성 또는 (더 나쁜!) 거짓 음성이 발생하기 쉽다.
쓰기를 철저히 테스트하는 것이 매우 중요하다. 왜냐하면 위험성이 높기 때문이다. 쓰기 작업이 잘못되면 데이터가 손상돼 데이터베이스뿐만 아니라 외부 애플리케이션에도 영향을 미칠 수 있다. 쓰기를 다루는 테스트는 이러한 실수에 대비한 보호책이 되므로 매우 가치가 있다.
그러나 읽기는 이에 해당하지 않는다. 읽기 작업의 버그에는 보통 해로운 문제가 없다. 따라서 읽기 테스트 임계치는 쓰기 테스트 임계치보다 높아야 한다. 가장 복잡하거나 중요한 읽기 작업만 테스트하고, 나머지는 무시하라.
리포지터리는 직접 테스트하지 말고, 포괄적인 통합 테스트 스위트의 일부로 취급하라.
4부 단위 테스트 안티 패턴
11장 단위 테스트 안티 패턴
단위 테스트와 관련해 자주 받는 질문 중 하나는 '비공개 메서드를 어떻게 테스트하는가?'이다. 짧게 대답하면 '전혀 하지 말아야 한다.'이지만, 이 주제는 약간의 뉘앙스를 담고 있다.
비공개 메서드를 노출하면 테스트가 구현 세부 사항과 결합되고 결과적으로 리팩터링 내성이 떨어진다. 비공개 메서드를 직접 테스트하는 대신, 포괄적인 식별할 수 있는 동작으로서 간접적으로 테스트하는 것이 좋다.
때로는 비공개 메서드가 너무 복잡해서 식별할 수 있는 동작으로 테스트하기에 충분히 커버리지를 얻을 수 없는 경우가 있다. 식별할 수 있는 동작에 이미 합리적인 테스트 커버리지가 있다고 가정하면 다음 두 가지 문제가 발생할 수 있다.
* 죽은 코드다. 테스트에서 벗어난 코드가 어디에도 사용되지 않는다면 리팩터링 후에도 남아서 관계없는 코드일 수 있다. 이러한 코드는 삭제하는 것이 좋다.
* 추상화가 누락돼 있다. 비공개 메서드가 너무 복잡하면 별도의 클래스로 도출해야 하는 추상화가 누락됐다는 징후다.
비공개 메서드를 테스트하는 것 자체는 나쁘지 않다. 비공개 메서드가 구현 세부 사항의 프록시에 해당하므로 나쁜 것이다. 구현 세부 사항을 테스트하면 궁극적으로 테스트가 깨지기 쉽다.
또 다른 일반적인 안티 패턴으로 단위 테스트 목적으로만 비공개 상태를 노출하는 것이 있다. 이 지침은 비공개로 지켜야 하는 상태를 노출하지 말고 식별할 수 있는 동작만 테스트하라는 비공개 메서드 지침과 같다.
테스트틑 제품 코드와 정확히 같은 방식으로 테스트 대상 시스템(SUT)과 상호 작용해야 하며, 특별한 권한이 따로 있어서는 안 된다.
도메인 지식을 테스트로 유출하는 것은 또 하나의 흔한 안티 패턴이며, 보통 복잡한 알고리즘을 다루는 테스트에서 일어난다.
코드 오염은 테스트에만 필요한 제품 코드를 추가하는 것이다.
코드 오염의 문제는 테스트 코드와 제품 코드가 혼재돼 유지비가 증가하는 것이다.
구체 클래스를 대신 목으로 처리해서 본래 클래스의 기능 일부를 보존할 수 있으며, 이는 때때로 유용할 수 있다. 그러나 이 대안은 단일 책임 원칙을 위배하는 중대한 단점이 있다.
일부 기능을 지키려고 구체 클래스를 목으로 처리해야 하면, 이는 단일 책임 원칙을 위반하는 결과다.
더 나은 방법으로 (앰비언트 컨텍스트에서 정적 메서드를 통해 참조하는 대신) 다음 예제와 같이 서비스 또는 일반 값으로 시간 의존성을 명시적으로 주입하는 것이 있다.
이 두 가지 옵션 중에서 시간을 서비스로 주입하는 것보다는 값으로 주입하는 것이 더 낫다.
비즈니스 연산을 시작할 때는 서비스로 시간을 주입한 다음, 나머지 연산에서 값으로 전달하는 것이 좋다.
Comments