Spring Boot에서 MVC 패턴을 사용한 백엔드 프로젝트 테스트

수정 중 입니다.

테스트 전략 분리

각 서비스 테스트

  1. 단위 테스트(Unit Test): 각 서비스 내부의 개별 함수나 메서드를 테스트합니다. 이는 서비스 내부의 각 부분이 정확하게 작동하는지 확인하는 데 중요합니다.

  2. 서비스별 통합 테스트(Service Integration Test): 각 서비스가 외부 리소스(예: 데이터베이스, 다른 서비스 등)와 올바르게 통신하는지 테스트합니다. 이를 위해 모킹(Mocking)이나 스텁(Stub) 등의 기법을 사용하거나, 필요한 경우 실제 리소스를 사용할 수도 있습니다.


모든 서비스 테스트

  1. 전체 시스템 통합 테스트(System Integration Test): 모든 서비스를 함께 실행하여 전체 시스템이 올바르게 작동하는지 테스트합니다. 이는 사용자 시나리오를 따라 테스트를 수행하거나, 서비스 간의 통신을 검증하는 등의 작업을 포함할 수 있습니다.

1. 단위 테스트

Controller 단위 테스트

@WebMvcTest 어노테이션을 사용하여 컨트롤러를 격리된 환경에서 테스트한다.

이 경우, @WebMvcTest는 웹 계층에 관련된 빈들만 로드하기 때문에 테스트 실행 시간이 짧다.

@MockBean을 사용하여 서비스 레이어를 모킹하고, 예상되는 행동을 정의할 수 있다.

// @WebMvcTest는 Spring MVC의 컨트롤러 단위 테스트를 위한 특수화된 테스트 애노테이션입니다.
// 웹 계층에 필요한 구성요소들만 로드되며, 
// 이는 웹 계층에 대한 단위 테스트를 빠르고 효율적으로 수행할 수 있게 합니다.
@WebMvcTest(YourController.class)
public class YourControllerTest {

	// MockMvc는 Spring MVC 애플리케이션을 테스트하기 위한 라이브러리입니다.
    // 이를 사용하면 HTTP 요청을 디스패처 서블릿에 보내고, 
    // 그 결과로 반환된 모델과 뷰를 검증할 수 있습니다.
    @Autowired
    private MockMvc mockMvc;

    // 실제 객체 대신 Mockito 프레임워크를 사용하여 생성된 Mock 객체가 사용
    // 이를 통해, 테스트에서는 외부 시스템에 의존하지 않고 원하는 동작을 정의할 수 있습니다.
    @MockBean 
    private YourService yourService;
    
    // 테스트 메소드들...
}

실제 컨트롤러 단위 테스트 코드

실제 개발한 프로젝트의 테스트 코드를 보면서 더 자세하게 알아보자.

// 테스트할 컨트롤러 클래스와 연관된 웹 계층에 필요한 구성 요소들만 로드
@WebMvcTest(SurveyDocumentExternalController.class)
public class SurveyDocumentExternalControllerTest {

	// JSON 변환 위해
    private final ObjectMapper objectMapper = new ObjectMapper();
    
    // HTTP 요청을 보내기 위해
    @Autowired
    MockMvc mockMvc;
    
    // SurveyController가 의존하고 있는 surveyDocumentService 
    // @MockBean을 이용하여 가짜 객체를 넣어줬다.
    @MockBean
    SurveyDocumentService surveyDocumentService;
    
