실제 오브젝트와 같이 테스트는 손상될 수 있습니다. 낡은 것이 아니라 환경이 변경된 것입니다. 새 운영 체제에 이식되었을 수도 있습니다. 또는 연습한 코드를 정확히 변경한 것이 테스트 실패의 원인이 될
수 있습니다. e-banking 응용프로그램의 버전 2.0에서 작업하고 있다고 가정해 보십시오. 버전 1.0에서는 이 메소드를 사용하여 다음과 같이 로그인했습니다.
public boolean login (String username);
버전 2.0를 통해 마케팅 부서에서 암호 보호가 바람직함을 인식하게 되었습니다. 따라서 메소드가 다음과 같이 변경되었습니다.
public boolean login (String username, String password);
로그인을 사용하는 모든 테스트는 실패하며 컴파일하지도 않습니다. 이 시점에서는 가능한 유용한 작업이 많지 않으므로 로그인 없이 작성할 수 있는 유용한 테스트도 많지 않습니다. 수백 번 테스트에 실패하게 됩니다.
이 테스트는 글로벌 검색 및 바꾸기 도구를 사용하여 login(something)의 모든 인스턴스를 찾아 login(something, "dummy password")로 바꾸도록 수정할 수
있습니다. 그 다음 해당 암호를 사용하도록 모든 테스트 계정을 준비하면 이제 사용 준비가 완료됩니다.
마케팅에서 암호에 공백을 포함할 수 없다고 결정하면 처음부터 전부 다시 작성하게 됩니다.
이러한 방법은 낭비에 가깝습니다. 특히 테스트를 쉽게 변경할 수 없는 경우 더욱 그렇습니다. 더 나은 방법이 있습니다.
테스트가 원래 제품의 login 메소드를 호출하지 않는다고 가정해 보십시오. 대신 로그인했던 테스트를 사용하여 진행할 준비를 하는 데 사용하는 라이브러리 메소드를
호출합니다. 처음에 이 메소드는 다음과 같을 수 있습니다.
public boolean testLogin (String username) {
return product.login(username);
}
버전 2.0로 변경하면 유틸리티 라이브러리는 다음과 같이 변경됩니다.
public Boolean testLogin (String username) {
return product.login(username
, "dummy password");
}
수천 개의 테스트를 변경하는 대신, 하나의 메소드만 변경합니다.
이상적으로는 필요한 모든 라이브러리 메소드는 테스트 노력 초반에 사용 가능합니다. 그러나 실제로는 모두 그렇지 않습니다. 제품의 login을 처음 변경할 때까지 testLogin 유틸리티 메소드가 필요하다는 사실도 인식하지 못할 것입니다. 따라서 테스트 유틸리티 메소드는 종종 기존 테스트에서 필요한 대로 "분리"되었습니다. 스케줄의 압박이
있었다고 해도, 이 계속적 테스트 복구를 수행하는 것은 매우 중요합니다. 복구하지 않으면 바람직하지 않고 관리할 수 없는 테스트 스위트를 다루는 데 많은 시간을 낭비하게 됩니다. 주어진 테스트 시간을
이전 테스트를 유지보수하는 데 소모하느라 새 테스트는 옆으로 제쳐두거나 필요한 수만큼 작성하지 못할 수도 있습니다.
참고: 제품의 login 메소드 테스트는 여전히 이를 직접 호출합니다. 동작이 변하면 테스트의 일부 또는 전체가 갱신될 필요가 있을 수 있습니다(동작이 변해도
login 테스트에 실패하지 않은 경우 결함을 제대로 발견하지 못하는 것일 수 있습니다.)
이전 예제에서는 구체적인 응용프로그램에서 테스트로 추상화할 수 있는 방법을 표시했습니다. 대부분의 경우 더 많이 추상화할 수 있습니다. 여러 테스트에서 메소드 호출 시 공통된 시퀀스(로그인하고 어떤 상태를 설정하며
테스트하는 응용프로그램의 파트를 탐색하는 등)로 시작함을 알게 될 수 있습니다. 그 다음부터 각 테스트마다 수행하는 작업이 달라집니다. 이러한 모든 설정은 readyAccountForWireTransfer과 같이 내용을 의미하는 이름을 사용하는 단일 메소드에 포함될 수 있으며 포함되어야 합니다. 이 방법을 사용하면 특정 유형의 새
테스트를 작성할 때 상당한 시간을 절약할 수 있습니다. 또한 각 테스트의 의도를 좀 더 이해하기 쉽게 만들 수 있습니다.
테스트는 이해할 수 있어야 합니다. 이전 테스트 스위트의 공통된 문제점은 테스트 수행 작업 또는 이유를 아무도 알지 못했다는 점입니다. 손상되면 가장 단순한 방식으로 문제점을 수정하려는 경향이 있습니다. 이 때문에
종종 결함을 제대로 찾지 못하는 테스트가 생깁니다. 더 이상 원래 테스트하려던 내용을 테스트하지 못하게 됩니다.
컴파일러를 테스트하고 있다고 가정해 보십시오. 처음 작성되는 클래스 중 일부는 컴파일러의 내부 구문 분석 트리를 정의하고 이를 변환합니다. 구문 분석 트리를 구성하고 변환을 테스트하는 여러 테스트가 있습니다. 이
중 하나의 테스트는 다음과 같습니다.
/*
* Given
* while (i<0) { f(a+i); i++;}
* "a+i" cannot be hoisted from the loop because
* it contains a variable changed in the loop.
*/
loopTest = new LessOp(new Token("i"), new Token("0"));
aPlusI = new PlusOp(new Token("a"), new Token("i"));
statement1 = new Statement(new Funcall(new Token("f"), aPlusI));
statement2 = new Statement(new PostIncr(new Token("i"));
loop = new While(loopTest, new Block(statement1, statement2));
expect(false, loop.canHoist(aPlusI))
이 테스트는 읽기 어렵습니다. 시간이 흘렀다고 가정해 보십시오. 무언가 변경되어 테스트를 갱신해야 합니다. 이 시점에는 더 많은 제품 하부 구조를 가집니다. 특히 문자열을 구문 분석 트리로 전환하는 구문 분석
루틴이 있을 수도 있습니다. 이 경우 해당 루틴을 사용하도록 테스트를 완전히 다시 작성하는 것이 좋습니다.
loop=Parser.parse("while (i<0) { f(a+i); i++; }");
// Get a pointer to the "a+i" part of the loop.
aPlusI = loop.body.statements[0].args[0];
expect(false, loop.canHoist(aPlusI));
이 테스트는 보다 이해하기 쉽습니다. 또한 현재와 미래에도 시간을 절약할 수 있게 해줍니다. 실제로 유지보수 비용이 매우 낮아지므로 구문 분석기가 사용 가능하게 될 때까지 대부분을 미루는 것도 바람직할 수
있습니다.
이 접근 방식에는 사소한 단점이 있습니다. 이 테스트는 변환 코드(의도대로) 또는 구문 분석기(우연히)에서 결함을 발견할 수 있습니다. 따라서 문제점 분리 및 디버깅이 다소 어려울 수 있습니다. 반면 구문 분석기
테스트에서 누락된 문제점을 찾는 것도 그리 나쁘지만은 않습니다.
구문 분석기의 결함이 변환 코드의 결함을 가릴 수도 있습니다. 이럴 가능성은 비교적 적으며 그 비용은 대부분 보다 복잡한 테스트의 유지보수 비용보다 더 적습니다.
대규모 테스트 스위트는 변경되지 않는 일부 테스트 블록을 포함합니다. 이 부분은 응용프로그램의 안정된 영역에 해당합니다. 테스트의 기타 블록은 자주 변경됩니다. 이 부분은 동작이 자주 변경되는 응용프로그램의 영역에
해당합니다. 이 후자 테스트 블록은 유틸리티 라이브러리를 과도하게 사용하는 경향이 있습니다. 각 테스트마다 변경 가능한 영역에서 특정 동작을 테스트합니다. 유틸리티 라이브러리는 이런 테스트에서 대상 동작을 확인하되
나머지 부분은 비교적 테스트되지 않은 동작의 변경에 무관하게 남아 있도록 디자인됩니다.
예를 들어 위에서 "루프 사전 사용(hoisting)" 테스트는 구문 분석 트리를 빌드하는 방법의 세부사항과 무관합니다. 이는 여전히 while 루프의 구문 분석 트리
구조에는 민감합니다(a+i의 서브트리를 페치하는 데 필요한 액세스의 시퀀스 때문). 해당 구조가 변경 가능한 것으로 입증되면 fetchSubtree 유틸리티 메소드를
작성하여 보다 추상적으로 테스트를 작성할 수 있습니다.
loop=Parser.parse("while (i<0) { f(a+i); i++; }");
aPlusI = fetchSubtree(loop, "a+i");
expect(false, loop.canHoist(aPlusI));
이제 테스트는 두 가지 사항에만 민감하게 됩니다. 언어 정의(예: ++로 증분될 수 있는 정수) 및 루프 사전 사용을 제어하는 규칙(정확성을 확인하는 동작)이 이에
해당합니다.
유틸리티 라이브러리를 사용해도, 확인하는 내용과는 무관한 동작 변경 때문에 정기적으로 테스트가 손상될 수 있습니다. 테스트를 수정해도 변경으로 인한 결함을 찾을 기회를 상당 부분 놓치게 됩니다. 언젠가 테스트에서
기타 일부 결함을 찾을 기회를 보존하는 것도 필요합니다. 그러나 이러한 일련의 수정에 대한 비용이 테스트로 결함을 찾는 경우의 가치를 초과할 수 있습니다. 이 경우 단순히 테스트를 버리고 더 가치가 큰 새 테스트를
작성하는 데 노력을 쏟는 것이 더 좋습니다.
대부분의 사람들은 유지보수 부담이 너무 커서 모든 테스트를 버리게 되기 전에는 테스트를 버린다는 개념에 익숙하지 않습니다. 테스트별로 다음 사항을 질문하면서 신중하고 지속적으로 결정을 내리는 것이
좋습니다.
-
유틸리티 라이브러리의 추가 이외에 이 테스트를 수정하는 데 얼마나 많은 작업을 필요합니까?
-
이 시간을 다른 방식으로 사용할 수 있습니까?
-
나중에 이 테스트로 심각한 결함을 찾을 가능성이 얼마나 높습니까? 지금까지의 테스트 추적 레코드 및 관련 테스트는 어떻습니까?
-
다시 테스트가 손상되기까지 얼마나 걸립니까?
이 질문에 대한 대답은 개략적인 예상 또는 추측에 불과합니다. 그러나 질문에 대답을 하면 단순히 모든 테스트를 수정하는 정책을 택하는 것보다 더 나은 결과를 가질 수 있습니다.
테스트를 버리는 또 다른 이유는 현재 테스트가 중복되기 때문입니다. 예를 들어 개발 초기에는 기본적인 구문 분석 트리 구현 메소드(LessOp 생성자 및 이와 유사한
항목)를 사용하는 단순한 테스트가 많이 있을 수 있습니다. 나중에 구문 분석기를 작성하는 중에도 여러 구문 분석기 테스트가 존재합니다. 구문 분석기는 구현 메소드를 사용하므로 구문 분석기 테스트도 간접적으로 구현
메소드를 테스트합니다. 코드 변경으로 생성 테스트가 손상되므로 중복되는 일부 테스트를 버리는 것이 합리적입니다. 물론 변경되거나 새로 작성된 구현 동작에는 새 테스트가 필요합니다. 이는 직접적으로(구문 분석기를
통해 전반적으로 테스트하기 어려운 경우) 또는 간접적으로(구문 분석기를 통한 테스트가 적절하고 보다 유지보수가 가능한 경우) 구현될 수 있습니다.
|