테스트 커버리지 측정하기
이상적으로는 테스트가 애플리케이션 코드의 100%를 커버하는 것이 좋다. 테스트 커버리지는 그 자체로 코드의 품질을 어느 정도 보증한다. 하지만 절대적이지는 않다. 높은 테스트커버리지가 테스트의 질을 완전히 보장하지는 않기 때문이다. 훌륭한 개발자는 테스트를 실행하여 얻어 낸 기계적인 백분율 수치 이상을 볼 수 있어야 한다.
테스트 커버리지란?
테스트 커버리지를 계산하는 데 다양한 지표가 활용될 수 있다. 가장 기본적인 지표는 테스트 묶음을 실행하는 동안 호출되는 애플리케이션의 메서드나 코드 줄의 수를 가지고 나타낸 백분율이다. 혹은 테스트가 호출하는 메서드를 추적해서 집계할 수 있다.
메서드가 어떻게 구현되어 있는지 자세히 알고 있다면 단위 테스트를 작성할 수 있다. 테스트 대상 메서드에 분기문이 있을 경우, 각 분기마다 하나씩 단위 테스트를 작성해야 한다.
일반적으로 화이트박스 테스트를 활용하면 더 높은 테스트 커버리지를 얻을 수 있다. 더 많은 메서드에 접근할 수 있을뿐더러 각 메서드에 대한 입력과 보조객체의 동작을 제어할 수 있기 때문이다.
테스트 커버리지 지표는 테스트 묶음을 실행하고 코드를 분석하는 도구로 파악할 수 있다. 이러한 코드 커버리지를 측정하는 도구는 Junit과 통합이 잘되어 있다. 실제로 IntelliJ IDE를 활용하면 코드 커버리지를 편리하게 집계할 수 있다.
아래 사진은 내가 작성한 UnitTest 단위의 테스트 커버리지를 측정한 결과이다.
똑같은 프로젝트에서 Exception을 테스트하는 커버리지를 측정한 결과이다.
이 처럼 테스트 코드에 따라 커버리지가 변경되는 것을 알 수 있다.
해당 커버리지 지표는 원한다면 html 형식의 리포트로도 생성할 수 있다.
이 외에도 JaCoCo(Java Code Coverage) 플러그인을 사용하여 테스트 커버리지 리포트를 생성할 수 있다. 사이트에서 maven repository만 안내되어 있길래 gradle은 따로 찾아왔다.
테스트하기 쉬운 코드 작성하기
테스트 코드를 작성할 때 가장 좋은 사례는 가독성이 좋고 테스트하기 편하도록 가능한 한 소스코드를 단순하게 작성하는 것이다.
일반적으로 테스트 코드를 작성할 때 유의사항은 다음과 같다.
- public 메서드의 시그니처를 변경하면 안 된다. public API는 정보 제공자와 정보 사용자 간의 계약이다
애플리케이션 코드를 보면 호출은 보통 외부의 다른 애플리케이션에서 일어난다. 이때 외부 애플리케이션은 해당 API의 클라이언트와 같은 역할을 한다. public 메서드의 시그니처를 변경했다면 애플리케이션이나 단위 테스트의 메서드 호출을 전부 변경해야 할 것이다.
- 의존성 줄이기
단위 테스트는 코드를 격리된 상태에서 검증해야 한다. 테스트하기 쉬운 코드를 작성하려면 의존성을 최대한 줄여야 한다. 클래스가 인스턴스화되고, 특정한 상태로 설정해야 하는 다른 클래스에 많이 의존하는 경우 테스트하기 매우 복잡해지며 복잡한 모의 객체를 만들어야 할 수 있다.
그렇다면 의존성은 어떻게 줄이는게 좋을까? 의존성을 줄이려면 메서드를 분리해야 한다. 특히 객체를 인스턴스화하는 메서드와 비즈니스 로직을 가지고 있는 메서드를 분리하는 것이 중요하다.
아래 코드를 보자. Vehicle 객체가 생성될 때 Driver 객체도 같이 만들어진다. 두 가지 객체가 강하게 결합되어 있고 Vehicle 클래스가 Driver 클래스에 의존하는 문제가 있는 것이다. 이럴 때는 Driver 객체를 Vehicle 클래스에 전달하는 방식으로 해결할 수 있다.
// 강하게 결함되어 있는 Vehicle과 Driver
class Vehicle {
Driver d = new Driver();
boolean hasDriver = true;
private void setHasDriver(boolean hasDriver) {
this.hasDriver = hasDriver;
}
}
// Vehicle과 Driver의 의존성을 분리함
class Vehicle {
Driver d;
boolean hasDriver = true;
Vehicle(Driver d) {
this.d = d;
}
private void setHasDriver(boolean hasDriver) {
this.hasDriver = hasDriver;
}
}
이렇게 수정하면 이후에 Driver 객체를 생성하고 Vehicle 객체 생성시 모의 객체를 Vehicle에 전달할 수 있다. 이를 의존성 주입이라고 한다.
- 생성자 생성시 유의 점 → 클래스를 특정 상태로 설정하는 것은 별도의 작업으로 분리해야 한다
// 생성자에서 인스턴스 변수에 값을 할당하므로 테스트할 클래스를 인스턴스와 하면서 클래스를 특정상태로 정의하게 된다.
// 클래스를 인스턴스화 할 때 마다 같은일을 반복하므로 유지보수나 테스트가 어렵다
class Car {
private int maxSpeed;
Car() {
this.maxSpeed = 180;
}
}
// 클래스를 특정 상태로 설정하는 것을 별도의 작업으로 분리했다.
class Car {
private int maxSpeed;
public void setMaxSpeed(int maxSpeed) {
this.maxSpeed = maxSpeed;
}
}
- 데메테르 법칙 따르기
데메테르 법칙은 클래스는 알아야 할 만큼의 정보만 가져가야 한다는 것이다.
* 데메테르 법칙
가까운 친구와 이야기 하거나 낯선 이와 이야기하지 말라
즉 객체를 요구하되 객체 안에서 다시 찾지 않으며 현재 애플리케이션에 꼭 필요한 객체만 요청하는 것이다.
아래 예제는 데메테르 법칙을 적용하여 정확히 필요한 참조만 전달하는 것을 보여준다.
class Car {
private Driver driver;
// Context 에는 드라이브에 대한 정보가 다양하게 들어 있다.
// Car 클래스가 getDriver() 메서드가 있다는 사실을 알아야 한다. 그러므로 Car 클래스의 생성자를 테스트하고 싶다면
// 생성자를 호출하기 전에 유효한 Context객체를 가져와야 한다
Car(Context context) {
this.driver = context.getDriver();
}
//데메테르 법칙을 정용하여 메서드나 생성자에 정확히 필요한 참조만 전달하는 것이 좋다
Car(Driver driver) {
this.driver = driver;
}
}
* 미슈코 헤버리의 사회 분석
cf. 해당 글은 미슈코 헤버리가 구글 재직 당시 구글 테스팅 블로그에 올린 Singletons are Pathological Liars라는 글에서 발췌한 것이다. 이 글에서 미슈코 해버리는 객체가 필요로 하는 의존성이 코드상에 직접적으로 드러나지 않는다는 문제를 제기하면서, 필요한 의존성을 명시적으로 주입하여 코드의 품질을 향상하는 방법에 대해 서술했다.
당신이 관계(코드)를 구축한 사람이라면 의존성을 명확하게 파악하겠지만, 당신의 후임자는 아닐 것이다. 당신의 후임자가 의존성이 명확하지 않은 코드들로 가득 찬 프로젝트를 개발하게 내버려 둘 것인가?
- 숨은 의존성과 전역 상태 피하기
전역 상태를 공유하는 것은 때때로 의도하지 않은 결과를 만들어 낸다. 전역 상태는 되도록 피하는 게 좋다. 전역 객체에 대한 접근을 허용하면 단순히 전역 객체에만 접근을 공유하는 게 아니라 그 전역 객체가 참조하는 모든 객체를 공유하게 되기 때문이다.
아래 코드는 전역 객체를 사용할 때 발생할 수 있는 문제점을 제시한다.
public void makeReservation() {
Reservation reservation = new Reservation();
reservation.makeReservation();
}
public class Reservation() {
public void makeReservation() {
// manager은 이미 초기화된 전역 BManager에 대한 참조를 가진다고 가정한다.
manager.initDatabase(); // manager이 전역 객체에 해당한다. Rervation에 대한 문서가 없다면 개발자는 Reservation 클래스가 manager에 의존한다는 사실을 알 수 없다.
}
}
public void makeReservation() {
DBManager manager = new DBManager();
manager.initDatabase();
Reservation reservation = new Reservation(manager); // 전역 객체던 manager을 전역상태가 아니게 변경했다
reservation.makeReservation();
}
- 제네릭 메서드 사용하기
팩토리 메서드와 같은 정적 메서드는 매우 유용하지만, 많은 정적 유틸 메서드에 문제가 있다. Math.sqrt()처럼 그 자체로 완결성이 있는 메서드는 테스트에 미치는 영향이 크지 않다. 그러나 복잡한 비즈니스 로직을 다루는 메서드나 정적 메서드 안에서 실행되고 있는 메서드들은 테스트하기가 매우 까다롭다. 정적 메서드를 남발하게 되면, 다형성을 활용할 수 없게 된다. 다형성을 활용하지 않는다는 것은 애플리케이션과 테스트 모두에서 코드를 재사용하지 않는다는 뜻이기도 하다.
결론적으로 파라미터에 구체적인 타입을 명시해야 하는 정적 유틸 메서드가 있다면 반드시 제네릭을 사용해야 한다.
아래 코드는 정적 유틸 메서드에서 제네릭을 사용한 예제이다.
public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
Set<E> result = new HashSet<>(s1);
result.addAll(s2);
rereturn result;
}
- 분기문보다는 다형성 활용하기
일반적으로 테스트에서 수행하는 작업은 다음 순서를 따른다.
- 테스트할 클래스를 인스턴스화한다
- 클래스를 특정 상태로 설정한다
- 클래스의 최종 상태를 검증한다
그런데 클래스가 너무 복잡하면, 클래스를 인스턴스화할 때 어려울 수 있다. 만약 if나 switch문이 길게 늘어진 분기문이 생성되어 복잡성이 올라간다면, 다형성을 활용하는 것을 고려해 볼 수 있다. 다형성은 객체를 여러 작은 클래스로 나누어 길고 복잡한 문기문을 대체할 수 있게 해 준다.
TDD
개발자가 테스트를 먼저 작성한 다음 테스트를 통과하는 코드를 작성하는 프로그래밍 기법을 의미한다. 코드를 작성한 다음에는 코드를 검사하고 난잡한 부분을 정리하거나 코드의 질을 높이기 위해 리팩터링 한다. TDD의 목적은 '작동하는 클린 코드'를 만드는 데 있다.
TDD에서의 테스트는 설계를 주도하고, 메서드의 첫 번째 클라이언트가 된다.
TDD로 개발할 때는 다음과 같은 장점이 있다.
- 목적이 분명한 코드를 작성할 수 있고, 개발자는 애플리케이션이 필요로 하는 것을 정확하게 개발했다는 확신을 얻을 수 있다. 코드를 설계하는 데 테스트를 사용할 수 있다.
- 새로운 기능을 더 빨리 적용할 수 있다. 테스트는 개발자가 의도대로 코드를 구현하게 유도하는 힘이 있다.
- 테스트는 정상적으로 작동하는 기존 코드에 버그가 생기는 것을 방지할 수 있다
- 테스트는 개발 문서의 역할을 한다. 테스트를 따르는 것은 소스 코드가 해결해야 하는 문제를 이해하는 것과 같다.
TDD는 다음과 같이 진행된다.
- 테스트한다
- 코드를 재작성한다
- 리팩터링 한다
- (반복한다)
리팩터링은 소프트웨어의 외적 동작을 바꾸지 않고 내부적인 구조만 개선함으로써 시스템을 변경하는 과정을 의미한다. 이때 외적 동작이 바뀌지 않았다는 것을 증명하기 위해서 테스트를 사용한다.
TDD가 익숙해진다면, 개발자는 새로운 코드를 작성하기 전에 반드시 실패하는 테스트를 먼저 작성하게 된다. 실패하는 테스트를 먼저 작성하면, 테스트에 성공하는 소스 코드만 작성할 수 있게 된다.
행위 주도 개발
2000년대 중반 댄 노스가 주장한 BDD는 비즈니스 요구사항을 직접적으로 만족하는 IT솔루션을 만드는 데에 집중한다. TDD가 품질 좋은 소프트웨어를 만드는 데 기여한다면 BDD는 사용자의 문제를 직접적으로 해결하는 소프트웨어를 만드는 데 기여한다.
BDD 방법론을 잘 따른다면 사용자가 요구하는 것 이상을 보고 사용자가 필요한 것 이상을 구현하게 된다.
돌연변이 테스트 수행하기 (Mutant Test)
100%를 달성한 코드 커버리지도 완벽한 작동을 보장하지는 않는다. 여전히 테스트가 충분히 수행되지 않았을 수 있다. 이럴 때 돌연변이 테스트를 사용할 수 있다. 돌연변이 분석, 돌연변이 프로그램이라고도 하는 돌연변이 테스트는 새 테스트를 설계하고 기존 테스트의 품질을 평가하는 데 사용한다.
돌연변이 테스트의 기본적인 아이디어는 프로그램을 '조금' 수정하는 것이다. 이때 효과적인 테스트는 돌연변이를 탐지하고 방지할 수 있는데 이를 돌연변이 죽이기라고한다.
돌연변이는 +를 -로 바꾸는 등 기존 연산자를 다른 연산자로 바꾸거나 if와 else의 내용을 바꾸는 등 일부 조건을 뒤집는 등의 돌연변이 연산으로 만든다. 만약 돌연변이가 테스트를 통과한다면 테스트가 잘못된 것으로 간주할 수 있다. 돌연변이 테스트는 이를 통해 테스트 자체의 신뢰성을 높이거나, 테스트 데이터의 약점을 찾을 수 있으며, 실행 중에 거의 혹은 전혀 접근할 수 없었던 코드의 약점을 찾을 수도 있다.
돌연변이 테스트를 통해 실패의 원인을 명확하게 규정할 수 있다. 아래 코드를 보자.
// 검증되어야 하는 기본 테스트
if(a) {
b = 1;
} else {
b = 2;
}
// 돌연변이 테스트를 위해 조건문을 a 에서 !a 로 변경했다
if(!a) {
b = 1;
} else {
b = 2;
}
위 코드에서 테스트 검증을 수행한다면, 실패의 원인이 처음에 통과된 분기 로직을 타지 않는 데 있다는 것을 명확하게 보여줄 수 있어야 한다.
현재 자바 진영에서 가장 유명한 돌연변이 테스트 프레임워크는 Pitest다. 해당 링크에는 gradle를 통해 Pitest를 사용하는 예제가 포함되어 있다. 해당 프레임워크를 사용하면, 돌연변이 테스트를 수행하고 그에 따른 테스트 커버리지를 제공받을 수 있다.
개발 주기 내에서 테스트하기
테스트는 개발 주기 내에서 수행할 수 있으며 기본적으로 시간과 장소를 가리지 않는다.
개발 주기는 다음과 같이 구분된다.
- 개발 (development) → 개발자의 local에서 작업하는 것을 의미한다. 개발자는 작업이 완료되면 SCM 시스템에 커밋할 수 있다
- 통합 (integration) → 다른 팀에서 개발한 컴포넌트까지 포함하여 애플리케이션을 빌드하고 여러 컴포넌트가 함께 잘 동작하는지 확인한다. 이를 지속적 통합이라고 한다 (CI : Continuous Integration)
- 인수/부하 테스트 (acceptance/stress test) → 고객의 피드백을 받기 위해 인수 단계에서는 가능한 한 자주 배포하는 것이 권장된다
- 예비 운영 (pre-production) →실제 배포 직전에 수행하는 마지막 검증 단계로 프로젝트 별로 진행하지 않는 경우도 있다
결과적으로, 테스트는 많이 하고 디버깅은 줄여야 한다.