    @Test
    @DisplayName("설문 생성 TEST")
    public void createSurveyTest() throws Exception {
        // given
        // 설문 생성을 위한 요청 DTO이다.
        SurveyRequestDto surveyRequestDto = createSurveyRequestDto();
        // 컨트롤러의 설문 생성 메소드 내의
        // 실행되는 서비스의 응답 값을 지정해준다.(현재는 @MockBean으로 가짜 객체를 넣어줬으니 제대로 작동하지 않는다.)
        final Long FIRST_CREATED_SURVEY_ID = 100L;
        //  when 이 메소드가 실행됐을 때 응답값은 .thenReturn에 지정
        when(surveyDocumentService.createSurvey(any(HttpServletRequest.class), any(SurveyRequestDto.class)))
                .thenReturn(FIRST_CREATED_SURVEY_ID);

        // when
        // MockMvc를 통해 설문 생성 경로로 HTTP 요청을 보낸 뒤 응답을 확인한다.
        MvcResult createResult = mockMvc.perform(post("/api/document/external/create")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(surveyRequestDto))
                )
                .andExpect(status().isOk())
                .andDo(print())
                .andReturn();
                
        // then
        // 응답 값이 지정한 값과 같은지 확인한다.
        assertEquals(createResult.getResponse().getContentAsString(), String.valueOf(FIRST_CREATED_SURVEY_ID));
    }
    
    private static SurveyRequestDto createSurveyRequestDto() {
        
        ...

        return SurveyRequestDto.builder()
                .title("설문 제목")
                .description("설문 내용")
                .type(0)
                .reliability(true)
                .startDate(new Date())
                .enable(true)
                .questionRequest(questionRequestDtoList)
                .design(design)
                .build();
    }

}

Service 단위 테스트

@ExtendWith(MockitoExtension.class) 어노테이션은 JUnit5의 기능인 Extension Model을 활용하여 MockitoJUnit5와 통합하는 역할을 한다. 이를 사용하면 @Mock, @InjectMocks, @Spy 등의 Mockito 어노테이션을 JUnit5 테스트에서 사용할 수 있게 된다.

@Mock 어노테이션은 원본 객체와 같은 메소드를 가지지만 프로그래머가 정의한 대로 동작하도록 설계될 수 있다.

@InjectMocks 어노테이션은 Mockito@Mock 또는 @Spy 어노테이션으로 생성된 Mock 객체를 해당 어노테이션이 붙은 필드(주로 테스트하려는 클래스의 인스턴스)의 필드에 자동으로 주입한다.

@ExtendWith(MockitoExtension.class)
public class SurveyDocumentServiceTest {

    @Mock
    private SurveyDocumentRepository surveyDocumentRepository;

    @InjectMocks
    private SurveyDocumentService surveyDocumentService;

    // 테스트 메소드들...
}

객체 Mocking

간단한 유저 정보 서비스 코드가 있다고 해보자.

@Service
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional
    public Long createUser(HttpServletRequest request, UserDto userDto) {
        // UserDto에서 유저 정보를 가져오기
        String name = userDto.getName();
        String email = userDto.getEmail();

        // User 객체 생성
        User user = User.builder()
                .name(name)
                .email(email)
                .build();

        // User 객체 저장
        User savedUser = userRepository.save(user);

        // 저장된 User의 ID 반환
        return savedUser.getId();
    }
}

위의 서비스 코드를 단위 테스트 하는 코드를 짜보자.

@Test
@DisplayName("유저 생성 성공")
public void createUserSuccess() {
	// given
    HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
    UserDto userDto = Mockito.mock(UserDto.class);

	when(userDto.getName()).thenReturn("Test Name");
	when(userDto.getEmail()).thenReturn("test@example.com");

	when(userRepository.save(any(User.class))).thenAnswer(i -> {
    	User user = i.getArgument(0);
        user.setId(1L);
        return user;
	});

	// when
	Long userId = userService.createUser(request, userDto);

	// then
    assertEquals(1L, userId);
}

위의 테스트 메소드를 보면 Mockito.mock 을 통해 서비스 코드의 createUser 메소드가 필요로 하는 객체를 Mocking 하고 있다. 즉, 필요한 객체를 가짜 객체로 만들어서 인자로 넘겨준다는 것이다.

위의 가짜 객체를 넘겨주면 어떻게 될까? 물론 서비스 코드에서 가짜 객체에 접근할 때 에러가 발생할 것이다.

