[Java, Spring] 단위 테스트의 재정립
협력 객체가 많은 SUT의 단위 테스트는 꼭 Mock을 써야하는가?
- 용어 정리
- ‘단위 테스트’에서 ‘단위’의 정의 (학파의 논쟁)
- 각 학파의 단위 테스트 장단점
- 어떤 스탠스를 취할 것인가?
- 높은 비율의 테스트 커버리지에 속지말자
- 좋은 단위 테스트의 4대 요소
사실 레벨 1에서 확실하게 짚고 넘어가야 할 부분이라고 생각한다.
그런데 지금 와서 다시 혼란이 생긴 것을 보면, 아직 확실하게 짚지 못한 것 같다. (메타인지 부족)
레벨 2로 오면서 스프링과 Mock을 사용하게 되었고,
그 과정에서 단위 테스트의 정의에 대한 혼란이 생겼다.
지금이라도 다시 여러 경로를 통해 공부해서 확실하게 재정립 해보려 한다.
각 학파의 TDD에 관한 부분까지 적으려면 너무 길어질 것 같아서, 이후에 나누어서 정리해보려한다.
용어 정리
- SUT (System under Test) : 각 테스트의 테스트 대상이 되는 객체
- ex) Car 클래스
- MUT (Method under Test) : 각 테스트의 테스트 대상이 되는 메서드
- ex) Car 클래스의 move() 메서드
‘단위 테스트’에서 ‘단위’의 정의 (학파의 논쟁)
내 개인적으로 고민하고 결론을 내기 전에, ‘디트로이트 학파 (Classicist) vs 런던 학파 (Mockist)’ 에서 각각의 학파는 어떤 정의를 내리고 있을까?
- 디트로이트 학파 (고전파) : 단일 기능 (단일 클래스 또는 단일 클래스와 협력 클래스)
- 하나의 동작에 여러 의존성이 포함된다면, 그 의존성을 만들어 주어서라도 테스트를 진행하는 것이다.
- 물론 Database와 같은 공유 의존성만큼은 ‘테스트 더블’ 을 적용할 수 있다.
- 런던 학파 (런던파) : 단일 클래스
- 철저하게 하나의 클래스 단위로 격리하여 단위 테스트를 진행하는 것이다.
- SUT에 협력 객체(의존성)가 존재한다면, 불변 객체(Enum, 상수 등)를 제외한 모든 협력 객체는 ‘테스트 더블’을 적용하여 SUT를 철저히 격리시킨다.
두 학파의 가장 큰 차이점은 ‘단위(입자성)의 정의를 어떻게 내리는지’ 에 대한 부분이다. 즉, 런던파는 한 번에 한 클래스만 테스트 되어야하고, 고전파는 SUT와 연결된 협력 객체까지 같이 테스트를 진행하게 된다.
어떻게 다른 것인지 간단한 예제를 만들어보자. 먼저 테스트 대상이 될 Car클래스를 만들어보자.
public class Car implements Comparable<Car> {
private final Name name;
private final Position position;
public Car(final Name name, final Position position) {
this.name = name;
this.position = position;
}
public Car move(final MoveStrategy moveStrategy) {
if (moveStrategy.isMovable()) {
final Position increase = this.position.increase();
return new Car(this.name, increase);
}
return this;
}
... 생략
}
이러한 Car 클래스의 move() 메서드를 테스트하려고 한다. 각 학파의 테스트를 구현해보자.
먼저 디트로이트 학파의 테스트다.
class CarTest {
@Test
void 무브가_확정된_경우_Car의_Position이_1_증가한다() {
// given
Car car = new Car(new Name("Abel"), new Position(0));
// when
Car movedCar = car.move(() -> true);
movedCar = movedCar.move(() -> true);
movedCar = movedCar.move(() -> true);
// then
assertThat(movedCar.getPosition()).isEqualTo(new Position(3));
}
... 생략
}
SUT의 협력 객체들을 실제로 new 연산자를 통해 생성하는 모습이다.
다음은 런던 학파의 테스트다.
class CarMockTest {
@Test
void 무브가_확정된_경우_Car의_Position이_1_증가한다() {
// given
final Name name = mock(Name.class);
final Position position = mock(Position.class);
final MoveStrategy moveStrategy = mock(MoveStrategy.class);
Car car = new Car(name, position);
given(moveStrategy.isMovable()).willReturn(true);
given(position.increase()).willReturn(new Position(1));
// when
Car movedCar = car.move(moveStrategy);
movedCar = movedCar.move(moveStrategy);
movedCar = movedCar.move(moveStrategy);
// then
assertThat(movedCar.getPosition()).isEqualTo(new Position(3));
}
.. 생략
}
SUT의 협력 객체들(Name, Position, MoveStrategy)은 모두 Mocking한 뒤 Stub을 해서, STU와 실제 협력 객체를 철저히 격리하는 모습이다.
각 학파의 단위 테스트 장단점
디트로이트 학파 (Classicist)
- 장점
- 리팩터링 내성에 강하다.
- 실제 협력자들을 사용해서 MUT를 테스트하기 때문에, 테스트가 세부구현에 의존하지 않을 가능성이 커진다.
- 가성비가 뛰어나다.
- 중요한 기능 단위로 어떻게 테스트 되는지 파악하기 쉬워진다.
- 따라서 의미없는 작은 단위의 테스트의 비율을 최대한 줄일 수 있다.
- 리팩터링 내성에 강하다.
- 단점
- 규모가 큰 MUT의 테스트가 어렵다.
- 테스트 해야할 MUT가 의존하고 있는 객체가 많다면, 해당 MUT의 테스트를 위해 일일이 협력 객체들을 생성하는 과정이 필요하다.
- 테스트가 실패할 때 디버깅이 어려울 수 있다.
- 기본적으로 기능 단위로 테스트가 진행된다.
- 해당 기능에 필요한 협력 객체가 많을 시, 어디서 버그가 나는 지 찾기 어려울 수 있다.
- 규모가 큰 MUT의 테스트가 어렵다.
런던 학파 (Mockist)
- 장점
- 더 나은 입자성을 제공한다.
- 이러한 장점은 밑의 장점들로 연결된다.
- 규모가 큰 MUT도 테스트 하기 쉽다.
- SUT를 테스트 더블을 통해 철저히 격리시키기 때문에, SUT의 협력 객체가 많다고 해도 테스트하기 쉬워진다.
- 테스트가 실패할 때 디버깅이 쉽다.
- SUT의 협력 객체에 대한 의존성을 Test Double을 통해 모두 제거했기 때문에, 테스트 실패가 뜨면 어느 곳에서 버그가 일어나는지 쉽게 잡을 수 있다.
- 더 나은 입자성을 제공한다.
- 단점
- 해당 테스트가 무엇을 검증하는지 정확히 이해하기 어려울 수 있다.
- 너무 세밀하게 단위 테스트가 나뉘어져 있어서, 오히려 파악이 어려울 수 있다.
- 동작 단위로 한 번에 명세할 수 있는 것을, 굳이 따로 세분화해서 명세하는 느낌이 든다.
- MUT가 너무 가볍거나 테스트가 크게 의미 없는 경우일 수도 있다.
- ex) 사람이 카드를 둔다. => 사람이 오른팔을 들고 손목을 올린 후 엄지와 검지를 구부려서 카드를 잡고……
- 과잉 명세를 통해 리팩터링 내성이 약화될 가능성이 크다.
- Mock을 통한 지나친 세부구현 테스트까지 확장하게 된다면, 결과 값은 의도대로 반환되어도 세부 구현의 작은 리팩터링이 진행되었을 때 테스트의 거짓 양성이 뜰 수 있다.
- 해당 테스트가 무엇을 검증하는지 정확히 이해하기 어려울 수 있다.
어떤 스탠스를 취할 것인가?
블라디미르 코리코프는 런던파보다 고전파에 무게를 주면서 왜 그런지 설득한다. 개인적으로 그 설득이 옳다는 생각이 들어서, 왜 런던파보단 고전파인가에 대해 나름대로 정리해보고자 한다.
- 리팩터링 내성은 테스트에 필수적이다.
- 리팩터링 내성에 관한 부분은 밑에서 정리하겠지만, 세부 구현과 밀접한 연관이 있는 테스트일수록 리팩터링 내성은 떨어진다.
- 세부 구현의 변화에 더 영향을 받는 것은 협력 객체를 Mocking해서Stub하는 런던파일 수밖에 없다.
- 테스트는 제한된 시간에 최대한 의미있게 구현해야 한다.
- 런던파는 기능 단위가 아닌 코드 단위로 세밀하게 테스트가 분리되는데, 그 안엔 테스트가 별 의미없는 작은 단위의 테스트도 이루어진다.
- 즉, 과도한 명세가 이루어진다.
- 결국은 고객 입장에서 중요하다고 인식되는 기능 단위의 테스트에 공을 들여야한다.
- SUT의 많은 협력 객체에 의해 테스트의 어려움이 생긴다면, 테스트 방식의 잘못이 아니다.
- 테스트가 어려울 정도로 SUT의 규모가 크다면, 테스트 방식이 아닌 설계부터 다시 점검해야 한다.
- 테스트 디버깅의 어려움은 테스트를 자주 실행하는 습관을 들여서 해결할 수 있다.
- 아무리 여러 협력 객체가 존재하는 테스트가 실패할 지라도, 가장 최근에 수정한 코드를 중점으로 살펴보면 된다.
- 전제 조건은 리팩터링을 할 때마다 테스트를 돌려주어야 한다는 것이다.
이제 고전파의 입장에서 단위 테스트를 정의해 보자면,
- 하나의 클래스 단위가 아닌, 하나의 동작 단위로 검증한다.
- 빠르게 실행 되어야 한다.
- 격리가 되어야 한다는 뜻은, SUT가 격리되는 것이 아닌 다른 테스트와 격리되어야 한다는 뜻이다.
어떤 스탠스를 취했는지 정했다면, 이제 좋은 단위 테스트는 무엇인지 알아야 한다.
높은 비율의 테스트 커버리지에 속지말자
보통 테스트 커버리지가 높으면 테스트의 질이 높거나 좋은 단위 테스트라고 생각하기 쉽다. 이는 매우 위험한 발상이다.
이유는 테스트 케이스 중 맞는 케이스 하나만 돌리더라도, 테스트 된 MUT는 테스트 커버리지의 비율을 높이게 된다. 즉, 다른 테스트 케이스를 돌리지 않아도 테스트 커버리지는 충분히 높일 수 있다는 뜻이다.
그래서 테스트 커버리지는 ‘좋은 부정 신호’ 이면서 ‘나쁜 긍정 신호’ 라고 한다. 테스트 커버리지의 비율이 낮다면 나쁜 테스트라는 것이 확실해질 수 있지만, 테스트 커버리지의 비율이 높다면 좋은 테스트라는 확신을 가질 수 없기 때문이다.
좋은 단위 테스트의 4대 요소
- 회귀 방지
- 미래에 다른 기능이 리팩터링 되거나 추가되었을 때, 테스트가 실패하게 되어 해당 테스트로 회귀되는 현상을 방지하는 것을 말한다.
- 테스트가 메인 기능의 버그가 ‘있음’을 얼마나 잘 나타내는가? -> ‘기능의 고장(거짓 음성)’을 방지
- 회귀 방지 지표를 극대화하려면, 가능한 한 많은 테스트 케이스와 많은 프로덕션 코드를 실행하게 해야한다. 그래야 초기에 많은 회귀를 잡아낼 수 있다.
- 많은 프로덕션 코드를 실행한다는 점에서, 확실히 실제 협력 객체의 코드까지 같이 테스트하는 고전파의 이점이 드러난다고 생각한다.
- 미래에 다른 기능이 리팩터링 되거나 추가되었을 때, 테스트가 실패하게 되어 해당 테스트로 회귀되는 현상을 방지하는 것을 말한다.
- 리팩터링 내성
- 해당 MUT의 로직을 리팩터링 했을 때, 해당 단위 테스트가 실패하지 않는 내성을 말한다.
- 테스트가 메인 기능의 버그가 ‘없음’을 얼마나 잘 나타내는가? -> ‘거짓 테스트 실패(거짓 양성)’를 방지
- MUT의 세부 구현이 어떤 식으로 변경되더라도, 의도한 결과 값만 변하지 않는다면 테스트의 성공은 유지되어야 한다.
- 확실히 Mock을 사용해서 협력 객체를 테스트 코드에 드러내야 하는 런던파보다, 실제 협력 객체를 사용해서 테스트하기 때문에 세부 구현을 몰라도 되는 고전파에게 이점이 있다고 생각한다.
- 해당 MUT의 로직을 리팩터링 했을 때, 해당 단위 테스트가 실패하지 않는 내성을 말한다.
- 빠른 피드백
- 빠른 테스트 속도를 의미한다.
- 만약 피드백이 느리다면 후반에 버그를 찾게 되고, 그만큼 버그를 찾거나 리팩토링하는 일에도 많은 비용이 들 것이다.
- 유지 보수성
- 테스트 코드의 라인 수는 최대한 적어야 한다는 것을 말한다.
이 중 ‘유지 보수성’ 을 제외한 앞의 3가지 요소는 상호 배타적이다. 즉, 2가지를 챙기려면 1가지를 희생해야한다는 뜻이다.
- 회귀 방지 + 리팩터링 내성 (e2e 테스트)
- 많은 테스트 케이스와 많은 코드와 연관된 테스트이기 때문에 회귀 방지는 우수하다.
- 거짓 양성에 면역이 되어 리팩터링 내성도 우수하다.
- 하지만 이러한 많은 코드의 테스트로 인해 빠른 피드백은 적절히 희생해야한다.
- 리팩터링 내성 + 빠른 피드백 (간단한 테스트)
- 간단한 테스트이니 빠른 피드백이 가능하다.
- 또한 거짓 양성에 면역이 되어 리팩터링 내성도 우수하다.
- 하지만 간단한 만큼 많은 코드와 테스트 케이스를 통해 테스트하지 못하여, 초기에 회귀를 방지하기 어렵다.
- 회귀 방지 + 빠른 피드백 (깨지기 쉬운 테스트)
- 세부 구현과 밀접한 연관을 가진 테스트이기에 회귀를 초기에 방지하기 쉽다.
- 빠른 피드백도 당연히 가능하다.
- 하지만 세부 구현을 리팩터링하게 된다면 바로 깨지기 때문에 리팩터링 내성을 아예 포기하게 된다.
여기서 ‘리팩터링 내성’ 을 주의깊게 봐야한다.
앞서 말했듯이, 리팩터링 내성은 MUT의 내부 코드를 리팩터링 해도 테스트는 성공을 유지해야 하는 내성을 말한다. 문제는 리팩터링 내성의 가치는 0이거나 100이거나 둘 중 하나가 될 수밖에 없다는 것이다.(모 아니면 도) 회귀 방지와 빠른 피드백은 조절이 가능하다.
- 여기서 상대적으로 리팩터링 내성에 약한 런던파에 비해 고전파의 이점이 드러난다고 생각한다.
테스트의 가치를 측정하는 방법은 ‘회귀 방지 * 리팩터링 내성 * 빠른 피드백 * 유지 보수성’ 이다. 즉, 하나의 요소라도 0이 된다면 해당 테스트는 가치가 없는 테스트가 된다. 그렇기 때문에 리팩터링 내성은 꼭 챙겨야한다.
이제 유지 보수성과 리팩터링 내성 이 2가지 요소를 최대로 챙겨간다고 생각했을 때, 나머지 요소인 ‘회귀 방지와 빠른 피드백’ 둘 중 하나를 희생해야한다.
단위 테스트에 더 맞는 요소는 빠른 피드백이다. 하지만 도메인 비즈니스 로직처럼 중요한 로직의 테스트일수록 좀 더 회귀 방지에 초점을 맞춰야 한다는 생각이 든다. 단위 테스트의 중요도에 따라 어느 요소에 더 중점을 둘 지 판단하며 구현하면 되지 않을까 생각한다.