Presentation Layer Test - (feat. 컨트롤러 테스트!)

yeom yaloo·2024년 4월 5일
0

백엔드 관련 지식

목록 보기
6/7

제목 없는 데이터베이스

⛓ Presentation Layer?

Untitled

클라이언트와 가장 맞닿아 있는 Controller Layer 계층

Untitled

1. UI와 표현이 관련된 코드가 위치한 계층

  • 웹 클라이언트의 요청 및 응답을 처리해요.

2. 서비스 계층과 데이터 액세스 계층에서 발생하는 예외 처리를 하는 계층

  • 서비스 계층과 데이터 액세스 계층(=persistence Layer or Repository or DAO)에서 발생하는 예외를 처리하고 최종 뷰에 표현해야 할 도메인 모델을 사용해요
  • 뷰에서 입력 데이터에 대한 유효성 검증 기능을 해당 계층에서 해요.
  • 비지니스 로직과 뷰를 분리하기 위해서 컨트롤러 기능을 제공해요.
  • 스프링에서는 @Controller, @RestController 기능을 사용해서 이를 표현해요.

잠깐 지식 DTO는?

1. DTO = 도메인 모델 클래스

  • 관계형 데이터베이스의 엔티티와 비슷한 개념을 가지는 것으로 VO(Value Object) 또는 DTO(Data Transfer Object)객체에 해당해요.
  • 도메인 모델 클래스는 3개의 계층 전체에 걸쳐 사용해요.

Untitled


📫 Presentation Layer Test?

💡 우리는 이제 간단하게 해당 계층에 대한 테스트 작업을 진행할 거예요.

이때 @Controller는 뷰를 반환할 때 사용하고 @RestController는 데이터를 반환할 때 사용하는 것을 인지하고 진행할게요.

단위 테스트 전에 코드 - 이렇게 진행하면 안 돼요!

1. 컨트롤러

public class AuthController {
    @PostMapping("/save")
    public ApiResponse save(@RequestBody User user) {
        if (userService.getUser(user.getUserName()) != null) {
            return ApiResponse.fail();
        }
        userService.save(user);
        return ApiResponse.success(HttpStatus.OK.name(), null);
    }
}

2. 통합 테스트 코드 사용 시

@ExtendWith(MockitoExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
public class AuthControllerTest {

    @Mock
    private User user;

    @Autowired
    private MockMvc mockMvc;

    AutoCloseable openMocks;

    ObjectMapper objectMapper = new ObjectMapper();

    @BeforeEach
    public void setup() {
        openMocks = MockitoAnnotations.openMocks(this);
    }

    @Test
    @DisplayName("로컬 회원 가입 테스트")
    public void saveTest() throws Exception
    {
        // given
        user = User.builder()
                .userName("minchoi")
                .passwd("1234")
                .providerType(ProviderType.LOCAL)
                .roleType(RoleType.USER)
                .build();

        // when & then
        mockMvc.perform(MockMvcRequestBuilders
                        .post("/api/v1/auth/save")
                        .content(objectMapper.writeValueAsString(user))
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk());
    }
}
  • @ExtendWith(MockitoExtension.class): 이 애너테이션은 JUnit 5의 확장 모델을 사용하여 Mockito를 사용할 수 있도록 해줘요. MockitoExtension은 Mockito에서 테스트 더미를 관리하고 주입하는 데 사용돼요.
  • @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK): 이 애너테이션은 Spring Boot 애플리케이션의 전체 컨텍스트를 로드하여 통합 테스트를 실행할 때 사용해요. webEnvironment 속성은 테스트를 실행할 웹 환경을 지정하고, MOCK로 설정하면 내장 서블릿 컨테이너 대신에 Mock Servlet 환경을 사용해요.
  • @AutoConfigureMockMvc: 이 애너테이션은 MockMvc를 자동으로 구성하여 Spring MVC 컨트롤러를 테스트할 수 있도록 해요. MockMvc를 사용하여 HTTP 요청을 보내고 응답을 검증할 수 있어요. MockMvc는 내부적으로 Spring의 DispatcherServlet을 사용하여 요청을 처리해요.

@WebMvcTest 애너테이션을 이용한 단위 테스트 코드

1. 예시 컨트롤러 코드

@RestController
public class UserVehicleController {

    private final UserVehicleService userVehicleService;

    @Autowired
    public UserVehicleController(UserVehicleService userVehicleService) {
        this.userVehicleService = userVehicleService;
    }