위의 문제를 해결하기 위해서는 when.thenReturn 을 사용한다. 서비스 코드에서 가짜 객체를 사용하는 메소드들이 실행되면 그 결과 값을 미리 지정한 값으로 돌아가게 하는 것이다.

이렇게 Mock 객체를 사용하는 이유는 createUser 메소드가 UserDto 객체의 내부 상태에 의존하지 않고 독립적으로 테스트할 수 있도록 하기 위함이다. 이렇게 하면 객체의 실제 구현에 의존하지 않고도 동일한 인터페이스를 가진 어떤 객체와도 동작할 수 있다. 이는 단위 테스트의 목적에 맞게 특정 메소드의 동작만을 고립시켜 테스트에 도움이 된다.

하지만 위의 방법이 항상 옳은 것은 아니다. 테스트의 목적과 상황에 따라 어떤 방법을 사용할지 결정해야 한다. 위의 방법은 테스트 코드가 복잡해지고, 실제 객체의 동작을 완벽하게 모방하지 못하는 경우가 있을 수도 있으며 각 테스트 케이스에서 Mock 객체의 동작을 일일이 설정해야 하므로 유지 보수도 어려워질 수 있다.

즉, Mocking을 사용할지, 실제 객체를 사용할지 결정하는 것은 테스트의 목적과 복잡도, 그리고 테스트 대상의 특성에 따라 달라진다. 이를 고려하여 적절한 방법을 선택하는 것이 중요하다.

실제 서비스 단위 테스트 코드

    @Test
    @DisplayName("설문 생성 성공")
    public void createSurveySuccess() {
        // given
        // 가짜 HttpServletRequest 객체 사용
        HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
        SurveyRequestDto surveyRequestDto = createSurveyRequestDto();
        SurveyDocument surveyDocument = createSurveyDocument();
		
        // 가짜 객체를 사용하는 메소드의 반환 값 설정
        when(apiService.getCurrentUserFromJWTToken(request)).thenReturn(Optional.of(1L));
        when(designRepository.save(any(Design.class))).thenReturn(surveyDocument.getDesign());
        when(dateRepository.save(any(DateManagement.class))).thenReturn(surveyDocument.getDate());
        when(questionDocumentRepository.save(any())).thenReturn(surveyDocument.getQuestionDocumentList().get(0));
        when(questionDocumentRepository.save(any())).thenReturn(surveyDocument.getQuestionDocumentList().get(1));
        when(choiceRepository.save(any())).thenReturn(surveyDocument.getQuestionDocumentList().get(0).getChoiceList().get(0));
        when(choiceRepository.save(any())).thenReturn(surveyDocument.getQuestionDocumentList().get(0).getChoiceList().get(1));
        when(surveyDocumentRepository.save(any())).thenReturn(surveyDocument);

        // when
        Long surveyId = surveyDocumentService.createSurvey(request, surveyRequestDto);

        // then
        assertEquals(1L, surveyId);
    }

Repository 단위 테스트

@DataJpaTest 어노테이션은 JPA 관련 테스트 설정만 로드한다.

  • @DataJpaTest 특징
    • JPA에 관련된 요소들만 테스트하기 위한 어노테이션으로 JPA 테스트에 관련된 설정들만 적용해준다.
    • 메모리상에 내부 데이터베이스를 생성하고 @Entity 클래스들을 등록하고 JPA Repository 설정들을 해준다. 각 테스트마다 테스트가 완료되면 관련한 설정들은 롤백된다.
@DataJpaTest
public class SurveyDocumentRepositoryTest {

    @Autowired
    SurveyDocumentRepository surveyDocumentRepository;

    @Test
    @DisplayName("설문 조회 테스트")
    public void findSurveyById() {
        //given
        SurveyDocument surveyDocument = createSurveyDocument();
        SurveyDocument saveSurvey = surveyDocumentRepository.save(surveyDocument);

        // when
        SurveyDocument findSurvey = surveyDocumentRepository.findSurveyById(saveSurvey.getId()).get();

        // then
        assertEquals(saveSurvey.getId(), findSurvey.getId());
	}

Webclient 테스트

단위 테스트를 할때, webClientmock으로 주입 받는 상황을 보자.

@ExtendWith(MockitoExtension.class)
public class ChatGptServiceTest {

