본문 바로가기
SpringBoot/테스트 코드 | TDD

[Spring] @WebMvcTest를 이용한 API 테스트 코드 작성 시행착오

by rla124 2024. 8. 27.

개발을 하면서 동시에 테스트 코드 작성까지 했으면 퍼펙트했겠지만 학기 중에 학점을 꽉 채워 듣고 다른 여러 가지를 병행하고 있었기 때문에 시간적으로 테스트 코드를 작성하지 못하였는데 2차 MVP에서는 팀의 목표와는 별도로 내가 맡은 API에 대해서 테스트 코드도 작성해보려고 한다. 앱 실행 스플래쉬 단계에서 먼저 프론트에서 연결해야 했던 API에 대한 테스트 코드를 작성하며 겪었던 시행 착오와 알게된 지식을 기록하고자 한다. 

 

이 게시글에서 소개하는 최종 테스트 코드 작성은 이 PR 링크에서 확인할 수 있다. 

 

테스트 코드를 작성하려는 API 소개

테스트 코드를 작성하려는 API pr 링크이다. 이 이후로 리펙토링을 하여 아래와 같이 되었다. 

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/members")
public class MemberController {

    private final MemberService memberService;

    @PostMapping("/register")
    @Operation(summary = "멤버 등록", description = "디바이스 등록을 위한 API 입니다.")
    public ResponseEntity<MemberInfo> register(@RequestBody MemberRegisterRequest memberRegisterRequest) {

        MemberInfo response = memberService.register(memberRegisterRequest);
        return ResponseEntity.ok(response);
    }
}

 

컨트롤러에 이어서 내부 서비스 레이어의 로직은 아래와 같았다. 이 게시글에 이어서 설명할 것이지만 서비스 레이어의 중복 conflict 예외 처리 테스트도 하고자 하였다.

@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberInfo register(MemberRegisterRequest request) {

        Optional<Member> optionalMember = memberRepository.findByDeviceId(request.deviceId());

        if (optionalMember.isPresent()) {
            throw new WhoaException(EXIST_MEMBER);
        }

        Member newMember = registerMember(request.deviceId());

        memberRepository.save(newMember);

        return MemberInfo.of(newMember);
    }

    private Member registerMember(String deviceId) {
        return Member.createInitMemberStatus(deviceId);
    }
}

 

일반 회원가입에 필요한 request 필드로 아이디와 비밀번호 등이 있다면 스플래쉬 단에서 iOS 디바이스 아이디를 등록한다는 것에서만 다르고 request dto를 통해 받아온 값을 Member 도메인의 static 메소드를 통해 객체를 생성하는 로직이다.

 

이 전반적인 로직에 대해 컨트롤러/레포지토리/서비스 이렇게 3가지 관점에서 테스트 코드를 작성해보려고 한다. 

 

1. 컨트롤러 테스트 코드

200 ok를 반환하는 테스트 코드에 대한 설명은 주석으로 대신한다. 모든 테스트 코드는 chatGPT 의존 없이 직접 짠 테스트 코드이다.

package com.whoa.whoaserver.member.controller;

// 모든 import는 생략 

@WebMvcTest(MemberController.class) 
// 컨트롤러 레이어 테스트 코드 작성을 위한 어노테이션
// (controller, controllerAdvice, jsonComponent만 가능)
// memberController와 관련된 빈만 로드하고자 선언

@AutoConfigureMockMvc(addFilters = false)
// mockMvc를 자동으로 구성해주는 어노테이션
// 테스트에서 HTTP 요청을 쉽게 시뮬레이션하기 위함 
// 해당 플젝은 로그인 없이 모두가 이용가능하도록 하고자 
// Security 도입을 안했기 때문에 
// Spring Security 필터를 비활성화하려고 false 사용(default가 true) 

@MockBean({JpaMetamodelMappingContext.class})
// 테스트에서 사용되는 빈을 모킹 -> JpaMetamodelMappingContext를 mock bean으로
// JPA 관련 빈의 자동 구성 문제를 방지하고 관련 메타데이터 처리하기 위해
// 테스트에서 빈 생성 문제를 피하기 위해 선언

// @ActiveProfiles("dev")
// 별도의 프로필 활성화 없이도 default 환경에서도 잘 동작하는 테스트 상황이라 필요 없었음
public class MemberControllerTest {

