코드네임 JY

[CRUD 연습] 컨트롤러 테스트 가이드 본문

백엔드 공부

[CRUD 연습] 컨트롤러 테스트 가이드

영재임재영 2023. 1. 10. 13:41

 

🧃 컨트롤러의 역할

컨트롤러는 어떤 역할을 할까? 서버 측 가장 앞단에 존재하는 '컨트롤러'는 주로 HTTP 요청을 다루는 역할을 한다.

클라이언트가 웹 브라우저에 URI request를 보내게 되면, 해당 request는 컨트롤러가 받게 된다.

또한 해당 request에 대한 response를 Model에 담에 View를 통해 보여주기도 한다. (MVC 패턴에서 배웠다! 😀😀)

 

테스트 코드의 종류에는 크게 2가지가 있다. '단위 테스트' 와 '통합 테스트' 로 나눌 수 있다.

단위 테스트(Unit Test)에서는 스프링 컨테이너를 올리지 않고 순수하게 로직에 대한 테스트를 진행하는 것이 목적이고,

통합 테스트(Integration Test)에서는 스프링 컨테이너를 올려서 전체적인 기능을 테스트하는 것이 목적이다.

 

단위 테스트에서는 HTTP request에 대해 다루지 않는다! 그리고 컨트롤러의 역할은 HTTP request를 다루는 역할이라고 했다.

그렇다면 컨트롤러에서 실행하는 테스트는 어떤 테스트일까? 통합 테스트만 수행할 수 있는건가?

 

🍔 컨트롤러 테스트 종류

컨트롤러에서 수행하는 테스트는 크게 2가지로 나눌 수 있다.

 

1️⃣ MockMvc 객체를 사용한 '단위 테스트'

@WebMvcTest 어노테이션과 MockMvc 객체를 사용하면 ApplicationContext를 띄우지 않고 컨트롤러 테스트를 진행할 수 있다.

@WebMvcTest(HelloController.class)
public class HelloControllerTest {
	
    @Autowired
    private MockMvc mvc;
    
    @Test
    void 연결() {
    	mvc.perform((get("/hello"))
        	.andExpect(status().isOK())
    }
}

위 예시 코드를 살펴보면, 테스트 코드 클래스에 @WebMvcTest 어노테이션을 적어주었다. 이는 사용할 컨트롤러를 지정한다.

또한 MockMvc 객체의 인스턴스를 통해 '/hello' 주소로 HTTP GET request를 수행할 수 있다.

(하지만 이는 가짜 request 요청이다! 실제 request는 절대 아님!)

 

엇? 스프링 컨테이너를 올리지 않고 테스트를 했다! 그렇다면 컨트롤러에서도 단위 테스트를 수행할 수 있네? ㅇㅇ 맞다.

하지만 컨트롤러에서 테스트를 진행하는 것이라면, 대부분 통합 테스트를 수행해서 컨트롤러를 검증하게 된다.

 

2️⃣ RestTemplate 템플릿을 활용한 '통합 테스트' ⭐️

이는 컨트롤러의 근본적인 역할에 의해 설명할 수 있다. 컨트롤러는 HTTP 요청을 다루는 역할을 하니까!

실제 서비스에서는 실제 HTTP request가 들어오는 상황에서 이를 처리해야하는데, 이는 컨트롤러에서 다룰 수 있다.

따라서 HTTP request를 발생시키기 위해 @SpringBootTest 어노테이션을 붙여서 스프링 웹 서버를 띄워야 한다!

@SpringBootTest
public class HelloControllerTest {
	
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Test
    void 연결 {
    	ResponseEntity<String> responseEntity 
            = restTemplate.getForEntity('http://localhost:8080/hello/1', String.class);
          
        assertThat(responseEntity.getStatusCode()).isEqualTo(200);
    }
}

예시로 위 코드처럼 구현해볼 수 있다. (사실 위 코드는 올바르지 않다. 수도 코드 느낌으로 일단 보자!)

TestRestTemplate 객체의 인스턴스에 getForEntity 라는 메서드를 사용해 위와 같이 HTTP GET request를 수행할 수 있다.

 

참고로, RestTemplate과 TestRestTemplate은 서로 상속관계는 아니다! 여기서는 TestRestTemplate을 주로 사용할 것이다!

("TestRestTemplate does not extend RestTemplate and does offer a few very exciting new features" 라고 한더라)

 

설명을 위한(?) 간단한 설명은 마쳤다. 이제 내가 실제로 코드를 짜보면서 엄청나게 헤매었던 점을 글로 써보도록 하겠다!

 

🍙 랜덤 포트를 열어두는 이유가 뭐야?

@SpringBootTest
class MusicAPIControllerTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void 등록() {
        // given
        MusicSaveRequestDto requestDto
		    = new MusicSaveRequestDto("title", "artist", "album", "lyrics");
        String url = "http://localhost:8080/api/v1/music";
        