    @Mock
    private WebClient webClient;

    @InjectMocks
    private ChatGptService chatGptService;
    
    ...
}

위의 코드는 chatGPTService 에서 HTTP 통신을 할때, webClient을 사용하기 때문에 단위 테스트를 하기 위해서 webClientMock 하여 테스트 하는 코드이다.

위의 Mock 으로 주입된 가짜 webClient 객체의 결과 지정을 어떻게 할까?
이를 위해서는 webClient의 체인의 각 호출에 대해 서로 다른 모의 객체를 제공해야 한다.

        when(webClient.post()).thenReturn(requestBodyUriSpec);
        when(requestBodyUriSpec.uri(anyString())).thenReturn(requestBodySpec);
        when(requestBodySpec.header(anyString(), anyString())).thenReturn(requestBodySpec);
        when(requestBodySpec.body(any())).thenReturn(requestHeadersSpec);
        when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);

즉, 이렇게 이어진 체인에 대해 모두 결과를 지정해줘야 한다는 것이다. 이는 매우 복잡하며 번거롭다.

MockWebServer 사용 - 공식문서

MockWebServer를 사용하면 실제 HTTP 요청을 보내고 응답을 받을 수 있기 때문에, WebClient의 동작을 더 정확하게 테스트할 수 있다.

  • 의존성 추가
testImplementation 'com.squareup.okhttp3:okhttp:4.12.0'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
  • 테스트 코드에 MockServer 적용
public class ChatGptServiceTest {

    private static MockWebServer mockBackEnd;
    private ObjectMapper MAPPER = new ObjectMapper();
    private ChatGptService chatGptService;

    @BeforeEach
    void setUp() throws IOException {
		// MockWebServer 생성
    	mockBackEnd = new MockWebServer();
        // MockWebServer 시작
    	mockBackEnd.start();
        
        // MockWebServer 실행 포트 확인
    	String baseUrl = String.format("http://localhost:%s", mockBackEnd.getPort());
        // 테스트할 서비스에서 사용할 WebClient가 MockWebServer로 요청을 보내도록 수정하여 생성
    	WebClient webClient = WebClient.builder().baseUrl(baseUrl).build();
        // 조작된 webClient 주입
        // 이 방법은 문제가 있다. 하단에 추가 설명
    	chatGptService = new ChatGptService(WebClient.builder());
    	chatGptService.setWebClient(webClient);  
    }

    @AfterEach
    void tearDown() throws IOException {
        mockBackEnd.shutdown();
    }

    @Test
    @DisplayName("ChatGPT 질문 후 응답 테스트")
    void chatGptResult() throws JsonProcessingException {
        // given
        ChatGptQuestionRequestDto chatGptQuestionRequestDto = new ChatGptQuestionRequestDto();
        chatGptQuestionRequestDto.setQuestion("ChatGPT 질문입니다.");

        ChatResultDto expectedResponse = ChatResultDto.builder()
                .index(1)
                .text("ChatGPT 답변입니다.")
                .finishReason("종료 이유입니다.")
                .build();

        mockBackEnd.enqueue(new MockResponse()
                .setBody(MAPPER.writeValueAsString(expectedResponse))
                .addHeader("Content-Type", "application/json"));

        // when
        Mono<ChatResultDto> actualResponseMono = chatGptService.chatGptResult(chatGptQuestionRequestDto);

        // then
        ChatResultDto actualResponse = actualResponseMono.block();
        assertEquals(expectedResponse.getIndex(), actualResponse.getIndex());
        assertEquals(expectedResponse.getText(), actualResponse.getText());
        assertEquals(expectedResponse.getFinishReason(), actualResponse.getFinishReason());

        RecordedRequest recordedRequest = mockBackEnd.takeRequest();
        assertEquals("POST", recordedRequest.getMethod());
        assertEquals("/api/chatGpt", recordedRequest.getPath());
    }
}

