단위 테스트는 얼마나 격리되어야 하나요? (Feat. 테스트 더블)
단위 테스트는 얼마나 격리되어야 하나요?
올해 새로운 토이 프로젝트에 TDD를 처음으로 적용해봤는데요. 테스트 코드를 짜던 와중에 과연 이 코드가 단위 테스트의 개념에 잘 부합하고 있는지 궁금해지기 시작했습니다. 대충 여러 자료를 찾아보니 대체로 모든 글에서 단위 테스트를 아래의 개념으로 설명하고 있었습니다.
단위 테스트란 상대적으로 격리된 방식으로 코드의 구별된 단위를 테스트하는 것
😥.. 단위 테스트의 개념은 정말 애매하지 않나요?
구별된 단위란 함수일까요, 아니면 클래스를 말하는 걸까요?
테스트 대상을 대체 얼마나 격리해서 테스트해야 할까요?
저는 단위 테스트를 작성하며 이 두 가지의 모호한 개념을 이렇게 인식했습니다.
1. 구별된 단위
Spring Boot 기반 서버에서 Service Layer의 각 메소드를 테스트 단위로 설정했습니다.
2. 격리된 방식
Service 클래스와 의존 관계를 갖고 있는 모든 클래스를 Mock 처리하여 완전히 고립된 환경에서 테스트하고자 했습니다.
이 두 가지를 기반으로 테스트 코드를 짜는 와중에 다시 3가지의 문제점을 마주쳤습니다.
1. Mockito 라이브러리를 사용하면 given 조건을 설정하는 긴 코드로 인해 가독성이 매우 떨어집니다.
2. Mock 객체를 너무 남용한다는 생각이 들기 시작했습니다.
3. 테스트 대상이 Repository의 메서드를 호출했는지 검증하는 것만으로도 오류를 잘 검증하는지 궁금해졌습니다.
2번의 문제점은 거의 10개에 가까운 클래스에 의존하고 있는 Service 클래스를 테스트하다가 든 의문이었습니다. 모든 의존 클래스를 Mock 객체로 변환해 테스트 대상인 Service 클래스를 고립시키고자 했는데, 이렇게 Mock 객체를 남용해도 괜찮은지 궁금했습니다. 서비스를 위한 테스트가 아니라, 테스트 코드를 위한 테스트를 한다는 생각이 스멀스멀 들기 시작했습니다. 그래서 서적과 블로그 자료를 찾아보며 테스트 대상을 얼마나 격리시켜야 하는지 알아보는 시간을 가졌습니다.
'상대적'이라는 의미에 집중하자.
단위 테스트란 상대적으로 격리된 방식으로 코드의 구별된 단위를 테스트하는 것
'좋은 코드 나쁜 코드'라는 책에 따르면 우리는 단위 테스트의 개념에서 '상대적'이라는 단어에 집중을 해야 합니다. 개발자의 해석에 따라 테스트할 단위는 클래스, 함수, 심지어 코드 파일일 수도 있습니다. 어떤 개발자는 테스트 대상이 의존하는 코드를 모두 차단하는 방식으로 격리하는 반면에 다른 개발자는 의존 코드도 포함해 테스트하는 것을 선호하기도 합니다.
이렇듯 현재 짜고 있는 테스트 코드가 단위 테스트의 정의를 정확히 부합하고 있는지에 대해서는 집착할 필요가 전혀 없습니다. 가장 중요한 것은 프로젝트의 특징에 따라 코드를 잘 테스트할 수 있고 유지보수할 수 있는 코드를 구현하는 것입니다. 좋은 테스트 코드가 갖춰야 할 요소에 대한 내용은 다른 게시글을 통해 자세히 알아보겠습니다.
의존 코드를 분리하고 싶다면 테스트 더블을 사용하자
가능한 의존 객체의 코드를 사용해 테스트를 하고 싶더라도, 그것이 불가능한 상황들이 존재합니다.
불가능한 상황 예시
1. 실제 서비스 용도로 사용되는 데이터베이스에 테스트 데이터가 적재되고 삭제됩니다.
2. 푸시 알림을 보내는 기능을 테스트하고 싶은데, 테스트를 하면 진짜 사용자들에게 테스트용 푸시 알림이 날아갑니다.
3. 테스트 대상이 Open API를 사용한다면 인터넷 환경이 좋지 않을 때에는 테스트가 실패하기도 합니다.
우리가 테스트하려는 Service Layer는 잘 돌아가는데도 불구하고 외부 요인으로 인해 테스트 결과가 달라질 수 있습니다. 어떤 경우에는 실행하고 있는 테스트로 인해 실제 서비스에 장애를 일으킬 수도 있습니다. 이렇게 테스트 대상이 의존하는 객체를 테스트 환경으로부터 분리하고 싶은 경우에는 테스트 더블을 활용할 수 있습니다.
테스트 더블이란 테스트 환경에서 의존성을 시뮬레이션하는 객체를 말합니다.
목, 스텁, 페이크 3가지 유형이 존재합니다.
테스트 더블은 다음과 같은 상황에서 쓰일 수 있습니다.
1. 너무 복잡한 의존성을 테스트 더블로 간단화한다.
2. 테스트로부터 외부 세계(실제 서비스 등)를 보호한다.
3. 외부 세계로부터 테스트를 보호한다.
Java에서는 Mockito 라이브러리를 통해 목과 스텁 객체를 사용할 수 있습니다.
Mockito로 Mock 사용해보기
Mock이란 클래스나 인터페이스의 프록시 객체로 멤버 함수에 대한 호출을 기록하는 일만 수행한다.
Mock 객체의 함수를 호출하면 Mock 객체는 인수로 제공된 값을 기록합니다. 이를 통해 테스트 대상이 의존 객체의 함수를 잘 호출하고 있는지 검증하기 위해 사용합니다. Mockito 라이브러리로 목 테스트를 수행하는 간단한 테스트 코드 예제를 살펴보겠습니다.
@SpringBootTest
@DisplayName("퀴즈 관련 테스트")
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
public class QuizServiceTest {
@InjectMocks
private QuizService quizService;
@Mock
private QuizRepository quizRepository;
@Test
public void 다섯개의_퀴즈를_무작위_조회한_경우() {
//given
create10Quizzes();
//when
var quizList = quizService.readRandom5Quiz();
//then: readRandom5Quiz()에서 quizRepository.findRandomLimit5()가 한 번 실행되었는가?
verify(quizRepository).findRandomLimit5();
}
...
Mock 객체로 사용할 의존 클래스에게 @Mock 어노테이션을 추가합니다. Mock 객체를 사용할 주체인 테스트 대상에게는 @InjectMocks 어노테이션을 추가해 사용할 Mock 객체를 주입합니다. 위 테스트 코드처럼 verify() 메소드를 통해 기대하는 함수가 잘 호출되었는지 검증하는 목 테스트를 수행할 수 있습니다.
Mockito로 Stub 사용해보기
Stub이란 함수가 호출되면 미리 정해놓은 값을 반환하는 객체를 말합니다.
테스트 대상이 의존 객체의 메소드로부터 어떤 리턴 값을 받아야 할 때 그 과정을 시뮬레이션하는데 유용합니다. Mockito 라이브러리가 Mock과 Stub을 둘 다 지원하고 있어서 보통 둘을 합쳐 목이라고 부르기도 합니다. 간단한 예제 코드를 살펴보겠습니다.
@SpringBootTest
@DisplayName("퀴즈 관련 테스트")
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
public class QuizServiceTest {
@InjectMocks
private QuizService quizService;
@Mock
private QuizRepository quizRepository;
@Test
public void 다섯개의_퀴즈를_무작위_조회한_경우() {
//given
List<Quiz> quizList = new ArrayList<>();
quizList.add(new Quiz("CPR은 몇 분마다 교대하면 좋을까요?"));
quizList.add(new Quiz("심정지 환자를 발견하면 무엇부터 해야 할까요?"));
quizList.add(new Quiz("AED 패드는 어디에 부착해야 하나요?"));
quizList.add(new Quiz("이번 달은 몇 월인가요?"));
quizList.add(new Quiz("이 메소드는 몇 개의 퀴즈를 조회해야 하나요?"));
when(quizRepository.findRandomLimit5()).thenReturn(quizList);
//when
var result = quizService.readRandom5Quiz();
//then
Assertions.assertThat(result.size()).isEqualTo(5);
}
mockito 라이브러리의 when(), thenReturn() 메소드를 통해 스텁 테스트를 할 수 있습니다. 테스트 코드의 given 단계에서 기대하는 메소드가 호출되었을 때 스텁 객체가 반환할 리턴 값을 설정할 수 있습니다.
Fake 객체 사용해보기
Fake 객체란 의존 관계의 클래스를 대체하는 구현체를 말합니다.
테스트에서 안전하게 사용할 수 있다는 장점이 있습니다.
의존 클래스를 통해 외부 환경과 통신하는 대신 페이크 객체 내의 멤버 변수에 상태를 저장합니다. 의존 클래스와 동일하게 구현되어 있기 때문에 실제 의존 클래스가 특정 입력을 받아들이지 않으면, 페이크도 마찬가지로 받아들이지 않습니다. Fake 객체는 특정 라이브러리를 사용하지 않고 실제 의존 클래스를 재정의하는 방식으로 구현할 수 있습니다.
public class FakeQuizRepository implements QuizRepository{
Map<Long, Quiz> map = new HashMap<>();
long id = 0L;
@Override
public List<Quiz> findRandomLimit5() {
List<Quiz> quizzes = new ArrayList<>(map.values());
Collections.shuffle(quizzes);
return quizzes.stream().limit(5).collect(Collectors.toList());
}
@Override
public <S extends Quiz> S save(S entity) {
map.put(id++, (Quiz)entity);
return entity;
}
…
}
FakeRepository 클래스는 외부 DB에 데이터를 저장하는 대신, 클래스 내 HashMap에 데이터를 저장하고 조회합니다. 테스트에서 사용할 QuizRepository의 save와 findRandomLimit5 메소드에 대해 HashMap을 사용하도록 재정의합니다. 구현한 Fake 객체는 다음과 같이 테스트에 적용할 수 있습니다.
@SpringBootTest
@DisplayName("퀴즈 관련 테스트")
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
public class QuizServiceTest {
private QuizService quizService;
private QuizRepository quizRepository;
//Fake 객체를 quizService에 의존성 주입
QuizServiceTest() {
this.quizRepository = new FakeQuizRepository();
this.quizService = new QuizService(quizRepository, new FakeQuizAnswerRepository());
}
@Test
public void 다섯개의_퀴즈를_무작위_조회한_경우() {
//given: FakeQuizRepository의 해시 맵에 퀴즈 데이터 저장
quizRepository.save(new Quiz("CPR은 몇 분마다 교대하면 좋을까요?"));
quizRepository.save(new Quiz("심정지 환자를 발견하면 무엇부터 해야 할까요?"));
quizRepository.save(new Quiz("AED 패드는 어디에 부착해야 하나요?"));
quizRepository.save(new Quiz("이번 달은 몇 월인가요?"));
quizRepository.save(new Quiz("이 메소드는 몇 개의 퀴즈를 조회해야 하나요?"));
//when
var result = quizService.readRandom5Quiz();
//then
Assertions.assertThat(result.size()).isEqualTo(5);
}
...
목과 스텁의 장단점
목과 스텁의 장점
- 테스트 코드 개발 속도가 매우 빠릅니다.
- 테스트 대상의 복잡한 의존 관계를 아주 간편하게 해결할 수 있습니다.
사실 Mockito 라이브러리는 빠르고 간단한 만큼 부작용도 큽니다.
목과 스텁의 단점
- 복잡한 의존관계를 알아차리기 힘들기 때문에 그만큼 더 코드 구조가 복잡해질 수 있습니다.
- 목과 스텁이 실제 의존 클래스의 행동과 다르게 구현될 수 있습니다.
- 함수가 호출되었는지 검증할 뿐, 실제 동작 결과를 테스트하지 않습니다.
- 세부 구현 사항과 테스트 코드가 밀접하게 연관되어 리팩터링이 어려울 수 있습니다.
페이크의 장단점
페이크의 장점
- 구현체를 통해 실질적인 테스트가 가능합니다.
- 세부적인 구현 사항을 몰라도 테스트가 가능합니다.
페이크의 단점
- 테스트 코드를 작성하는데 시간이 걸립니다.
- 의존 클래스의 코드가 변경되면 페이크의 코드도 같이 변경해야 합니다.
Mock과 Stub에 대한 찬반 논쟁
목 찬성론자(런던 학파)
목 찬성론자들은 의존 코드를 직접 사용하는 것을 피하고 대신 목을 사용해야 한다고 주장합니다.
런던 학파가 주장하는 목의 장점은 다음과 같습니다.
1. 단위 테스트가 더욱 격리된다.
특정 코드에 문제가 생기면 그 테스트에서만 실패합니다.
2. 테스트 코드 작성이 더 쉬워진다.
테스트에 필요한 항목과 의존성을 올바르게 설정하고 확인할 필요가 없습니다.
즉, 런던 학파는 테스트 대상의 코드가 ‘어떻게’ 동작하는가에 중점을 두고 테스트합니다.
고전주의자(디트로이트 학파)
목 반대론자를 고전주의자 또는 디트로이트 학파라고 부릅니다. 이들은 개발자는 테스트 더블보다는 의존 클래스를 직접 사용하는 것을 최우선으로 해야 한다고 주장합니다. 실제 의존성을 사용하는 것이 가능하지 않을 때에는 가능한 목과 스텁보다는 페이크를 활용합니다. 이들은 테스트 대상의 최종 결과가 ‘무엇인가’에 중점을 두고 있습니다.
디트로이트 학파가 주장하는 목의 단점은 다음과 같습니다.
1. 목은 특정 함수를 호출하는지만 확인할 뿐 실제로 코드에 문제점이 있는지는 검증하지 않는다.
2. 고전적인 접근 방식을 사용하면 구현 세부 사항으로부터 더 독립적인 테스트가 가능하다.
저는 자료를 찾아보며 디트로이트 학파의 의견을 좀 더 수용적으로 받아들인 것 같습니다. 목을 지양하고 Fake 객체를 이용하게 된 이유는 다음과 같습니다.
1. Mock 객체를 남용하면 코드의 악취를 쉽게 알아차리기 힘들어집니다.
Mockito 라이브러리를 사용하면 복잡한 의존 관계로 이루어진 테스트 대상도 아주 쉽고 간단하게 테스트할 수 있습니다. 그렇기 때문에 테스트 대상이 갖고 있는 복잡한 구조나 리팩터링이 필요한 복잡한 코드를 발견하기가 힘듭니다.
2. 비즈니스 코드가 극히 일부만 수정되어도 테스트가 실패하기 쉽습니다.
목과 스텁 객체를 사용하면 테스트 코드가 테스트 대상의 세부 구현 사항과 밀접하게 결합하게 됩니다. 즉, 테스트 대상이 같은 결과를 도출하도록 리팩터링하더라도 테스트는 실패할 수 있습니다.
아래 블로그 자료가 공부하는 데 있어 큰 도움이 되었습니다.
테스트 코드 리팩터링하기
서론에서 언급했던 저의 고립된 테스트 구현 방식을 다시 살펴보겠습니다. 기존에는 Service Layer를 외부 환경으로부터 고립시키고자 모든 Repository를 Mock으로 처리하여 DB와 Repository Layer를 분리했습니다. 테스트 대상이 의존하는 그 외의 모든 객체들도 함께 Mock으로 처리했습니다.
그러나 아래 링크의 글을 읽으며 현재 방식은 유연한 테스트 코드를 짜기에 매우 불리한 구조라는 것을 깨닫게 되었습니다.
글을 읽으며 느낀 Mock의 장단점은 다음과 같습니다 .. 🙃
1. Mock 객체로 사용하고 있는 수많은 Repository 클래스에 대한 단위 테스트도 필요합니다.
2. Mock은 구현 사항에 밀접하게 연관되어 있어 Repository 클래스의 메서드명 하나만 달라져도 테스트가 실패합니다.
3. H2 인메모리 또는 로컬 DB 등 Mock보다 효율적으로 DB를 테스트로부터 분리할 수 있는 도구들이 많이 존재합니다.
4. 저장소를 테스트 환경에서 사용하면 저장소에서 발생할 수 있는 문제점도 함께 확인할 수 있습니다.
5. 저장소를 사용하는 것만으로 Mock 테스트보다 훨씬 유연하고 적은 비용의 테스트 코드를 작성할 수 있습니다.
결국 여러 자료를 참고하며 테스트 코드가 다음과 같이 작동하도록 변경했습니다.
1. 테스트에서 Repository Layer를 분리하지 않고 H2 인메모리 DB를 사용합니다.
인메모리 DB는 로컬 DB와 달리 휘발성 메모리를 사용하기 때문에 테스트를 실행할 때마다 데이터베이스가 초기화됩니다. 또한 H2를 설치하지 않아도 어떤 환경에서든 H2 인메모리 DB를 사용할 수 있습니다.
2. 가능한 의존 클래스를 그대로 사용합니다. 테스트 더블이 반드시 필요하다면 Fake 객체를 사용합니다.
가능하면 의존 클래스를 그대로 사용해 단위 테스트를 진행합니다. 만일 반드시 테스트 더블이 필요하다면 가능한 Fake 객체를 사용합니다. 빠른 개발 속도가 필요하다면 Mockito를 임시로 사용하되, 리팩터링 단계에서 Fake 객체를 사용하도록 수정합니다.
글을 쓰며 ‘좋은 코드 나쁜 코드’, ‘클린 코드’, '자바와 JUnit을 활용한 실용주의 단위 테스트' 서적을 참고하였습니다. 클린 코드는 아주 간략하게만 테스트를 다루고 있어 큰 도움이 되지 못했습니다. 그리고 JUnit을 활용한 실용주의 단위 테스트는 JUnit 사용법에 대해 두꺼운 책으로 자세히 다루는 것 같아 미처 다 읽지 못했습니다 ㅎㅎ.. 좋은 테스트 코드 작성법에 대해 관심 있으신 분들은 ‘좋은 코드 나쁜 코드’에서 테스트 관련 챕터만 읽어보셔도 큰 도움이 되실 것 같습니다.