    // 서버 없이도 Spring MVC 테스트 가능하도록
    // HTTP 요청을 만들어 컨트롤러를 테스트하기 위해
	@Autowired
	private MockMvc mockMvc;
     
    // java 객체와 JSON 간의 변환
	@Autowired
	private ObjectMapper objectMapper;

    // 실제 서비스 빈을 대신 -> MemberService의 실제 구현이 아닌 모킹된 객체가 사용됨
	@MockBean
	private MemberService memberService;

	@Nested // JUnit에서 중첩 테스트 클래스 작성을 위해
	class 디바이스등록 {
		private static final String TEST_DEVICE_ID = "B353GF4G-E1CB-58B0-0B77-9E47E6G9D362";
		private static final String DUPLICATED_DEVICE_ID = "8834046F-06E3-4BF5-AEEA-E3AB5BF6F383";

		@Test
		void 디바이스_등록_요청() throws Exception {
			// Given
			MemberRegisterRequest request = MemberRegisterRequest.builder()
				.deviceId(TEST_DEVICE_ID)
				.build();

			// When
			ResultActions resultActions = mockMvc.perform(post("/api/members/register")
				.content(objectMapper.writeValueAsString(request))
				.contentType(MediaType.APPLICATION_JSON)
			);

			// Then
			resultActions.andExpect(status().isOk());
		}

	}
}

 

문자열 상수화를 한 TEST_DEVICE_ID는 실제 DB에 저장된 디바이스 아이디가 아니기 때문에 mockMVC를 이용한 테스트 HTTP 요청 시 200 ok가 떠야하는 상황이었다.

 

디바이스 아이디 등록 record request dto에 @Builder 어노테이션을 추가해서 TEST_DEVICE_ID를 request 필드에 할당하여 요청 body 객체를 먼저 만들었다. 그리고 mockMVC를 통해서 post 메소드로 엔드포인트(이때 반드시 "/" 기호로 시작해야 했다. 그렇지않으면 url 인식 오류 에러가 뜨며 테스트가 실패했다.) 요청 시 objectMapper를 통해 이 request를 JSON 형태로 변환한다. 그리고 status가 ok인지 확인하는 과정을 거치는 방식으로 작성했다. 

 

Custom Exception에 대한 테스트 코드를 작성할 때 주의사항 : MOCK 설정(배운 점)

200ok까지는 문제가 없었으나 이미 디바이스 등록이 된 value를 이용해 request를 만들고 다시 mockMVC로 HTTP 디바이스 등록 요청을 할 때 내가 미리 서비스 레이어에서 작성해두었던 custom exception인 WhoaException EXIST_MEMBER 409 CONFLICT 상태 코드가 결과로 나와야 했었다. 하지만 409가 원칙적으로 뜨지 않고 200이 떴던 잘못된 테스트 코드와 결과는 아래와 같았다.

@Test
void 중복된_디바이스_아이디_등록() throws Exception {
    // Given
    MemberRegisterRequest request = MemberRegisterRequest.builder()
        .deviceId(DUPLICATED_DEVICE_ID)
        .build();

    // When
    ResultActions resultActions = mockMvc.perform(post("/api/members/register")
        .content(objectMapper.writeValueAsString(request))
        .contentType(MediaType.APPLICATION_JSON)
    );

    // Then
    resultActions.andExpect(status().isConflict());
}

 

위에서 코드 마지막 줄처럼 isConflict가 나왔어야 했는데 200 ok가 나왔던 결정적인 이유는 Mock 설정이 전혀 이루어지지 않아 MemberService에서 EXIST_MEMBER 예외가 실제로 발생하지 않았기 때문이다. 

 

왜 이미 등록된 디바이스 아이디인데 예외 처리가 안되고 200 status가 뜨는걸까

 

테스트 코드 클래스 필드에서 MemberService를 @MockBean으로 설정했지만 이 서비스 레이어의 register(MemberRegisterRequest request) 메소드 호출 시 어떤 결과를 반환할지 미리 선언/지정을 해주지 않았기 때문에 서비스 레이어의 메소드가 실제 로직을 수행하지 않아 예외 발생 자체가 안되어 200 ok가 뜬 문제 상황이다.

 