    @GetMapping("/{username}/vehicle")
    public ResponseEntity<String> getVehicleDetails(@PathVariable String username) {
        // UserVehicleService를 사용하여 사용자의 차량 세부 정보를 가져옴
        VehicleDetails vehicleDetails = userVehicleService.getVehicleDetails(username);
        
        if (vehicleDetails != null) {
            // 차량 세부 정보를 문자열로 반환
            String vehicleInfo = vehicleDetails.getMake() + " " + vehicleDetails.getModel();
            return ResponseEntity.ok(vehicleInfo);
        } else {
            // 사용자가 차량을 소유하고 있지 않을 경우 Not Found 반환
            return ResponseEntity.notFound().build();
        }
    }
}

2. @WebMvcTest를 이용한 단위 테스트

@WebMvcTest(UserVehicleController.class)
public class UserVehicleControllerTests {
    @Autowired
    private MockMvc mvc;

    @MockBean
    private UserVehicleService userVehicleService;

    @Test
    public void testExample() throws Exception {
        given(this.userVehicleService.getVehicleDetails("sboot"))
                .willReturn(new VehicleDetails("Honda", "Civic"));
        
        this.mvc.perform(get("/sboot/vehicle").accept(MediaType.TEXT_PLAIN))
                .andExpect(status().isOk()).andExpect(content().string("Honda Civic"));
    }
}
  • @WebMvcTest 역시 슬라이스 테스트를 지원하는 애너테이션 중 하나예요.
  • 이를 사용하면 간단하게 우리가 원하는 단위 테스트가 가능해요.
  • 이를 사용하지 않으면 @SpringBootTest 통합 테스트를 통한 진행이 이뤄진답니다.

3. 패키지 구조

자바 코드 작성 패키지

Untitled

  • 여기까지가 일반적으로 작업을 진행하는 자바 코드 진영이에요.

테스트 코드 작성 패키지

Untitled


  • EntityManager
    • Entity를 관리하는 역할
    • JPA는 기본적으로 한 요청 당, 하나의 EntityManager를 사용함.
    • 각 EntityManager들은 정해진 영속성 컨텍스트를 참조함.
      • 그래서 가장 먼저 Entity가 ‘영속화’ 되어 있어야 함.
    • 요청이 생기면 엔티티 매니저가 만들어지고, 엔티티들을 영속성 컨텍스트에 생성하면서 엔티티를 영속화 한다. 그리고 엔티티 매니저가 영속성 컨텍스트를 기반으로 요청을 처리한다.

🎪 테스트 구조에 관해서…

테스트 권장 구조


@DataJpaTest
public class UserRepositoryTests {

    //생략

    @Test
    public void testSaveUser() {
		    //given
        User user = DummyUser.createDummyUser("testuser", "test@example.com");
        
        //when
        userRepository.save(user);
				
				
				//then
        assertNotNull(user.getId()); // ID가 생성되었는지 확인
    }

}
  • 기본적으로 테스트는 given , when , then 으로 이루어져 있어요.
  • given
    • 테스트의 시작 지점이에요.
    • 테스트 수행에 필요한 초기 상태나 조건을 설정해요.
    • 주어진 상황에서는 테스트 수행을 위해 필요 데이터를 설정하거나 객체를 초기화 하는 작업을 이곳에서 진행해요.
  • when
    • 테스트 하려는 동작을 수행하는 단계예요.
    • 특정 행동 또는 메서드를 호출해요.
    • 보통은 주어진 상황에 대해서 특정한 동작 수행이 있을 때 결과를 검증해요.
  • 절대적으로 해당 구조로 진행되는 것은 아니예요. 그러나! 이런 구조를 이용해서 테스트를 진행하는 것이 더 테스트를 이해하기 쉽고 의도를 명확하게 전달할 수 있게 해주기 때문에 권장하고 있어요.

🔪 슬라이스 테스트

슬라이스 테스트?

1. 정의

  • 말 그대로 레이어별로 잘라서, 레이어를 하나의 단위로 보는 단위 테스트를 한다는 것이 슬라이스 테스트예요.

2. Spring.io

Test slicing is about segmenting the ApplicationContext that is created for your test. Typically, if you want to test a controller using MockMvc, surely you don’t want to bother with the data layer. Instead you’d probably want to mock the service that your controller uses and validate that all the web-related interaction works as expected.