테스트를 위한 WebClient 조작 메소드 문제점

// test 코드
chatGptService = new ChatGptService(WebClient.builder());
chatGptService.setWebClient(webClient); 

// 실제 ChatGptService 코드 내의 테스트를 위해 WebClient를 조작하는 메소드
public void setWebClient(WebClient webClient) {
	this.webClient = webClient;
}

위의 코드를 통해 ChatGptService가 사용하는 WebClient 조작하여 테스트 시 WebClientMockWebServer를 바라보도록 하여 테스트를 할 수 있다. 이때, 테스트 코드는 test 패키지에 있으므로 WebClient 를 조작하는 메소드의 접근 제어자는 public 이어야 한다.

테스트를 위한 메서드를 public으로 열어두는 것의 문제

  1. 캡슐화 위반: 객체 지향 프로그래밍에서는 각 객체의 상태와 행동을 캡슐화하여 외부에서의 직접적인 접근을 제한하는 것이 중요하다. 캡슐화는 객체의 내부 구현을 숨기고, 외부에는 필요한 인터페이스만을 제공함으로써 객체의 독립성과 유지 보수성을 높이는 역할을 한다. 그러나 테스트를 위한 메서드를 public으로 열어 두면 이러한 캡슐화가 깨지게 된다.

  2. 클래스 인터페이스 오염: 클래스의 public 인터페이스는 해당 클래스의 사용 방법을 정의하는 중요한 부분이다. 테스트를 위한 메서드가 public 인터페이스에 포함되면 클래스의 사용자가 이 메서드를 잘못 사용할 가능성이 있다. 또한 이 메서드는 실제 동작에는 필요 없는 메서드이므로 클래스의 인터페이스를 불필요하게 복잡하게 만들 수 있다.

  3. 코드 유지 보수성 저하: 테스트를 위한 메서드가 추가되면 이 메서드를 유지 보수해야 하는 부담이 생긴다. 또한 이 메서드는 실제 동작과는 무관하므로 코드를 이해하고 유지 보수하는 데 혼란을 줄 수 있다.

생성자를 통해 주입

ChapGPTServiceWebClient를 생성자를 통해 주입 받는다면, 테스트에서는 MockWebServer를 바라보는 WebClient를 주입할 수 있다. 즉, 테스트 환경에서는 테스트를 위한 객체를 이용해 생성한다는 것이다.

@Service
public class ChatGptService {

    private WebClient webClient;
	// 생성자 주입으로 변경
    public ChatGptService(WebClient webClient) {
        this.webClient = webClient;
    }
    
    ...
}
// Test 코드
@ExtendWith(MockitoExtension.class)
public class ChatGptServiceTest {

    private static MockWebServer mockBackEnd;
    private final ObjectMapper mapper = new ObjectMapper();
    @InjectMocks
    private ChatGptService chatGptService;

    @BeforeEach
    void setUp() throws IOException {
        mockBackEnd = new MockWebServer();
        mockBackEnd.start();

        String baseUrl = String.format("http://localhost:%s", mockBackEnd.getPort());
        WebClient webClient = WebClient.builder()
                .baseUrl(baseUrl)
                .filter(WebClientConfig.logRequest())
                .build();
		// ChatGptService에 조작된 WebClient 주입
        chatGptService = new ChatGptService(webClient);
    }

    @AfterEach
    void tearDown() throws IOException {
        mockBackEnd.shutdown();
    }
    
    ...
    
    // 테스트 코드
}

Webclient 운영 환경별 설정

운영 환경에 따라 WebClient 빈의 생성 설정(baseUrl)을 달리해서 사용하는 방법도 있다.

  • TestWebClientConfig : test 환경에서 사용할 WebClient 설정이다.
    • 운영 환경에서 사용할 WebClient 설정은 @Profile({"local","prod"})application.properties 별 사용할 설정을 프로필로 지정해주면 된다.
