본문 바로가기
Spring

SpringBoot Controller Test 작성 및 spring-security에서의 test

by khds 2021. 8. 31.

프로젝트를 진행할 때 Test를 진행하는 것이 매우 중요하다. 그 이유는 백엔드로서 코드를 작성하면 그 코드가 올바르게 동작하는지 확실히 해야 하기 때문이다. repository 함수가 제대로 동작하는지, service가 제대로 동작하는지, controller이 제대로 동작하는지 등을 확인해야 한다. 

여기서는 controller test를 살펴볼 것이다. controller를 만들고 제대로 작동되는지 확인하기 위해서는 test코드를 작성하지 않는다면 post나 put 같은 기능을 일일이 웹에서 실행을 하고 이것저것 시도하는 것을 서버를 켜고 해야 하는 번거로움이 있다. 그렇기에 test코드를 작성하는 것이 바람직하다.

아래는 간단한 post에 대한 코드이다. 

 

  • PostsApiController
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;
    
    @PostMapping("/api/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto){
        return postsService.save(requestDto);
    }
}

 

 

  • PostsService  
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class PostsService {

    private final PostsRepository postsRepository;

    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }
}

 

 

  • PostsSaveRequestDto
package sin.sin.dto;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class PostsSaveRequestDto {

    private String title;
    private String content;
    private String author;

    @Builder
    public PostsSaveRequestDto(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;

    }

    public Posts toEntity() {
        return Posts.builder()
            .title(title)
            .content(content)
            .author(author)
            .build();
    }
}

 

 

  • Posts
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@Entity
public class Posts {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 500, nullable = false)
    private String title;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    private String author;

    @Builder
    public Posts(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }
}

 

 

  • PostsRepository
import org.springframework.data.jpa.repository.JpaRepository;

public interface PostsRepository extends JpaRepository<Posts, Long> {

}

 

 

이렇게 Post에 대해 간단하게 작성해 보았다.

아래는 테스트 코드를 작성한 것이다.

 

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;

import java.util.List;
import org.junit.After;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class) // 테스트를 진행할 때 JUnit에 내장된 실행자 외에 다른 실행자 외에 다른 실행자를 실행시킨다.
// 여기서는 SpringRunner라는 스프링 실행자를 사용한다.
// 즉, 스프링 부트 테스트와 JUnit 사이에 연결자 역할을 한다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // 포트번호를 랜덤포트로 지정
class PostsApiControllerTest {

    @LocalServerPort
    private int port;   //포트번호 

    @Autowired
    private TestRestTemplate restTemplate;   //post 요청을 위해 필요, @SpringBootTest를 쓸 때 필요

    @Autowired
    private PostsRepository postsRepository;

    @After  // test가 끝난 다음에 실행
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    public void Posts_등록하다() throws Exception {
        //given
        String title = "제목";
        String content = "내용";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
            .title(title)
            .content(content)
            .author("작성자")
            .build();

        String url = "http://localhost:" + port + "/api/posts"; 

        //when
        ResponseEntity<Long> responseEntity = restTemplate
            .postForEntity(url, requestDto, Long.class); //post 요청

        //then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); //status 확인
        assertThat(responseEntity.getBody()).isGreaterThan(0L);

        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }
}

 

@SpringBootTest를 사용하였다. @WebMvcTest를 사용하는 경우도 있다. @WebMvcTest는 가짜 웹 서버를 만들어 테스트하는 것인데 이 경우엔 JPA 기능이 작동하지 않기 때문에 Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화돼서 지금 같은 상화에서는 유용하지가 않다.

 

위에 예시에서는 Post 요청만을 테스트한 것이다. Post 요청을 할 때 restTemplate.postForEntity를 사용했지만 다른 함수를 사용해도 된다. 다른 요청과 더불어 자세한 내용은 아래의 주소를 참조하길 바란다.

 

https://enterkey.tistory.com/275

 

Spring에서 RestTemplate을 사용하여 REST 기반 서비스 요청과 테스트하기

서론 우리는 며칠간에 걸쳐 Spring Boot로 Spring 기반 REST 서비스와 템플릿 뷰를 사용하여 Multipart Form data를 사용하기 위한 컨트롤러를 구현하는 방법을 살펴보았다. 또한 컨트롤러를 테스트하기 위

enterkey.tistory.com

 

