[TOY] Java API Junit Test 작성 후기

최지나·2024년 1월 15일

개요

  • 토이 프로젝트를 진행하면서 꼭 해보고 싶었던 것 중에 하나가 바로 개발과 동시에 test를 진행하는 것이었다!
  • 업무를 진행하다보면 일정 떄문에 일단 개발을 다 해놓고 나중에 test 코드를 짜거나, Jmeter로 부하 테스트 진헹만 하고, 테스트를 생성할 시간조차 없을 때가 많아 경험이 부족했다 🥶 이에 API 단위 test를 생성하고, TestUtil을 통해 리팩토링한 경험을 기록하고자 한다 🐣

Junit 개념

  • Java 어플리케이션을 테스트하는데 사용되는 자바 테스트 프레임워크
  • 코드를 변경할 때마다 테스트를 수동으로 실행할 필요 없이 변경 사항이 어플리케이션에 영향을 주는지 확인 가능
  • 종속성 추가(Gradle)
testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1'

코드

TestUtil 생성

  • API request 를 보내고, MvcResult를 받아와 응답을 검증하는 코드가 모든 API마다 반복되기 때문에, 이를 공통으로 사용하기 위한 메서드를 생성하여 테스트 코드를 간소화하였다
public static MvcResult performRequest(MockMvc mockMvc, String url, String requestBodyJson, String method,
            int expectedStatus, Integer expectedRetCode) throws Exception {
        ResultActions resultActions = null;
        RequestBuilder requestBuilder = null;

        switch (method) {
            case "GET":
                requestBuilder = MockMvcRequestBuilders.get(url).contentType(MediaType.APPLICATION_JSON);
                break;
            case "POST":
                requestBuilder = MockMvcRequestBuilders.post(url).contentType(MediaType.APPLICATION_JSON)
                        .content(requestBodyJson);
                break;
            case "PATCH":
                requestBuilder = MockMvcRequestBuilders.patch(url).contentType(MediaType.APPLICATION_JSON)
                        .content(requestBodyJson);
                break;
            case "DELETE":
                requestBuilder = MockMvcRequestBuilders.delete(url).contentType(MediaType.APPLICATION_JSON);
                break;
            default:
                throw new IllegalArgumentException("Invalid HTTP method: " + method);
        }

        resultActions = mockMvc.perform(requestBuilder);

        if (expectedStatus == 200) {
            resultActions.andExpect(status().isOk());
        } else {
            resultActions.andExpect(status().is(expectedStatus));
        }

        if (expectedRetCode != null) {
            resultActions.andExpect(jsonPath("$.retCode").value(expectedRetCode));
        }

        return resultActions.andReturn();
    }

어노테이션

  • @ActiveProfiles("test") : 특정 프로파일 활성화. 이 경우 application-test.properties 파일을 사용하여 테스트용 데이터베이스를 설정 가능
  • @AutoConfigureMockMvc : SpringBoot 테스트에서 MockMvc 객체를 자동으로 구성하는데 사용
    • MockMvc는 http 요청 및 응답을 시뮬레이트 하고 컨트롤러의 동작 테스하는데 사용
  • @SpringBootTest: spring 컨텍스트를 로드하고, 모든 애플리케이션 구성 요소를 초기화 -> 실제 애플리케이션과 동일한 환경에서 테스트 수행 가능
  • @Transactional: 테스트 메서드에서 트랜잭션을 관리. 테스트가 완료되면 트랜잭션이 롤백되어 데이터베이스를 테스트 이전의 상태로 격리시킴
  • @Mock: Mockito 프레임워크를 사용하여 가짜(mock) 객체를 생성하는데 사용
  • @InjectMocks: Mockito 프레임워크를 사용하여 가짜 객체로 초기화된 필드를 포함하는 클래스의 인스턴스를 생성
  • @Autowired: spring framework에서 관리되는 빈(bean)을 필드에 주입하는데 사용
  • @DisplayName: 화면에 표출할 주석
@ActiveProfiles("test")
@AutoConfigureMockMvc
@SpringBootTest
@Transactional
@DisplayName("게시글 테스트")
public class PostControllerTest {

    @Mock
    private PostService postService;

    @Mock
    private LoggingService loggingService;

    @InjectMocks
    private PostController postController;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    private static final String POST_API_URL = "/api/v1/posts";

성공 케이스 - POST 요청

