[Effective Unit Testing] Chap5. 유지보수성 |
-
코드는 쓰이는 횟수보다 읽히는 횟수가 훨씬 많다.
그리고 현실에서의 작성의 대부분은 기존 코드를 수정하거나 확장하는 걸 뜻한다.
이를 유지보수라 하기도 하고 개발이라 부르기도 한다.
-
테스트도 태생은 제품 코드와 다를 바 없는 코드인지라, 근본적으로 똑같이 불안정하다.
자동화된 단위 테스트를 작성할 때도 이런 취약성에 주의하면서 관리해야 한다.
5.1. 중복
-
모든 악의 근원 넘버원은 “어설픈 최적화” 이고, 넘버투는 “중복(Duplication)이다.
5.1.1. 예시
-
상수 중복은 given 과 then 의 상수를 따로 정의해서 쓰는 것을 이야기한다.
상수 중복은 지역 변수로 만들어서 제거할 수 있다.
5.1.2. 개선 방법
-
구조 중복과 의미 중복도 리펙토링 대상이다.
5.1.3. 정리
-
중복 제거도 과유불급이 될 수 있다.
최우선 목표는 코드를 읽기 쉽게 유지하여 읽는 이에게 그 의도와 기능을 명확히 전달하는 것이다.
이 사실을 잊지 않는다면 가독성을 위해 일부러 중복을 남겨둬야 할 상황도 있다는 것을 이해할 수 있다.
5.2. 조건부 로직
-
조건부 로직(Conditional logic)을 포함한 테스트는 제 역할을 못할 가능성이 높다.
5.2.1. 예시
5.2.1. 개선 방법
5.2.3. 정리
-
우리는 코드가 해야 할 일과 동작을 파악할 때에도 테스트를 활용한다.
우리가 실수하면 테스트가 바로 알려줄 거라 믿고, 안심하고 코드를 고치기도 한다.
조건부 로직은 이 두 가지 모두를 더 어렵게 하고 오류 발생 가능성을 높인다.
-
코드에 조건부 로직이 있으면 코드가 훨씬 어려워진다.
테스트 메서드는 if, else, for, while, switch 같은 조건부 실행 구조를 가져서는 안 된다.
이는 테스트 대상의 동작이 복잡할수록 더 중요하다.
-
조건문이 필요한 곳은 분명 있지만, 테스트 코드에서는 주의해야 한다.
테스트를 버리지 않고 오랫동안 안고 가고 싶다면 테스트를 소비자용 사용설명서보다도 쉽게 만들어야 한다.
5.3. 양치기 테스트
-
테스트라면 우리가 실수를 저지르는 즉시 경고해주리라는 믿음을 주어야 한다.
-
가장 지독한 테스트 냄새는 매번 실패하는 데도 대수롭지 않게 취급되는 테스트다.
이런 테스트는 누군가가 고치거나, 더 높은 확률로, 삭제하기 전까지, 앞으로 쭉 어쩌면 수개월이 지나도록 계속 실패하는 상태일 것이다.
매번 실패하는 테스트는 있으나 마나하다.
새로운 사실이나 신뢰할만한 어떠한 정보도 알려주지 못한다.
-
간헐적으로 실패하는 양치기 테스트(Flaky test)도 개발자의 삶을 고달프게 만든다.
이런 테스트는, 테스트가 실패해서 빌드가 중단되었는데, 막상 다시 돌려보면, 때마침 운이 좋아 통과할 수도 있다.
5.3.1. 예시
-
양치기를 만나기 좋은 상황은 테스트가 race condition 을 가진 스레드를 사용하거나, 현재 날짜나 시간에 따라 동작이 달라지거나, 입출력 속도가 테스트 실행 당시의 CPU 부하 등의 컴퓨터의 성능의 영향을 받는 경우가 대부분이다.
네트워크로 원격 자원에 접근하는 테스트도 네트워크나 그 자원에 일시적인 장애가 발생하면 양치기가 된다.
5.3.2. 개선 방법
-
시간이 문제인 경우, 특히나 조금 기다려줘야 테스트가 통과하는 경우라면 놀랍도록 많은 프로그래머가 Thread#sleep 을 애용한다.
Thread#sleep 도 비결정적이라 충분히 시간이 흐르리라고는 보장할 수 없고, 확실히 하기 위해 오래 쉬면 이는 테스트 쉬트가 서서히 느려지는 결과를 가져온다.
-
난수 발생기가 끼어 있거나, 멀티스레딩을 마딱뜨리는 경우는 다음과 같은 방법으로 벗어나야 한다.
1. 회피한다.
2. 제어한다.
3. 격리한다.
5.3.3. 정리
-
시스템 시계, 테스트를 돌리는 시각, 공유 자원에 동시 접근, 난수 발생기 등 불규칙한 실패의 원인이 무엇이건, 우리에게는 쓸만한 카드가 몇 개 있다.
문제를 피해 살짝 돌아갈 수도 있고, 비결정적 행위의 원인을 제어하거나, 골치 아픈 코드만 따로 격리 조치할 수 있다.
특히 스레드 스케줄링은 예측할 수 없다는 본질적인 문제가 있다.
5.4. 파손된 파일 경로
-
파손된 파일 경로(Cripping File Path)는 코드를 움직이지 못하게 꽁꽁 동여매어 개발자 컴퓨터 외에는 그 어디에서도 돌아가지 못하게 한다.
5.4.1. 예시
5.4.2. 개선 방법
-
테스트 코드에서 파일을 다룰 때 절대 경로는 무조건 피해야 한다.
용인될 수 있는 상황도 있을지 모르겠지만, 모두 없애버리는 게 기본 원칙이다.
가능하면 반드시 상대 경로를 쓰고, 정 안되면 시스템 속성이나 환경 변수로 한 단계 더 추상화하여 접근하라.
-
파일을 스트림으로 대체하라.
File 대신 InputStream, OutputStream 을 사용하면 이들이 인터페이스이기 때문에 원하면 언제든 테스트 더블로 교체할 수 있다는 장점이 있다.
5.4.3. 정리
-
한 운영체제에서만 동작하는 파일의 절대 경로를 좋게 보는 사람은 없다.
-
기본적으로 프로젝트에 필요한 모든 자원은 프로젝트 루트 디렉터리의 하위에 두는 걸 원칙으로 하라.
이렇게 하면 코드와 빌드 스크립트 어디에서건 상대 경로를 사용할 수 있는 기반이 갖춰진다.
또한, 테스트 코드에서만 사용하는 데이터 파일은 테스트 코드와 같은 위치에 두고 클래스패스로 접근하면 편하다.
5.5. 끈질긴 임시 파일
-
끈질긴 임시 파일(Persistent temp files) 테스트 냄새란 임시 파일이 임시적이지 않고 끈덕진 경우다.
다시 말해, 다음번 테스트 수행 시까지도 지워지지 않고 버티고 있는 상황을 말한다.
-
가정(assumption)은 일을 그르치는 지름길이다.
그런데 우리 프로그래머는 임시 파일이 정말 임시적이라 가정하곤 한다.
덕분에 디버깅하고 싶지 않은 깜짝 놀랄 상황이 발생하곤 한다.
사실, 테스트 목적상 꼭 필요한 경우만 아니라면 파일을 절대 사용하지 않는 것이 가장 좋다.
5.5.1. 예시.
5.5.2. 개선 방법
-
파일 사용은 무조건 최소한으로 자제해야 한다.
파일 I/O 를 사용하면 문자열 등의 인메모리 데이터를 다룰 때보다 테스트가 현저히 느려진다.
명심해야 한다.
그리고 테스트 안에서 임시 파일을 사용하고 싶을 때면 임시 파일이 말처럼 그리 임시적이지 않다는 사실을 떠올리자.
-
임시파일에 대처하는 간단한 지침은..
@Before 메서드에서 임시 파일 삭제
가능하면 임시 파일명도 고유하게 짓자.
파일이 있어야 하는지를 명시하자.
5.5.3. 정리
-
파일을 다루는 테스트의 문제 중 하나는 파일시스템이란 것이 모든 테스트가 공유하는 자원이라는 사실이다.
따라서 테스트끼리 서로 방해하는 상황이 발생할 수 있다.
-
피해를 최소화하려면 티어다운 메서드를 이용해서 테스트가 생성한 파일 모두를 지워줘야 한다.
또한 테스트 각각이 고유한 파일명을 사용하면 테스트 찌꺼기가 유발하는 문제를 상당히 줄일 수 있다.
한가지 더, 파일이 있어야 하는지를 명시한다면 테스트의 가독성과 유지보수성 모두가 크게 좋아질 것이다.
-
가장 중요한 해법은 테스트의 목적상 반드시 필요한 경우만 아니라면 절대로 파일을 사용하지 않는 것이다.
5.6. 잠자는 달팽이
-
메모리상의 데이터를 읽고 쓰는 것에 비하면 파일 조작은 끔찍할 정도로 느리다.
느려터진 테스트는 유지보수 입장에서는 치명적인 단점이다.
기능 추가나 버그 수정 등, 코드를 변경할 때마다 테스트 스위트를 반복해서 돌려봐야 하기 때문이다.
-
테스트 속도에 더 큰 영향을 주는 범인 Thread#sleep 은 기대하는 결과나 부수효과를 얻기 위해 다른 스레드가 일을 끝마치기를 기다리기 위해 자주 사용한다.
이러한 잠자는 달팽이(Sleeping snail)는 아주 쉽게 찾아낼 수 있다.
코드에서 thread#sleep 호출문을 찾아보고, 이례적으로 느린 테스트가 있는지 지켜보면 된다.
불행인 점은, 제거하기가 쉽지 않다는 것이다.
그러나 어렵긴 해도 충분히 시도해볼 만하고, 성공했을 때의 보상은 확실하다.
5.6.1. 예시
5.6.2. 개선 방법
-
Thread#sleep 이 느리다고 하는 이유는 작업 스레드가 종료되는 시간과 주 테스트 스레드가 이를 알아채는 시간까지의 차이가 있기 때문이다.
작업 스레드는 10밀리 초만에 끝날 때도 있지만, 어떤 때는 수백 밀리 초 혹은 그 이상이 걸리기도 한다면 어떨까? 버려지는 시간이 아깝더라도 최소 수백 밀리 초씩은 항상 기다려야 한다.
5.6.3. 정리
-
잠자는 달팽이란 다른 스레드가 완료되기를 기다리느라 Thread#sleep 으로 긴 시간을 허비한 후에야 다음 단계를 진행하는, 아주 느릿느릿한 테스트를 말한다.
스레드 실행 속도와 스케줄링 순서가 일정하지 않으니 작업 스레드의 평균적인 완료 시간보다 훨씬 오래 쉬어야 한다.
-
테스트 스레드는 작업 스레드가 일을 마치는 즉시 알 수 있어야 한다.
이를 이용하면 Thread#sleep 꼼수 때문에 버려지는 시간을 되찾을 수 있다.
5.7. 픽셀 퍼펙션
-
픽셀 퍼펙션(Pixel perfection)은 기본 타입 단언과 매직 넘버의 특수한 형태다.
이 녀석은 검증하기 어렵기로 유명한 컴퓨터 그래픽스 분야에서 상당히 빈번하게 발견되는 냄새이다.
기대한 것과 완벽하게 일치하는 이미지가 만들어졌는지 확인해야 하거나 생성된 이미지의 특정 픽셀이 원하는 색을 띠는지 확인하려는 테스트가 자주 픽셀 퍼펙션 냄새를 풍긴다.
이렇게 만들어진 테스트는 불안정하기로 악명이 높고 실제로도 입력에 조금만 변화를 주어도 쉽게 실패하게 된다.
5.7.1. 예시
5.7.2. 개선 방법
-
테스트를 작성하는 목적 중에는 대상을 적절히 추상화하여 표현하는 것도 포함된다.
예를 들어 두 상자가 선으로 이어져 있는지가 관심사라면 점이나 좌표라는 개념은 입 밖에 꺼낼 필요도 없다.
대신 상자와 연결 여부를 언급해야 한다.
5.7.3. 정리
-
픽셀 퍼펙션은 이름에서처럼 그래픽스나 그래픽을 출력하는 코드를 검사하는 테스트가 풍기는 냄새다.
이는 매직 넘버와 기본 타입 단언이 혼재하여 읽기가 극도로 어려울 뿐 아니라 쉽게 망가지는 테스트를 만들게 된다.
이런 테스트는 아무리 읽어봐도 제대로 이해하기 어렵다.
의미상으로는 훨씬 상위 개념을 검사하면서도 실제로는 점의 좌표와 색상처럼 훨씬 낮은 수준의 개념을, 그것도 하드코딩해서 검사하기 때문이다.
-
픽셀 퍼펙션은 망가지기도 쉽다.
사소한 입력의 변화가 만들어 내는 미묘한 출력의 차이도, 점 하나하나의 좌표와 색상까지 꼼꼼히 검사하는 테스트에서는 곧바로 실패로 이어지기 때문이다.
-
사소한 값 비교에 집착해서 잘 부서지는 테스트보다 포괄적인 의미를 대조하는, 영리한 알고리즘을 사용하는 테스트를 해야 한다.
5.8. 파라미터화된 혼란
-
폭넓은 지식을 쌓고 기술을 익히며 개발 능력을 키우는 것은 개발로 먹고사는 전문 프로그래머로서의 우리 의무다.
그런데 너무 멋져 보여서 과용하게 만드는 기술도 있다.
방금 익힌 멋진 기법을 활용할 수 있는 희박한 가능성이나 핑계를 찾으려 애쓰면서까지 말이다.
단위 테스트, 그 중에서도 JUnit 4 와 관련하여 가장 많이 남용하는 기법으로 파라미터화된 테스트 패턴 (parameterized Test Pattern)을 들 수 있다.
-
파라미터화된 테스트 패턴이란 데이터를 아주 조금씩만 바꿔가며 수차례 반복 검사하는 데이터 중심 테스트가 있을 때 중복을 없애주는 기법이다.
예를 들어 대상 코드에 넘길 입력값과 기대값만 다르고 나머지는 전부 같은 테스트 메서드 13개가 하나의 테스트 클래스에 모여있다고 생각해보자.
그렇다면 이 테스트 클래스를 파라미터화된 테스트로 바꿔서 반복되는 부분을 테스트 프레임워크가 처리하도록 위임할 수 있다.
파라미터화된 테스트 패턴이 명료하고 관리하기 쉬운 테스트 코드를 만들어주는 다른 방법에 비해 확실히 유리한 상황은 딱 하나뿐이다.
바로 실행 전까지는 정확한 테스트 데이터를 알 수 없는 경우다.
하지만 단위 테스트에서는 거의 찾아볼 수 없는 일이다.
-
파라미터화된 테스트는 멋진 패턴이다.
하지만 너무 과하게, 그것도 잘못된 상황에서 사용하면 테스트 냄새로 돌변한다.
즉, 코드도 이해하기 어렵고 실패한 테스트를 정확히 끄집어내기도 어려운 파라미터화된 혼란(parameterized mess)이 된다.
5.8.1. 예시
-
Parameterized test 러너는 아래와 같이 사용한다.
@RunWith(Parameterized.class) public class RomanNumeralsTest{ private int number; private String numeral; public RomanNumeralsTest(int number, String numeral){ this.number = number; this.numeral = numeral; } @Parameters public static Collection<Object[]> data(){ return asList(new Object[][] { { 1, “I” }, {2, “II” }, … {10, “X”} }); } @Test public void formatsPositiveIntegers(){ assertEquals(numeral, format(number)); } }
-
Parameterized 테스트 러너가 동적으로 자동 생성한 테스트는 근본적으로 익명이며 수행된 순서 외에는 구분할 수 있는 표식이 전혀 없다.
-
인식할 수 있는 이름이나 식별자 없이는 어떤 파라미터 집합을 이용한 테스트가 실패한 것인지 알아내기 어렵다
의지할 수 있는 유일한 단서는 단언문의 실패 메시지다.
예를 들어 오류 메시지에 어떤 테스트 케이스가 실패했는지 구분할 수 있을 만큼의 상세 정보를 담을 수 있다.
이 방법에도 한계가 있는데, 만약 두 개 이상의 값을 비교하는 경우라면 그 값이 다른 데이터 집합에도 등장할 수 있어, 그만큼 후보가 늘어나는 것이다.
5.8.2. 개선 방법
-
파라미터화된 테스트 패턴을 적용하기에 앞서 반드시 다시 한번 숙고해보는 것이 좋다.
5.8.3. 정리
-
파라미터화된 테스트 패턴이란 입력값과 출력값만 다른 다수의 테스트가 반복되는 걸 간결하게 줄여주는 기법이다.
이 패턴이 가장 많이 활용되는 영역은 검증, 변환, 문자열 처리와 모든 형태의 수학 분야 등이다.
그렇지만 논리도 분산되고 테스트를 실패하게 만든 데이터가 무엇인지 찾기 어려워서 코드를 간소화해 얻은 이점이 상쇄되는 경우도 많다.
-
그래도 어쩔 수 없이 파라미터화된 테스트를 사용하기로 했을 때를 위해 부작용을 최소화하는 팁 세가지가 있다.
개별 데이터 집합을 서로 시각적으로 구분하길 원한다면 각 데이터 집합을 메서드 호출로 감싸자. 데이터 집합 리스트를 만들 때 가장 흔히 쓰는 구불구불한 중괄호에 비해 메서드 호출 방식이 눈에 잘 들어올 것이다.
들여쓰기를 활용해 데이터 집합을 구분하는 방법도 있다.
요즘의 IDE 는 코드의 들여쓰기를 자동으로 정리해주는 강력한 기능을 제공한다.
반대로 심혈을 기울여 손수 쓴 코드를 망쳐놓기도 한다.
이 때문에 각 줄 끝에 짧은 주석을 달아 놓으면 수작업한 들여쓰기를 IDE 로부터 보호할 수 있다.
마지막으로 단언문의 오류 메시지를 이용해서 데이터 집합만으로 실패한 테스트를 구분하지 못하는 JUnit 의 한계를 극복하자.
테스트 케이스가 사용하는 데이터 집합의 모든 값을 단언 메시지에 포함시키면 실패한 시나리오를 빠르게 알아낼 수 있다.
5.9. 메서드 간 응집력 결핍
-
응집력(cohesion)은 잘 작성된 객체지향 코드의 핵심 특성이다.
간단히 말하면, 응집력이란 클래스 하나는 사물 하나, 즉 하나의 추상화 개념을 표현한다는 뜻이다.
그래서 우리는 강한 응집력을 원한다.
이와 같은 응집력은 바람직하며, 따라서 코드 냄새를 식별하는 수단으로 활용되기도 한다.
-
메서드 간이라 함은 같은 클래스에 속한 메서드 간의 공통점이 많으냐를 기준으로 응집력의 강약을 정한다는 의미다.
메서드 간 응집력 결핍(Lack of Cohesion)을 계산하는 방법에도 여러 가지가 있지만, 클래스의 메서드 모두가 그 클래스에 선언된 필드 전부를 사용하는 것을 완전무결한 응집력이 기준으로 삼는다는 점에서는 모두 일치한다.
-
단위 테스트 관점에서 해석하면, 한 클래스의 모든 테스트가 같은 픽스처를 이용해야 한다고 표현할 수 있다.
반대로 말하면 다른 픽스처를 이용하는 테스트 메서드는 독립된 테스트 클래스로 나눠야 한다.
5.9.1. 예시
5.9.2. 개선 방법
5.9.3. 정리
-
메서드 간 응집력 결핍이란 테스트 클래스 하나에 속한 테스트 메서드들이 서로 다른 픽스처 객체를 사용한다는 뜻이다.
이러한 테스트를 만들거나 수정해야 하는 프로그래머는 필요 이상으로 많은 객체를 다뤄야만 한다.
코드가 복잡해진 탓에 각 필드의 역할이 무엇인지, 어느 테스트가 어느 픽스처 객체를 사용하는지, 셋업에서는 어느 객체를 어떻게 설정해야 하는지 알기 어렵다.
다수의 테스트가 한 객체에[ 달려들어 사용하기 시작하면 적절한 그 객체에 이름을 지어 주기도 점점 어려워진다.
-
복잡 다양한 픽스처 조합이 필연적이라 판단되면, 이 응집력 결핍 현상을 없앨 방법은 두 가지가 남는다.
1. 새로운 테스트 클래스를 만들어서 테스트 메서드 일부를 옮기고, 필요하다면 공통 로직을 담아둘 기반 클래스를 추출한다.
2. 별도의 클래스가 제공하는 유틸리티 메서드를 이용해서 테스트 메서드 각자가 필요한 픽스처를 직접 생성한다.
5.10. 요약
-
중복이 모든 악의 근원이고, 테스트 코드에서도 중복 하나하나가 유지보수를 훨씬 어렵게 만든다.
테스트 코드에 조건부 로직이 있으면 테스트가 실제 수행한 일이 무엇인지 알 수 없게 되어 많은 시간을 뺏긴다.
-
간헐적으로 실패하는 양치기 테스트도 있고, 다른 컴퓨터에서는 실패하게 만드는 파일 경로 문제도 있다.
파일 경로와 관련해서 지워지지 않는 끈질긴 임시 파일도 빼놓을 수 없다.
끈질긴 임시 파일은 사라지지 않고 주변을 배회하다가 이들이 지워졌으리라 가정하고 있는 다음 테스트에 큰 혼란을 안겨준다.
-
달팽이라는 냄새는 Thread#sleep 을 호출하는 멀티스레드 코드에서 자주 나타난다.
이 냄새는 원래 훨씬 빨랐어야 할 테스트가 다른 일을 기다리며 가만히 멈춰있게 한다.
-
픽셀 퍼펙션을 그래픽스 분야에서 사용되는 과도하게 정밀한 단언문이다.
컴퓨터 그래픽스는 매직 넘버와 기본 타입 단언을 조합해서 비눗방울만큼이나 터지기 쉬운 광역 단언을 만들어 냈다.
-
파마리터화된 혼란을 분석해본 결과 JUnit 의 Parameterized 테스트 러너는 조심히 다뤄야 한다는 교훈을 얻었다.
또한, 이 러너가 동적으로 만들어낸 익명 테스트 중 하나가 실패할 때 혹은 테스트 케이스를 수정하려 할 때 유용한 팁도 있다.
-
테스트에서 응집력이 의미하는게 무엇인지, 그리고 그것이 부족해서 곤란해진 상황에 대처하는 방법을 알아야 한다.
'프로그래밍 놀이터 > 안드로이드, Java' 카테고리의 다른 글
[Effective Unit Testing] Chap7. 테스트 가능 설계 (0) | 2019.03.17 |
---|---|
[Effective Unit Testing] Chap6. 신뢰성 (0) | 2019.03.15 |
[Effective Unit Testing] Chap4. 가독성 (0) | 2019.03.13 |
[Effective unit Testing] Chap3. 테스트 더블 (0) | 2019.02.28 |
[Effective Unit Testing] Chap2. 좋은 테스트란? (0) | 2019.02.27 |
댓글