@Configuration
@Profile("test")
public class TestWebClientConfig {

    @Bean(initMethod = "start", destroyMethod = "shutdown")
    public MockWebServer mockWebServer() {
        return new MockWebServer();
    }

    @Bean
    public WebClient webClient(MockWebServer mockWebServer) {

        String baseUrl = String.format("http://localhost:%s", mockWebServer.getPort());
        return WebClient.builder()
                .baseUrl(baseUrl)
                .build();
    }
}
  • test 코드 : @ActiveProfile 을 사용하여 활성화할 프로필을 선택한다.
@Transactional
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
public class SurveyDocumentIntegrationTest {
    @Autowired
    MockMvc mockMvc;
    @Autowired
    MockWebServer mockBackEnd;
    private ObjectMapper mapper = new ObjectMapper();
    ...
}

그러면 이제 운영 환경에 따라 사용하는 WebClient 빈의 설정을 달리하여 등록해서 사용할 수 있다.

그러면 이제 운영 환경에서 사용되는 WebClient는 어떻게 테스트할까?

  • 운영 환경의 WebClient 설정
@Slf4j
@Profile({"local", "server"})
@Configuration
@RequiredArgsConstructor
public class WebClientConfig {
    private final ObjectMapper objectMapper;
    @Value("${gateway.host}")
    private String gateway;

    public static ExchangeFilterFunction logRequest() {
        return (clientRequest, next) -> {
            log.info("Request: {} {}", clientRequest.method(), clientRequest.url());
            clientRequest.headers().forEach((name, values) -> values.forEach(value -> log.info("{}={}", name, value)));
            return next.exchange(clientRequest);
        };
    }

    @Bean
    public WebClient webClient() {
        final int bufferSize = 16 * 1024 * 1024;  // 16MB
        final ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
                .codecs(configurer -> {
                    configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(objectMapper, MediaType.APPLICATION_JSON));
                    configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(objectMapper, MediaType.APPLICATION_JSON));
                    configurer.defaultCodecs().maxInMemorySize(bufferSize);
                })
                .build();

        return WebClient.builder()
                .baseUrl(gateway)
                .exchangeStrategies(exchangeStrategies)
                .filter(logRequest())
                .build();
    }
}

바로 단위 테스트를 이용하면 된다.

@ExtendWith(MockitoExtension.class)
public class WebClientConfigTest {

    @InjectMocks
    private WebClientConfig webClientConfig;
    @Mock
    private ObjectMapper objectMapper;

    @Test
    public void webClientBeanCreationTest() {
        WebClient webClient = webClientConfig.webClient();
        assertNotNull(webClient);
    }
}

위의 코드를 보면 운영 환경의 @ExtendWith(MockitoExtension.class)를 활용하여 WebClient가 의존하고 있는 ObjectMapperMock 객체로 주입한다.

그 이후 테스트 코드에서 WebClientConfig에 따라 webClient 를 생성하고 WebClient 객체가 null 이 아님을 검증한다.

2. 각 서비스 별 통합 테스트

다른 서비스와의 인터랙션 부분 해결 방법

현재 우리가 수행하고 있는 것은 MSA(Microservices Architecture)의 독립적인 구성요소인 A 서비스에 대한 통합 테스트이다. A 서비스는 B 서비스와의 인터랙션을 포함하고 있지만, 이 테스트의 목적은 A 서비스의 독립적인 기능성을 검증하는 것이다. 따라서 B 서비스와의 통신은 MockWebServer를 이용하여 모킹하고, 이를 통해 A 서비스의 통합 테스트를 수행한다.

특히 MSA(Microservices Architecture) 에서는 서비스 간에 독립성을 유지하는 것이 중요하다. 이 경우 A 서비스의 테스트 도중 B 서비스의 상태에 따라 테스트 결과가 달라지는 것을 방지하기 위해 B 서비스를 MockWebServer 등을 이용해 모킹하는 것이 유용하다.

테스트 전략

통합 테스트는 애플리케이션의 모든 계층이 함께 잘 작동하는지 검증하는 것이 목표이다.