  • 게시글 생성 성공 확인
  • Sql("/path/filename"): 각 메서드 테스트 실행 전 특정 경로의 sql 파일 먼저 실행. 테스트에 필요한 데이터를 insert한 뒤, 테스트가 끝나고 @Transactional로 인해 롤백된다
    • 예시에서는 PostUser와 PostUser2를 생성하고, PostUser가 작성한 게시글 4개를 insert하였다
  • @WithMockUser(username = "PostUser", authorities = { "USER" })": spring security를 사용하는 애플리케이션의 테스트에서 가짜(mock) 사용자를 생성하고, 해당 사용자의 권한을 지정하는데 사용. 로그인 API를 먼저 실행하지 않더라도, 인증된 사용자의 권한으로 테스트 진행 가능
    • username: 가짜 사용자의 이름을 지정
    • authorities: 가짜 사용자의 권한 지정
    @Test
    @Sql("/post/PostListSetup.sql")
    @WithMockUser(username = "PostUser", authorities = { "USER" })
    @DisplayName("게시글 등록 성공")
    public void createPostSuccess() throws Exception {
        String requestBodyJson = objectMapper
                .writeValueAsString(new PostReq(DateUtil.getCurrentDateTime(), "SYS_01", "2024 2월 개발 뉴스 공유드립니다",
                        "chatGPT 5.0 도입"));
        TestUtil.performRequest(mockMvc, POST_API_URL, requestBodyJson, "POST", 200, 200);
    }

성공 케이스 - GET 요청

  • UriComponentsBuilder에 쿼리 param을 지정하여 Get API의 uri를 생성
  • pageable 역시 query parameter의 일부로 넘겨줄 수 있다(page, size, sort)
    @Test
    @Sql("/post/PostListSetup.sql")
    @WithMockUser(username = "PostUser", authorities = { "USER" })
    @DisplayName("게시글 목록 조회 성공 - Page 0 Size 3")
    public void getPostListSuccessPage0Size3() throws Exception {

        UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(POST_API_URL);

        uriBuilder.queryParam("sendDate", DateUtil.getCurrentDateTime())
                .queryParam("systemId", "SYS_01")
                .queryParam("searchKeyWord", "post")
                .queryParam("searchType", PostSearchType.TITLE)
                .queryParam("page", 0)
                .queryParam("size", 3)
                .queryParam("sort", "title,desc");

        MvcResult result = TestUtil.performRequest(mockMvc, uriBuilder.toUriString(), null, "GET", 200, 200);
        JsonNode rootNode = objectMapper.readTree(result.getResponse().getContentAsString());

        int contentCount = rootNode.path("content").size();
        assertTrue(contentCount == 3);

        for (JsonNode element : rootNode.path("content")) {
            String title = element.path("title").asText();
            assertTrue(title.startsWith("post4"));
            break;
        }
    }

실패 케이스 예시 - 1

  • 로그인한 사용자가(PostUser2) 다른 사람이 작성한 게시물을 수정 시도했을 경우
  • @WithMockUser 설정을 통해 로그인한 사용자 설정 가능
    @Test
    @Sql("/post/PostListSetup.sql")
    @WithMockUser(username = "PostUser2", authorities = { "USER" })
    @DisplayName("게시글 수정 실패 - 잘못된 사용자")
    public void updatePostFailPostDiffMember() throws Exception {

        String requestBodyJson = objectMapper.writeValueAsString(
                new PostReq(DateUtil.getCurrentDateTime(), "SYS_01", "Updated Title", "Updated Content"));

        TestUtil.performRequest(mockMvc,
                POST_API_URL + "/" + getLatestPostIdByMemberId("PostUser"), requestBodyJson, "PATCH", 200, 400);
    }

실패 케이스 예시 - 2

  • 게시글 삭제 시 없는 게시글 번호(-1)삭제를 요청
    @Test
    @Sql("/post/PostListSetup.sql")
    @WithMockUser(username = "PostUser", authorities = { "USER" })
    @DisplayName("게시글 삭제 실패 - 잘못된 게시글 번호")
    public void deletePostFailNotFound() throws Exception {
        TestUtil.performRequest(mockMvc,
                POST_API_URL + "/" + -1, null, "DELETE", 200, 404); // Not Exist PostId
    }

느낀 점

  • API를 만들 때마다, 반드시 테스트 코드도 함께 작성하여 PR을 올려야지만, 코드가 merge되도록 규칙을 정하여서, 이를 실천하고 있다
  • 테스트 코드를 작성하다보면 무한히 길어질 수 있는데, 이를 새로운 메서드를 생성하여 간소화하는 과정이 재미있었다
  • 그리고 무엇보다 이제 새로운 기능으로 인한 다른 기능의 영향도 테스트가 편해져서, 처음에는 개발 속도가 느리더라고, 크게 보면 시간을 많이 단축할 수 있지 않을까 하는 기대가 있다 🌷
profile
의견 나누는 것을 좋아합니다 ლ(・ヮ・ლ)

2개의 댓글

comment-user-thumbnail
2024년 1월 15일

performRequest 메소드가 맘에들어요!

1개의 답글