마지막으로 springSecurity를 통해 소셜 로그인 및 유저 권한이 필요한 요청에 대한 테스트에 대해 살펴보고 마무리하겠다. 만약 권한을 설정하고 똑같은 test를 진행하면 다 깨질 것이다. 그렇기에 추가적으로 설정해야 할 것이 있다.

 

소셜 로그인을 진행할 경우 application-oauth를 만들었을 것이다. 하지만 이는 test 환경에서가 아닌 src/main 환경에만 적용된다. 일반적으로 test 환경에서 application.properties 가 없다면 main 환경의 application.properties를 그대로 가져온다. 하지만 application-oauth.properties 는 가져오지 않기 때문에 test 환경에서 새로 application.properties를 생성하고 main에 application.properties 에 대한 내용뿐만 아니라 oauth에 대한 것을 적어야 한다. 물론 실제 비밀번호 같은 건 사용하지 않고 가짜 설정값을 등록한다.

아래는 test에서 작성한 appplication.properties 에서 oauth에 대한 예시 코드이다.

 

spring.security.oauth2.client.registration.google.client-id=test
spring.security.oauth2.client.registration.google.secret=test
spring.security.oauth2.client.registration.google.scope=profile,email

 

그리고 임의로 인증된 사용자를 추가하여 API만 테스트해 볼 수 있게 해야 한다.

그러기 위해서는 먼저 spring-security-test를 build.gradle에 추가한다.

 

implementation 'org.springframework.security:spring-security-test'

 

그리고 test 메서드에 아래의 어노테이션을 추가하여 임의의 사용자 인증을 추가한다.

 

@Test
@WithMockUser(roles ="USER")

 

@WithMockUser는 인증된 가짜 사용자를 만들어서 사용한다. roles에 권한을 추가할 수 있다.

즉, 이 어노테이션으로 인해 ROLE_USER 권한을 가진 사용자가 API를 요청하는 것과 동일한 효과를 가지게 된다.

하지만 위 어노테이션만 적는다고 테스트가 작동하는 것은 아니다. @WithMockUser 가 MockMvc에서만 작동하기 때문이다. 하지만 위의 테스트 코드에서는 @SpringBootTest로 되어있고 MockMvc를 전혀 사용하지 않기에 작동하지 않을 것이다. 이를 해결하기 위해서 @SpringBootTest에서 MockMvc를 사용하도록 하는 방법이 있다.

아래는 변경된 테스트 코드이다.

 

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import org.junit.After;
import org.junit.Before;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import sin.sin.domain.Posts;
import sin.sin.domain.PostsRepository;
import sin.sin.dto.PostsSaveRequestDto;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PostsApiControllerTest {

    @LocalServerPort
    private int port;   //포트번호

    @Autowired
    private PostsRepository postsRepository;

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @Before
    public void setup() {
        mvc = MockMvcBuilders
            .webAppContextSetup(context)
            .apply(springSecurity())
            .build();
    }
    
    @After  // test가 끝난 다음에 실행
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    @WithMockUser(roles = "User")
    public void Posts_등록하다() throws Exception {
        //given
        String title = "제목";
        String content = "내용";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
            .title(title)
            .content(content)
            .author("작성자")
            .build();

        String url = "http://localhost:" + port + "/api/posts";

        //when  생성된 MockMvc를 통해 API를 테스트 한다. Body 영역은 문자열로 표현하기 위해 ObjectMapper를 통해 문자열 JSON으로 변환한다.
        mvc.perform(post(url)
            .contentType(MediaType.APPLICATION_JSON_UTF8)
            .content(new ObjectMapper().writeValueAsString(requestDto)))
            .andExpect(status().isOk());

        //then
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }
}

 

위와 같이 작성하면 테스트는 통과할 것이다. 

 

@SpringBootTest 대신 @WebMvcTest를 사용한다면 다른 조건을 적용해야 한다. 

@WebMvcTest는 WebSecurityConfigurerAdapter, WebMvcConfigurer를 비롯한 @ControllerAdvice, @Controller를 읽는다. 즉, @Repository, @Service, @Component는 스캔 대상이 아니다. 그러니 SecurityConfig는 읽는다 해도 SecurityConfig를 생성하기 위한 CustomOAuth2 UserService는 읽을 수가 없어서 에러가 발생할 것이다.

그래서 이 문제를 해결하기 위해 스캔 대상에 SecurityConfig를 제거 해야 한다. 아래의 코드를 보자.

 

@WebMvcTest(controllers = HelloController.class,
		excludeFilters = {
        @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE,
        							classes = securityConfig.class)
        }
)

 

 

 

참고

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - 이동욱