따라서 MemberService의 register 메소드를 호출할 때 내가 중복 예외 처리가 뜨도록 설정한 DUPLICATED_DEVICE_ID를 이용한 request로 요청 시 WhoaException을 발생시키도록 Mock 설정을 추가해야 했다.

 

@Test
void 중복된_디바이스_아이디_등록() throws Exception {
    // Given
    MemberRegisterRequest request = MemberRegisterRequest.builder()
        .deviceId(DUPLICATED_DEVICE_ID)
        .build();

    // MemberServiee의 register 메서드가 DUPLICATED_DEVICE_ID로 호출될 때 WhoaException을 발생하도록
    given(memberService.register(request)).willThrow(new WhoaException(EXIST_MEMBER)); // 추가

    // When
    ResultActions resultActions = mockMvc.perform(post("/api/members/register")
        .content(objectMapper.writeValueAsString(request))
        .contentType(MediaType.APPLICATION_JSON)
    );

    // Then
    resultActions.andExpect(status().isConflict());
}

 

이제 정상적으로 아래와 같이 DUPLICATED_DEVICE_ID로 테스트를 진행할 때 409가 뜨며 테스트 코드가 통과한다. 

무야호1

 

예외 처리를 포함한 테스트 코드의 모든 input, output 세팅은 테스트를 진행하는 내가 모두 총괄해야 하는구나를 느꼈다.

 

2. 레포지토리 테스트 코드 

이제는 디바이스 아이디에 따라 Member 객체를 만들어 이를 save하고 올바르게 엔티티가 저장이 되었는지에 대한 레포지토리 테스트 코드를 작성해보려고 했다. 마찬가지로 주석이 코드 설명을 대신한다.

 

package com.whoa.whoaserver.member.repository;

// 모든 import 문 생략 

@DataJpaTest
// Spring Data JPA 레포지토리 테스트를 위한 어노테이션
// JPA 관련 컴포넌트만 로드 
// 원래는 h2와 같은 인메모리 DB를 사용하지만
// 아래 어노테이션과 함께 사용 시 실제 DB를 바탕으로 테스트를 진행

@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
// 실제 사용하는 DB 설정을 그래도 유지(NONE의 의미)
// application 파일에서 정의된 DB를 그대로 사용

@Import({JpaAuditingConfig.class, QuerydslConfig.class})
// JPA  감사, queryDsl 활성화를 위해 테스트에서 추가적인 클래스를 로드
// 이를 로드하지 않으면 프로젝트에서 queryDsl을 사용했기 때문에 JPA 테스트 불가

@ActiveProfiles("dev")
// DB를 이용하므로 application-datasource.yml 활성화가 필요했으며
// 이를 해주지 않았을 경우 datasource url 명시가 없어서 테스트 자체가 실행이 안되었음
public class MemberRepositoryTest {

    // MemberRepository 주입
	@Autowired
	private MemberRepository memberRepository;

	private static final String TEST_DEVICE_ID = "B353GF4G-E1CB-58B0-0B77-9E47E6G9D362";

	@Test
	void 디바이스_등록에_따라_멤버를_저장() {
		// Given
		Member member = Member.builder()
			.deviceId(TEST_DEVICE_ID)
			.registered(true)
			.build();

		// When
		Member newMember = memberRepository.save(member);

		// Then
		Assertions.assertNotNull(newMember);
		Assertions.assertNotNull(newMember.getId());
		Assertions.assertNotNull(newMember.getCreatedAt());
		Assertions.assertNotNull(newMember.getDeviceId());
	}
}

 

기존에 Member 엔티티의 @Builder는 accessLevel이 PRIVATE였으나 테스트 코드 작성을 위해 해당 접근 제한을 없앴다.

 

위 통과한 테스트 코드에 이르기 까지 접했던 오류를 기록하고자 한다.

 

JPA 설정 관련 Config import 누락

@Import({JpaAuditingConfig.class, QuerydslConfig.class})

 

위 class에서 어느 하나라도 누락하면 안되는데 QuerydslConfig를 빠뜨리고 테스트를 실행했을 때 아래와 같은 오류가 뜬다. 

Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2024-08-27T17:11:44.598+09:00 ERROR 1804 --- [    Test worker] o.s.b.d.LoggingFailureAnalysisReporter   : 

