본문 바로가기

개발

Mock객체를 활용한 테스트와 유의사항

 

먼저 나는 테스트 코드를 철저하게 작성하는 팀에서 일해본적이 없다. 그렇지만 온갖 개발 관련 글과 강의에서 테스트 코드가 중요하다는 이야기를 수없이 들어왔기 때문에 개인적으로 진행하는 프로젝트들에서는 반드시 테스트 코드들을 작성하는 습관을 들여왔다. 현업에서 어떤식으로 작성하는지도 잘 모른채로 기본 사용법만 찾아본 채 근본 없이 작성한 테스트 코드지만, 이런 테스트코드조차 코드를 수정할 때마다 사이드이펙트들을 기가막히게 찾아내는 걸 보면서 테스트 코드의 효용성을 직접적으로 많이 느꼈고, 그래서 현 직장의 레거시 개편 프로젝트에서도 테스트 코드 도입을 열심히 주장한 결과 원하는대로 해보라는 허가를 받아 회사에서도 열심히 테스트 코드를 작성 중이다.

 

일단은 Service Layer의 메서드들만 테스트하는 코드를 작성하고 있는데, 사실 여전히 코드를 짜면서도 이렇게 테스트 코드를 짜는 게 맞는 건가하는 의문을 종종 품으면서 짜고 있기도 하고, 추후 Controller 테스트 코드도 짜려면 MockMvc, MockBean을 활용하여 테스트 코드를 짜야 할 텐데, 사실 이런 코드는 작성해보긴 했어도 여전히 반환 데이터를 테스트 코드에서 직접 정의하고 그게 반환되는지 확인하는 이런 테스트코드는 도대체 왜 짜는 것인가 도저히 이해가 안 되어 이번에 한번 제대로 알아보기로 했다.

 

 

나는 테스트코드를 어떻게 활용해왔는가


지금까지 내가 작성해온 테스트 코드들은, 위에서 말했다시피 서비스 메서드에 대한 테스트코드만 작성해왔는데, 나는 이 코드를 짜는 목적 자체가 이 서비스 메서드의 코드가 수행됐을때 실제 반환값을 검증하기 위해 작성해왔었다. 그러니까, 서비스 테스트코드지만 실제 DB커넥션 및 쿼리 작업까지 수행되어 실제 쿼리 결과값까지 검증을 하고 있었는데, 나는 사용자가 서비스를 이용할 때 동작하는 과정을 그대로 재현하는 게 맞지 않나 생각하여 이렇게 했던 것이지만 사실 내가 짜온 이런 테스트코드는 단위테스트도, 통합테스트도 아닌 애매한 반쪽짜리 테스트코드였다.

 

왜 그런 건지는 일단 단위 테스트와 통합 테스트의 차이를 짚어봐야 알 수 있는데, 두 테스트를 간단히 정의하면 아래와 같다.

 

  • 단위 테스트 : 하나의 함수 내의 모든 코드가 문제 없이 동작하는지 검증하는 테스트
  • 통합 테스트 : 하나의 서비스 로직을 수행하기 위한 여러 시스템들의 상호작용이 잘 이루어지는지 검증하는 테스트

 

이런 정의는 몇 번 읽어봤던 것 같은데, 과거에는 그냥 추상적으로만 이해하고 넘어갔던 것 같다. 근데 이제 쓰고보니 확실하게 어떤 그림인지 이해가 되는데, 대략적으로 설명하자면 아래와 같다.

 

먼저, 단위테스트는 일반적으로 클래스 메서드 단위의 테스트를 수행한다. 메서드 내부 코드의 라인 하나하나가 이상 없이 잘 실행되는지를 검증하는 것이 단위테스트의 목적이기에, 프레임워크나 외부 클래스와의 의존 여부와 관계 없이 개발자가 짠 내부 로직만을 테스트하는 것이다. 위의 코드의 예와 같이 리스트 생성은 잘 되는지, 반복문은 잘 돌아가는지, 반복문 돌리면서 리스트에 데이터 쌓는 로직은 잘 돌아가는지 등등 세부적인 코드의 수행을 테스트한다.

 

반면에 통합테스트는 코드 동작 여부에 더불어, 시스템의 상호작용까지 테스트한다. 그렇기에 실제 서비스 동작 환경과 거의 동일한 환경을 만들어놓고 테스트하는데, 우리가 테스트에서 익히 사용하는 @SpringBootTest가 이런 통합테스트 환경을 마련해주는 어노테이션인 것이다. 위 코드를 보면, 실제 애플리케이션에서 addInfo()함수를 정상적으로 호출하기 위해서는 @RequiredArgsConstructor라는 lombok의 외부 라이브러리의 어노테이션도 정상적으로 동작하여야 하고, 스프링 컨테이너가 ExamRepository라는 외부 클래스(Bean)도 정상적으로 주입시켜줘야하며, examRepository.addSample() 메서드도 정상적으로 동작하여 정상적인 값을 리턴하여야 한다.

 