@SpringBootTest 애노테이션을 사용하여 전체 애플리케이션 컨텍스트를 로드하는 통합 테스트를 수행할 수 있다.

이 경우, 실제 서비스와 리포지토리 레이어를 사용하여 테스트를 수행한다.

이는 애플리케이션의 실제 동작을 가장 잘 반영하지만, 테스트 실행 시간이 길어질 수 있다. 또한 DB는 인메모리를 사용하였다.

예시 코드

@Transactional // 각 테스트마다 데이터를 롤백하기 위해 적용
@SpringBootTest // 전체 애플리케이션 컨텍스트를 로드하기 위해 적용
@AutoConfigureMockMvc // MockMvc를 사용하기 위해 적용
// 다른 서비스와 통신할때 사용하는 WebClient가 MockWebServer를 바라보는 설정으로 적용하기 위해
// Redis 및 Mysql 인메모리 DB 사용 위해
@ActiveProfiles("test") 
public class SurveyDocumentIntegrationTest {
    @Autowired
    MockMvc mockMvc;
    @Autowired
    MockWebServer mockBackEnd;
    @Autowired
    private ObjectMapper mapper;

    @Test
    @DisplayName("설문 2개 생성 성공")
    public void createSurveySuccess() throws Exception {
        // given
        SurveyRequestDto surveyRequestDto = createSurveyRequestDto();
        final Long FIRST_CREATED_SURVEY_ID = 1L;
        final Long SECOND_CREATED_SURVEY_ID = 2L;
        Long userId = 1L;
        mockBackEnd.enqueue(createMockResponse(userId));
        mockBackEnd.enqueue(createMockResponse(userId));

        // when
        MvcResult createResult1 = mockMvc.perform(post("/api/document/external/create")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(mapper.writeValueAsString(surveyRequestDto))
                )
                .andExpect(status().isCreated())
                .andDo(print())
                .andReturn();
        MvcResult createResult2 = mockMvc.perform(post("/api/document/external/create")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(mapper.writeValueAsString(surveyRequestDto))
                )
                .andExpect(status().isCreated())
                .andDo(print())
                .andReturn();

        // then
        assertEquals(createResult1.getResponse().getContentAsString(), String.valueOf(FIRST_CREATED_SURVEY_ID));
        assertEquals(createResult2.getResponse().getContentAsString(), String.valueOf(SECOND_CREATED_SURVEY_ID));
    }
  • TESTMockWebServerWebClient 설정 파일
@Profile("test")
@Configuration
public class TestWebClientConfig {

    @Bean(initMethod = "start", destroyMethod = "shutdown")
    public MockWebServer mockWebServer() {
        return new MockWebServer();
    }

    @Bean
    public WebClient webClient(MockWebServer mockWebServer) {

        String baseUrl = String.format("http://localhost:%s", mockWebServer.getPort());
        return WebClient.builder()
                .baseUrl(baseUrl)
                .build();
    }
}
## Redis 임베디드 모드 사용
spring.cache.type=redis
spring.cache.redis.time-to-live=3600
spring.cache.redis.host=localhost
spring.cache.redis.port=6379

# local server
server.port=8082

# local gateway
gateway.host=http://localhost:8080

Redis 테스트

  • {"local", "server"} 환경에서 사용하는 Redis Configuration 파일
@Configuration
@Profile({"local", "server"})
public class RedissonConfig {
    @Value("${spring.cache.redis.host}")
    private String redisHost;
    @Value("${spring.cache.redis.port}")
    private int redisPort;

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        return Redisson.create(config);
    }

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    @Bean
    public CacheManager cacheManager() {
        RedisCacheManager.RedisCacheManagerBuilder builder =
                RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory());
        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(
                                new GenericJackson2JsonRedisSerializer())) // Value Serializer 변경
                .entryTtl(Duration.ofMinutes(30)); // 캐시 수명 30분
        builder.cacheDefaults(configuration);
        return builder.build();
    }
}