        // when (컴파일 에러 발생!)
        ResponseEntity<Long> responEntity
		    = restemplate.postForEntity(url, requestDto, Long.class);
            
        // then
        
    }
}

No qualifying bean of type 'org.springframework.boot.test.web.client.TestRestTemplate' available:
expected at least 1 bean which qualifies as autowire candidate.

 

위 코드를 돌려봤는데, 위와 같은 컴파일 에러가 발생했다. TestRestTemplate을 빈으로 등록할 수 없다는 의미이다.

이는 @SpringBootTest 어노테이션에서 원인을 찾을 수 있다.

 

@SpringBootTest 어노테이션을 사용하면 웹 환경을 만들어서 테스트를 진행할 수 있다.

자주 사용하는 8080 포트 이외에도, 지정한 포트 혹은 랜덤한 포트로 웹 서버를 띄울 수 있다는 것이다.

 

TestRestTemplate을 사용하려면, 현재 열려 있는(= 사용할 수 있는) 포트가 최소 1개 이상은 필요하다.

하지만 현재 열려 있는 포트가 없어서 컴파일 에러가 발생한 것이다. 따라서 사용할 수 있는 포트 하나를 만들어주어야 한다!

 

어떻게 만드냐고? 바로 이때 '랜덤 포트'를 통해 사용할 수 있는 포트를 만들어 놓을 수 있다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MusicAPIControllerTest {

    @LocalServerPort private int port;
    
    ...
}

랜덤 포트로 사용할 수 있는 포트를 하나 열어놓으면, TestRestTemplate이 해당 포트를 등록하여 사용할 수 있는 것이다!

정리하면! TestRestTemplate을 사용할 때는 반드시 랜덤 포트를 통해 사용할 수 있는 포트를 하나 열어두어야 한다! 😀😀

 

🍣 통합 테스트니까! (postForEntity의 동작 방식)

@RestController
public class MusicAPIController {

    private final MusicService musicService;

    @Autowired
    public MusicAPIController(MusicService musicService) {
        this.musicService = musicService;
    }

    @PostMapping("/api/v1/music")
    public Long save(@RequestBody MusicSaveRequestDto requestDto) {
        System.out.println("MusicAPIController.save");  // save 함수 호출했는지 확인하는 로그
        return musicService.save(requestDto);
    }
}

@Service
public class MusicService {
    private final MusicRepository musicRepository;

    @Autowired
    public MusicService(MusicRepository musicRepository) {
        this.musicRepository = musicRepository;
    }

    public Long save(MusicSaveRequestDto requestDto) {
        System.out.println("MusicService.save");  // save 함수 호출했는지 확인하는 로그
        return musicRepository.save(requestDto.toEntity()).getId();
    }
}
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MusicAPIControllerTest {

    @LocalServerPort private int port;
    
    @Autowired
    private MusicRepository musicRepository;
    
    @Autowired
    private TestRestTemplate restTemplate; 

    @AfterEach
    void AfterEach() {
        musicRepository.deleteAll();
    }

    @Test
    void 등록() {
        // given
        System.out.println("MusicAPIControllerTest.given");
        MusicSaveRequestDto requestDto
                = new MusicSaveRequestDto("Sneakers", "ITZY", "CHECKMATE", "Put my sneakers on!");
        String url = "http://localhost:" + port +"/api/v1/music";

        // when
        System.out.println("MusicAPIControllerTest.when");
        ResponseEntity<Long> responseEntity
            = restTemplate.postForEntity(url, requestDto, Long.class);

        // then
        System.out.println("MusicAPIControllerTest.then");
        System.out.println("responseEntity = " + responseEntity);
        System.out.println("responseEntity.getStatusCode() = " + responseEntity.getStatusCode());
        System.out.println("responseEntity.getBody() = " + responseEntity.getBody());

        List<Music> musicList = musicRepository.findAll();
        assertThat(musicList.get(0).getTitle()).isEqualTo(requestDto.getTitle());
    }
}