테스트 슬라이싱(Test slicing)은 테스트할 때 생성되는 ApplicationContext를 세분화하는 것입니다. 일반적으로 MockMvc를 사용하여 컨트롤러를 테스트하려는 경우 데이터 레이어에 신경 쓸 필요가 없습니다. 대신에 컨트롤러가 사용하는 서비스를 모킹하고, 모든 웹 관련 상호 작용이 예상대로 작동하는지 확인할 것입니다.

즉, 테스트 슬라이싱은 테스트를 세분화하여 필요한 부분만 포함하도록 합니다. 데이터 레이어, 서비스 레이어, 웹 레이어 등과 같은 다양한 레이어를 포함할 수 있습니다. 예를 들어, 컨트롤러를 테스트할 때 실제 데이터베이스에 액세스할 필요 없이 서비스 레이어만 모킹하여 웹 상호 작용을 테스트할 수 있습니다.

이를 통해 테스트를 더욱 효율적으로 작성하고, 필요한 부분만 집중적으로 테스트할 수 있습니다. 결과적으로 테스트 코드의 실행 속도가 향상되고 테스트 관리가 더욱 용이해집니다.

3. 왜 슬라이스 테스트를 할까?

@SpringBootTest 어노테이션을 이용하면 모든 테스트를 할 수 있는데 왜 레이어별로 잘라서 테스트할까?

@SpringBootTest 어노테이션의 단점은 아래와 같아요.

  • 실제 구동되는 애플리케이션의 설정, 모든 Bean을 로드하기 때문에 시간이 오래걸리고 무거워요.
  • 테스트 단위가 크기 때문에 디버깅이 어려운 편이에요.
  • 결과적으로 웹을 실행시키지 않고 테스트 코드를 통해 빠른 피드백을 받을 수 있다는 장점이 희석돼요.

따라서 @SpringBootTest 어노테이션은 어플리케이션 컨텍스트 전체를 사용하는 통합 테스트에 사용돼야 합니다.

슬라이스 테스트에 사용할 수 있는 어노테이션 종류

스프링 부트는 자동 설정의 일부만을 테스트 슬라이스로 가져와서 테스트에 활용할 수 있도록 다양한 테스트 어노테이션을 제공해줘요.

아래는 대표적인 슬라이스 테스트 어노테이션입니다.

  • @WebMvcTest
  • @WebFluxTest
  • @DataJpaTest
  • @JsonTest
  • @RestClientTest

🎶 @WebMvcTest

@WebMvcTest를 사용했을 때 등록되는 Bean들

1. 등록되는 빈 종류

  • @Controller
  • @ControllerAdvice
  • @JsonComponent
  • Converter
  • GenericConverter
  • Filter
  • WebMvcConfigurer
  • HandlerMethodArgumentResolver

2. 이밖의 Bean들은?

  • 이 밖에 테스트를 하는 데 필요하지 않은 컴포넌트들(ex. @Service@Repository)은 Bean으로 등록하지 않아요.

@MockBean - Mock Bean?

1. MockBean 사용 이유

  • @WebMvcTest를 사용함으로써 @Service Bean이 등록되지 않았기 때문에, Controller의 Service에 대한 의존성이 깨져서 테스트를 진행할 수 없기 때문에 MockBean을 사용해서 서비스 빈을 등록해서 사용할 수 있게 해야 해

2. Mock 객체 Bean?

  • Mock Bean은 Bean의 껍데기 Mock(껍데기!!) 객체만 가져오고 내부 구현은 사용자에게 위임한 형태예요.
  • 즉, 해당 Mock Bean 껍데기 빈으로 주입한 객체의 껍데기만 가져오고 작성되어 있는 메서드에 어떤 값이 필요한 경우 어떤 값이 반환 되어야 하는 등의 내용 모두를 개발자 필요에 의해서 조작이 가능한 형태예요.

Untitled

  • given() 으로 행동을 예측한 후
  • willReturn() 으로 해당 행동 예측 후의 반환 값을 설정
  • 주로 컨트롤러를 호출할 때 서비스 레이어의 메서드가 호출되기 때문에 이 부분이 정확하게 호출되는지 확인하기 위해서 서비스 레이어 메서드 호출을 했어요.
  • this.userVehicleService.getVehicleDetails("sboot")를 호출하여 서비스 레이어의 getVehicleDetails() 메서드가 정상적으로 호출되고, 반환되는 결과가 컨트롤러에 의해 올바르게 처리되는지를 검증하고 있어요.