그러니까 통합테스트는 프레임워크, 라이브러리, 연동된 외부 API등을 모두 불러오고 실행해야지만 수행될 수 있는 테스트이기에 @SpringBootTest를 필요로 하고, 단위테스트는 함수의 동작만을 체크하기에 @SpringBootTest를 필요로 하지 않는 것이다. 그런데 여기까지만 들으면 좀 이상할 것이다. 아무리 단위테스트가 함수 단위의 동작만 테스트한다고 할지라도, 위 코드만 봐도 어쨌든 examRepository.addSample()이라는 외부 클래스의 메서드를 호출하는데, 그럼 스프링 컨테이너가 실행되지 않은 상황에서는 examRepository에 Bean이 주입되지 않아 해당 라인에서 에러가 나는 게 아닐까?

 

그렇다. 단위테스트라고 무작정 저렇게만 함수를 돌리면, 외부 클래스와의 연계 없이 그냥 문자열 가공하는 정도의 로직만 돌리는 함수가 아닌 이상 에러가 발생할 수밖에 없다. 그렇기 때문에 여기서 Mock객체라는 개념이 등장하는 것이다.

 

 

Mock객체?


이전에 나는 Mock 테스트에 대한 정확한 개념이 없이도 MockBean, MockMvc등을 활용하여 테스트 코드를 작성한적이 몇번 있었다. 그런데 이 코드를 치면서도 왜 이렇게 테스트를 하는 것인지 도통 이해가 되지 않았다. 내가 생각한 테스트는 일단 메서드를 수행시키고, 오류는 발생하지 않는지, 리턴된 결과값은 기대값과 일치하는지 등을 확인하는 게 테스트 코드였는데, Mock을 활용한 테스트는 그냥 내부 동작 여부와 관계 없이 테스트 초반부에 결과값을 미리 지정해서 리턴하게 해두고, 테스트 후반부에 이 결과값이 지정한 값이 리턴됐는지 확인하는 코드를 짜길래 '뭐지?? 직접 지정했으니까 당연히 저 리턴값이 나오는 거 아닌가??' 하는 의아함을 감출 수 없었다. 그게 대략 아래와 같은 코드였는데, 한번 이게 무슨 말인지 확인해보자.

 

@DisplayName("사용자 목록 조회") 
@Test 
void findAll() {    

// given    
doReturn(userList()).when(userRepository).findAll();    

// when    
final List<User> userList = userService.findAll();    

// then    
assertThat(userList.size()).isEqualTo(5); 

} 

private List<User> userList() { 
final List<User> userList = new ArrayList<>(); 
    for (int i = 0; i < 5; i++) {
    	userList.add(new User("test@test.test", "test", UserRole.ROLE_USER)); 
    } 
	return userList; 
}

이 코드를 보면 given에서 userRepository의 findAll() 메서드를 호출하면 userList()의 리턴값이 나오도록 mock객체에 세팅을 해뒀다. 그리고 then에서 실제로 userList의 결과값대로 나왔는지 assertThat을 통해 체크한다. 그래서 나는 '당연히 userList값이 나오도록 설정해놨으니 저게 나오겠지.. 뭐야 도대체? 이런걸 왜 하는거야?'하면서 이 코드를 제대로 이해할 수가 없었는데, 코드를 자세히 들여다보고 생각해보면 그냥 내가 무지성 코더였을 뿐임을 깨달을 수 있었다.

 

자세히보면 Mock객체로 리턴값을 미리 지정하는 것은 userRepository이고, when에서 실제로 호출하는 메서드는 userRepository의 findAll()이 아니라, userService의 findAll()이다. 즉, 실제로는 userService.findAllI()메서드 내부에서 userRepository.findAll()을 호출하는 코드가 있는 것이고, 그 코드의 리턴값을 지정해줬던 것 뿐이다.

 

위 소스 코드는 예제를 퍼온 것이라 정확히는 모르지만, 아마 저렇게 Mock객체를 사용했다면 UserService클래스의 내부 그림은 이런 식이었을 것이다.

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
	
    private final UserRepository userRepository;
    
    public List<User> findAll() {
    	/* 
    	 *  UserService.finAll()의 비즈니스 로직..
         */
        return userRepository.findAll();
    }
    
}

이렇게 UserService의 findAll()을 수행할 때 내부적으로 UserRepository의 findAll()이 추가적으로 수행되는 것 뿐, UserService.findAll() 메서드의 자체적인 로직은 또 따로 있는 것이고, 위의 단위테스트는 UserService의 로직을 테스트하고 싶은 것이지, UserRepository의 메서드 수행 결과는 궁금하지 않은 것이다. 그렇기 때문에 UserRepository가 대충 수행됐다 치고, 수행돼서 5개의 객체가 담긴 List가 반환됐다 칠테니 이 함수가 호출되면 이렇게 리턴값을 달라고 미리 Mock객체에 지정해두는 것이다. 이렇게 하면 단위테스트 수행 중 외부 의존성에 의해 에러가 발생하면서 테스트가 중단되는 일 없이 UserService.findAll()의 로직만을 정확하게 테스트할 수 있게 되는 것이다.

 