위 테스트 코드를 실행해보고, 스프링이 찍어준 로그를 살펴보면.. (쉽게 살펴보기 위해 given, when, then 사이 띄웠음)

MusicAPIControllerTest.given

MusicAPIControllerTest.when
2023-01-10 12:51:11.236  INFO 2514 --- [o-auto-1-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2023-01-10 12:51:11.237  INFO 2514 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2023-01-10 12:51:11.237  INFO 2514 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 0 ms
MusicAPIController.save
MusicService.save
Hibernate: insert into music (id, album, artist, lyrics, title) values (default, ?, ?, ?, ?)

MusicAPIControllerTest.then
responseEntity = <200,1,[Content-Type:"application/json", Transfer-Encoding:"chunked", Date:"Tue, 10 Jan 2023 03:51:11 GMT", Keep-Alive:"timeout=60", Connection:"keep-alive"]>
responseEntity.getStatusCode() = 200 OK
responseEntity.getBody() = 1
Hibernate: select music0_.id as id1_0_, music0_.album as album2_0_, music0_.artist as artist3_0_, music0_.lyrics as lyrics4_0_, music0_.title as title5_0_ from music music0_
Hibernate: select music0_.id as id1_0_, music0_.album as album2_0_, music0_.artist as artist3_0_, music0_.lyrics as lyrics4_0_, music0_.title as title5_0_ from music music0_
Hibernate: delete from music where id=?

실행 로그를 보면, when과 then 사이에는 오직 한 줄의 코드 밖에 없다.

하지만 해당 코드에서 Controller와 Service 클래스의 save 함수를 호출한 것을 볼 수 있다. (확인 가능하게 로그 찍어두었다)

ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

위의 한 줄의 코드를 보고 내가 가졌던 의문점은 다음과 같다.

"테스트 코드 내에 Controller, Service의 이름을 명시하지 않았는데, 어떻게 해당 클래스에 접근해서 save 함수를 호출한거지?'

 

사실 내가 간과했던 점이 하나 있다. 위 테스트 코드는 @SpringBootTest를 사용한 '통합 테스트'라는 점..! 🙄🙄

 

실제 서비스라면 실제 API request가 들어오는 상황에서 request를 처리해야 한다.

하지만 테스트 코드에서는 그 상황 자체(=실제 API request가 들어오는 상황)를 직접 만들어서 테스트를 진행하지 않나!

 

여기서는 @SpringBootTest 어노테이션을 사용해 실제 스프링을 올려서 테스트를 진행하는 것이었고,

스프링 컨테이너에 스프링 빈이 등록될 때, 작성한 코드(클래스)들이 스프링 빈으로 컨테이너에 등록되는 것이니,

Controller나 Service 처럼 직접 구현한 코드를 통해 테스트 코드가 실행되는 것이었다!

MusicAPIControllerTest.given

MusicAPIControllerTest.when
// 🛑 DispatcherServlet을 통해 들어온 API 요청을 받음
2023-01-10 12:51:11.236  INFO 2514 --- [o-auto-1-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2023-01-10 12:51:11.237  INFO 2514 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2023-01-10 12:51:11.237  INFO 2514 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 0 ms

// 🛑 DispatcherServlet가 Controller로 API 요청을 넘겨주고 
// 🛑 Controller 내부, 매핑된 URI('/api/v1/music') 메서드에서 save 함수 호출
MusicAPIController.save

// 🛑 연결된 Service 부분으로 넘어가서 save 함수 호출
MusicService.save

// 🛑 Repository 부분으로 넘어가서 Hibernate가 쿼리를 날려 DB에 데이터 저장
Hibernate: insert into music (id, album, artist, lyrics, title) values (default, ?, ?, ?, ?)

MusicAPIControllerTest.then

실행 로그에 각각 어떤 동작이 수행되는지 적어놓았다!

정리하면! 통합 테스트기 때문에 실제 스프링에 등록된 빈으로 테스트 코드가 수행되는 것이다! 😀😀

 

어떤 목적을 가지고 컨트롤러에서 테스트 코드를 짜야하는지, TestRestTemplate을 사용하려면 랜덤 포트를 열어둬야하고,

통합 테스트기 때문에 실제 작성한 코드가 스프링 빈으로 등록되고, 이를 테스트 코드에서 사용한다는 점! 배우는데 오래 걸렸다 🥹

Comments