3. Mock Bean 사용 이유가 그래서 뭘까아?

  • 어떤 로직에 대해 Bean이 예상대로 동작하도록 하고 싶을 때, Mock Bean을 사용하는 것이에요.
    • 예를 들면 아임포트 등 외부의 결제 대행 서비스를 사용하여 결제 기능을 개발한다고 가정하해봅시다.
    • 결제 대행 서비스에서는 테스트 코드에서 보낸 요청을 올바르지 않은 요청으로 간주할 것이에요.
    • 이때 이 테스트 코드 요청을 올바른 요청으로 간주했을 때의 로직을 테스트하고 싶은 경우, Mock Bean 을 사용해요.

4. Mock 늘 좋을까? - 사용 시 주의점과 적절한 사용 방법

슬라이스 테스트 시, 하위 레이어는 Mock 기반으로 만들기 때문에 주의할 점들이 있다.

  • 실제 환경에서는 제대로 동작하지 않을 수 있다.
  • Mock을 사용한다면 내부 구현도 알아야 하고, 테스트 코드를 작성하며 테스트의 성공을 의도할 수 있기 때문에 완벽한 테스트라 보기 힘들다.
  • 내부 구현이 변경 됐을 때 테스트가 실패하지 않고 통과하게 되면서 혼란이 발생할 수도 있다.

그렇다면 언제 Mock 기반의 테스트를 사용해야 할까?

  • 랜덤의 성격을 띄고 있는 함수
  • LocalDate.now() 처럼 계속 흘러가는 시간의 순간
  • 외부에 존재하여 내가 제어할 수 없는 외부 서버, 외부 저장소 등 제어할 수 없는 영역
  • 대규모 어플리케이션(깊은 depth의 레이어)에서 하위 계층들의 테스트 셋업이 방대할 경우

결론

  • 모든 Bean들을 사용해야 하는 통합 테스트가 아니라면, 슬라이스 테스트를 시도해보자.
  • Mock 기반의 슬라이스 테스트라면 주의하여 엄격하게 사용해야 한다.

➕ 추가

회원 업데이트 기능 관련 테스트

1. 서비스 레이어 updateMember 회원 수정 메서드

MemberUpdateDTO updateMember(Long memberId, MemberUpdateDTO memberUpdateDTO);

2. 회원 수정 API

    //업데이트
    @PostMapping(value = "/update/{id}")
    public MemberUpdateDTO updateMember(@PathVariable("id") Long id, MemberUpdateDTO memberUpdateDTO){
        return memberService.updateMember(id, memberUpdateDTO);
    }

3. 회원 수정 성공 테스트

@DisplayName("회원수정 - 성공")
    @Test
    public void updateSuc() throws Exception {

        Long updateMemberId = 1L;

        Member dummyMember = Member.builder()
                .name("name1")
                .loginId("memberId1")
                .password("password1")
                .phoneNumber("01033333333")
                .build();

        MemberUpdateDTO memberUpdateDTO = MemberUpdateDTO.builder()
                .name("update!!!")
                .loginId("update!!!")
                .password("update!!!")
                .phoneNumber("update!!!")
                .build();

        given(this.memberService.updateMember(anyLong(), any(MemberUpdateDTO.class)))
            .willReturn(memberUpdateDTO);

        ObjectMapper mapper = new ObjectMapper();

        this.mvc.perform(post("/members/update/{id}", updateMemberId)
                        .content(mapper.writeValueAsString(memberUpdateDTO))
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name").value("update!!!"))
                .andExpect(jsonPath("$.loginId").value("update!!!"))
                .andExpect(jsonPath("$.password").value("update!!!"))
                .andExpect(jsonPath("$.phoneNumber").value("update!!!"))
            .andDo(print());

    }
  • perform(post("/members/update/{id}", updateMemberId) : POST 요청으로 /member/update/{id} PathVariable 사용할 땐 위와 같이 진행해요.
  • content(mapper.writeValueAsString(memberUpdateDTO)) : POST 요청으로 해당 DTO가 BODY를 통해서 넘어가야 하기 때문에 이를 위해서 ObjectMapper를 사용해요.
    • JSON 객체를 넘겨줄 때 자바 객체를 JSON 객체로 직렬화해서 보내야 하기 때문에 ObjectMapper를 사용함
  • 이때 Service 메서드 관련해서 willReturn(memberUpdateDTO) 실제 반환될 타입과 endExpect()에서 기대되는 부분을 맞춰줘야 해요.
profile
즐겁고 괴로운 개발😎

0개의 댓글