Redis Test

  • 위의 설정이 제대로 적용되는지 확인하는 테스트
    • 이 테스트 또한 운영 환경의 Redis(local) 만 실행하여 단위 테스트로 검증한다.
@ActiveProfiles("local")
@ExtendWith(SpringExtension.class)
@ContextConfiguration(
        initializers = {ConfigDataApplicationContextInitializer.class},
        classes = RedisConfig.class)
public class RedisConfigTest {
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;
    @Autowired
    private CacheManager cacheManager;

    @Test
    public void redisConnectionFactoryBeanExists() {
        assertThat(redisConnectionFactory).isNotNull();
    }

    @Test
    public void cacheManagerBeanExists() {
        assertThat(cacheManager).isNotNull();
    }
}
  • Redis 설정이 적용됐는지 확인한다.

임베디드 레디스 사용

공식문서

테스트 환경에 레디스 서버를 설치하거나 실행하지 않고, 테스트 시간에만 임시로 레디스 서버를 사용하고자 하는 경우에는 임베디드 레디스 서버를 사용할 수 있다. 임베디드 레디스는 JVM 상에서 실행되는 레디스 서버로, 테스트를 위해 자주 사용된다. spring-redis-test 라이브러리를 사용하면 테스트 시간에 임베디드 레디스를 손쉽게 사용할 수 있다.

의존성 추가

// gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation ('it.ozimov:embedded-redis:0.7.3') { exclude group: "org.slf4j", module: "slf4j-simple" } // 임베비드 레디스 추가

SLF4J 가 여러번 바인딩 되는 문제를 해결하기 위해 설정

RedisTestConfiguration

@Slf4j
@Profile("test")
@Configuration
public class TestRedisConfig {

    @Value("${spring.cache.redis.port}")
    private int redisPort;

    private RedisServer redisServer;

    @PostConstruct
    public void startRedis() throws IOException {
        int port = isRedisRunning() ? findAvailablePort() : redisPort;
        redisServer = RedisServer.builder()
                .port(port)
                .setting("maxmemory 128M")
                .build();
        try {
            redisServer.start();
        } catch (Exception e) {
            log.error("Redis Server Can't Start", e);
        }
    }

    @PreDestroy
    public void stopRedis() {
        redisServer.stop();
    }

    public int findAvailablePort() throws IOException {
        for (int port = 10000; port <= 65535; port++) {
            Process process = executeGrepProcessCommand(port);
            if (!isRunning(process)) {
                return port;
            }
        }

        throw new RuntimeException("NOT_FOUND_AVAILABLE_PORT");
    }

    /**
     * Embedded Redis가 현재 실행중인지 확인
     */
    private boolean isRedisRunning() throws IOException {
        return isRunning(executeGrepProcessCommand(redisPort));
    }

    /**
     * 해당 port를 사용중인 프로세스를 확인하는 sh 실행
     */
    private Process executeGrepProcessCommand(int redisPort) throws IOException {
        String command = String.format("netstat -nat | grep LISTEN|grep %d", redisPort);

        // linux 용
        // String[] shell = {"/bin/sh", "-c", command};
        // window 용
        String[] shell = {"cmd.exe", "/c", command};

        return Runtime.getRuntime().exec(shell);
    }

    /**
     * 해당 Process가 현재 실행중인지 확인
     */
    private boolean isRunning(Process process) {
        String line;
        StringBuilder pidInfo = new StringBuilder();

        try (BufferedReader input = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
            while ((line = input.readLine()) != null) {
                pidInfo.append(line);
            }
        } catch (Exception e) {
            throw new RuntimeException("ERROR_EXECUTING_EMBEDDED_REDIS");
        }
        return StringUtils.hasText(pidInfo.toString());
    }
}

메모리 설정과 현재 사용할 수 있는 포트를 확인해서 그 포트에 임베디드 레디스를 실행하는 설정이 있다.

3. 전체 시스템 통합 테스트

profile
가오리의 개발 이야기

0개의 댓글