그래서 정리하자면 Mock객체를 사용하는 이유, 단위 테스트를 하는 이유는 아래와 같다.

 

  • 시스템 구성과 무관하게 지정한 함수의 수행만을 테스트 하기 위해
  • 외부의 연동된 다른 클래스함수들의 수행 결과가 내가 하고자하는 함수의 수행결과에 영향을 미치지 않게 하기위해
  • 컨테이너와 라이브러리 연동 같은 무거운 작업을 생략하고 빠르게 테스트하기위해

대부분의 애플리케이션은 프레임워크와 라이브러리등을 필수로 사용하고 있기 때문에, 이렇게 단위테스트를 수행하려면 Mock객체 활용의 효용성이 두드러지게 되는 것이다.

 

 

Mock객체의 무분별한 사용의 문제


여기까지만 이해한다면, '아하 그럼 테스트 하고 싶은 로직을 제외한 나머지 부분들은 다 Mock객체를 통해 처리하면 되겠네?'라고 생각할 수 있다. 하지만 이것은 잘못된 생각이다. Mock객체는 확실히 테스트를 편리하게 만들어준다는 것이 큰 장점이지만, 그것이 동시에 큰 단점이기도 하기 때문이다. 이게 왜 단점이 되는지에 대해서 이해하려면 먼저 테스트 코드 작성의 목적에 대한 고찰이 필요하다. 테스트 코드 작성의 목적이 무엇이라고 생각하는가? 아마 처음 테스트코드를 접하면 개발한 기능 테스트의 간편화, 변경에 의한 사이드이펙트 방지 정도만으로 생각하기가 쉽다. 그런데 간과하기 쉬운 테스트 코드의 또 하나의 큰 장점은, 바로 테스트 코드 작성 과정에서 설계상의 문제점을 발견해낼 수 있다는 점이다. 가령 아래와 같은 클래스를 테스트한다고 가정해보자.

 

@Service
@RequiredArgsConstructor
public class TestService {
	
    private final ServiceA serviceA;
    private final ServiceB serviceB;
    private final ServiceC serviceC;
    private final ServiceD serviceD;
    private final ServiceE serviceE;
    
    public void call() {
        int result = serviceA.execute();
        if(result == 0) {...}
        else if(result == 1) {...}
    }
    
}

 

이 클래스의 call() 메서드를 테스트하려면, call() 메서드가 의존하고 있는 클래스는 ServiceA뿐이므로 ServiceA의 execute()함수에 대해서만 Mock객체를 생성해주면 테스트코드는 쉽게 작성할 수 있을 것이다. 하지만 Mock객체 없이 테스트하려면 어떻게 해야할까? 일단 TestService클래스를 생성하기 위해서는 필드에 final로 선언된 모든 클래스들을 주입받아야하기 때문에, 테스트코드에서 직접 5개의 클래스를 모두 생성하고 생성자의 파라미터로 넘겨야 클래스가 생성되고, call() 메서드를 수행할 수 있다. 즉, 테스트 코드의 작성이 불편해진다는 것인데.. 그럼 안 좋은 거 아닐까? 

 

물론 아무 생각 없이 코드를 짠다면 이는 불편함만 증대시키는 것일 뿐이다. 하지만, 어느정도 좋은 설계를 생각하며 코드를 짜는 사람이라면, 이 불편한 테스트 코드를 작성하면서 분명 이런 생각을 하게 될 것이다.

 

'왜 이렇게 주입받는 클래스가 많지..? 이러면 결합도가 너무 높은 거 아닌가?'

 

 

객체지향 프로그래밍은 기본적으로 각 객체의 역할과 책임을 분명히하고, SRP, ISP를 준수하여 높은 응집도와 낮은 결합도를 가진 객체를 정의하는 것이 중요하다는 것을 이론을 공부하며 배웠을 것이다. 이 개념을 아는 사람이라면, 혹여나 바쁘게 코딩하며 이런 설계상의 고려사항을 간과하고 객체지향적인 코드를 짜지 못하였다고 하더라도, 이렇게 테스트 과정에서 문제점을 인식하여 설계를 다시 손 볼 수 있는 기회를 얻게된다. 이런 과정이 반복되다보면 개발자는 최초에 코드를 짤 때부터 점점 더 테스트를 고려하며 테스트하기 좋은 코드를 짤 가능성이 높아지고, 여기서의 테스트하기 좋은 코드라는 것은 곧 객체지향적인 코드를 의미하기 때문에 결과적으로 좋은 테스트 코드가 좋은 설계를 만들게되는 것이다.

 

그렇기에 테스트를 편리하게 해주는 Mock객체는 불가피할 경우에만 사용하도록 하고, 기본적으로는 테스트에 필요한 클래스들을 Fake객체로 직접 생성하여 테스트하는 것을 기본 전략으로 가져가는 것이 좋은 설계를 위한 테스트 전략이라고 할 수 있는 것이다.

 

 

 

# Reference

 

 

 

 

'개발' 카테고리의 다른 글

RestTemplate / WebClient, 그리고 Reactive Programming  (0) 2023.03.27
Blocking/Non-Blocking & Sync/Async  (0) 2023.03.15
세션 불일치 문제 해결법  (0) 2023.02.27
레디스(Redis)란?  (0) 2023.02.20
TDD  (0) 2023.01.12