***************************
APPLICATION FAILED TO START
***************************

Description:

Parameter 0 of constructor in com.whoa.whoaserver.bouquet.repository.BouquetRepositoryImpl required a bean of type 'com.querydsl.jpa.impl.JPAQueryFactory' that could not be found.


Action:

Consider defining a bean of type 'com.querydsl.jpa.impl.JPAQueryFactory' in your configuration.

 

나는 MemberRepository에 대한 테스트를 진행하는 것이지만 QueryDsl을 적용하여 JPA를 구성한 레포지토리 인터페이스가 있었기 때문에 JPA 테스트를 진행할 때는 JPA  설정 파일 모두를 import 해야 함을 배울 수 있었다. 

 

아래와 같이 @AutoConfigureTestDatabase를 적용하여 실제 DB 설정과 동일하게 구성하였기 때문에 hibernate insert into 쿼리문이 찍히며 MemberRepository.save 테스트가 통과함을 확인할 수 있었다. 

무야호2

 

 

3. 서비스 레이어 테스트 코드

이 게시글의 도입부에서 서비스 레이어의 코드를 제시하였는데 하나의 메소드에 대해서, 그리고 컨트롤러 테스트 코드처럼 예외 처리가 잘 되는지에 대한 확인을 하면 된다. 

package com.whoa.whoaserver.member.service;

import com.whoa.whoaserver.global.exception.ExceptionCode;
import com.whoa.whoaserver.global.exception.WhoaException;
import com.whoa.whoaserver.member.domain.Member;
import com.whoa.whoaserver.member.dto.request.MemberRegisterRequest;
import com.whoa.whoaserver.member.dto.response.MemberInfo;
import com.whoa.whoaserver.member.repository.MemberRepository;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.mockito.BDDMockito.*;

@ExtendWith(MockitoExtension.class)
// Mockito의 확장 기능 : mock 객체를 테스트 클래스에 주입하고 초기화
// @Mock, @InjectMocks 어노테이션을 사용하기 위함 
public class MemberServiceTest {

	@InjectMocks // 테스트 대상 객체에 붙이는 어노테이션
	private MemberService memberService;

	@Mock // @InjectionMocks 객체 필드 중 @Mock으로 생성된 객체를 자동 주입
	private MemberRepository memberRepository;

	@Nested
	@DisplayName("디바이스 기기 등록 테스트")
	class RegisterTest {
		private static final String TEST_DEVICE_ID = "C464HG5H-F2DC-69C1-1C88-0F58F7H0E473";
		private MemberRegisterRequest request;

        // 각 테스트 실행 전 먼저 실행되도록
		@BeforeEach
		void setRegisterRequest() {
			request = new MemberRegisterRequest(TEST_DEVICE_ID);
		}

		@Test
		void 디바이스_기기_등록_성공() {
			// Given
			given(memberRepository.findByDeviceId(TEST_DEVICE_ID)).willReturn(Optional.empty());

			// When
			MemberInfo memberInfo = memberService.register(request);

			// Then
			Assertions.assertNotNull(memberInfo);
			Assertions.assertEquals(TEST_DEVICE_ID, memberInfo.deviceId());
		}

		@Test
		void 중복된_디바이스_기기_아이디_등록() {
			// Given
			Member existingMember = Member.createInitMemberStatus(TEST_DEVICE_ID);
			given(memberRepository.findByDeviceId(TEST_DEVICE_ID)).willReturn(Optional.of(existingMember));

			// When
			WhoaException e =
				Assertions.assertThrows(
					WhoaException.class, () -> memberService.register(request));

			// Then
			Assertions.assertEquals(ExceptionCode.EXIST_MEMBER, e.getExceptionCode());
		}
	}
}

 

MemberService class의 register 메소드를 테스트하는 코드이다. 서비스 레이어 테스트 코드에서 중요한 것은 내가 테스트를 위해 임의로 MemberRepository에서 메소드를 추가하는게 아니라 MemberService에 이용된 repository interface에 정의된 메소드를 그대로 이용하는 것이다. 

무야호3


테스트 코드를 작성하면서 공부할 수 있었던 부분이 많아 진작 작성하면 더 좋았을 것 같다는 생각이 들었다.