Post

Mockito를 사용한 스프링 MockMvc 테스트

MockMvc란?

MockMvc는 Spring MVC 컨트롤러 테스트를 도와주는 강력한 도구이다. 내부적으로 HTTP 요청을 모의(mock)하여 컨트롤러, 필터, 인터셉터 등을 통과하게 하고 실제 응답을 생성한다. 이를 통해 전체 애플리케이션을 실행하지 않고도 웹 계층 테스트가 가능해진다.
MockMvc는 컨트롤러의 요청 파라미터들이 올바르게 매핑되고, 요청과 응답이 예상대로 처리되는지 확인하는 용도로 주로 사용된다.

MockMvc를 활용한 테스트의 특징은 다음과 같다.

  • 빠른 테스트: 실제 서버를 띄우지 않기 때문에 빠른 속도로 테스트를 수행할 수 있다.
  • 독립적인 테스트: 외부 종속성을 사용할 필요 없이 단위 테스트에 가깝게 컨트롤러를 테스트할 수 있다.

스프링부트 환경에서 아래 스프링부트 테스트 의존성을 주입하면 MockMvc를 사용할 수 있다.

1
testImplementation 'org.springframework.boot:spring-boot-starter-test'

컨트롤러 테스트

간단한 User 컨트롤러를 작성하여 테스트해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@RestController
@RequestMapping("/api/user")
public class UserController {

  private final UserService userService;

  public UserController(UserService userService) {
    this.userService = userService;
  }

  @GetMapping("/{id}")
  public ResponseEntity<User> getUserById(@PathVariable Long id) {
    User user = userService.getUserById(id);
    return ResponseEntity.ok(user);
  }

  @PostMapping
  public ResponseEntity<User> createUser(@RequestBody User user) {
    User createdUser = userService.createUser(user);
    return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
  }

  @PatchMapping("/{id}")
  public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody Map<String, Object> updates) {
    User updatedUser = userService.updateUser(id, updates);
    return ResponseEntity.ok(updatedUser);
  }

  @DeleteMapping("/{id}")
  public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
    userService.deleteUser(id);
    return ResponseEntity.noContent().build();
  }
}

MockMvc를 사용하면 HTTP 요청과 응답을 모킹(mock)하여 실제 서블릿 컨테이너 없이도 웹 계층을 테스트할 수 있게 된다.
아래에서는 Mockito 프레임워크를 활용하여 mockMvc 테스트를 위한 가짜 객체를 생성하여 테스트한다.

Mockito : 실제 객체 대신 가짜 객체를 생성하여 테스트할 수 있게 도와주는 오픈 소스 모킹 프레임워크. 객체 간의 의존성을 모킹하여 특정 메서드 호출에 대한 동작을 미리 정의하고, 테스트 코드 내에서 그 동작이 잘 실행되는지 확인할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import ...

@ExtendWith(MockitoExtension.class)
public class UserControllerTest {

  private MockMvc mockMvc;

  @Mock
  private UserService userService; // UserService를 모킹

  @InjectMocks
  private UserController userController; // 모킹된 userService가 주입된 UserController

  private final ObjectMapper objectMapper = new ObjectMapper();

  // 컨트롤러 테스트 설정
  public UserControllerTest() {
    this.mockMvc = MockMvcBuilders.standaloneSetup(userController).build();
  }

  // GET 요청 테스트
  @Test
  void shouldReturnUserById() throws Exception {
    User mockUser = new User(1L, "John", "Doe");

    when(userService.getUserById(1L)).thenReturn(mockUser);

    mockMvc.perform(get("/api/user/1"))
      .andExpect(status().isOk())
      .andExpect(jsonPath("$.id").value(1L))
      .andExpect(jsonPath("$.firstName").value("John"))
      .andExpect(jsonPath("$.lastName").value("Doe"));

    verify(userService, times(1)).getUserById(1L);
  }

  // POST 요청 테스트
  @Test
  void shouldCreateUser() throws Exception {
    User newUser = new User(null, "Jane", "Doe");
    User savedUser = new User(2L, "Jane", "Doe");

    when(userService.createUser(any(User.class))).thenReturn(savedUser);

    String userJson = objectMapper.writeValueAsString(newUser);

    mockMvc.perform(post("/api/user")
        .contentType(MediaType.APPLICATION_JSON)
        .content(userJson))
      .andExpect(status().isCreated())
      .andExpect(jsonPath("$.id").value(2L))
      .andExpect(jsonPath("$.firstName").value("Jane"))
      .andExpect(jsonPath("$.lastName").value("Doe"));

    verify(userService, times(1)).createUser(any(User.class));
  }

  // PATCH 요청 테스트
  @Test
  void shouldUpdateUser() throws Exception {
    User updatedUser = new User(1L, "John", "Smith");

    when(userService.updateUser(eq(1L), anyMap())).thenReturn(updatedUser);

    Map<String, Object> updates = new HashMap<>();
    updates.put("lastName", "Smith");

    String patchJson = objectMapper.writeValueAsString(updates);

    mockMvc.perform(patch("/api/user/1")
        .contentType(MediaType.APPLICATION_JSON)
        .content(patchJson))
      .andExpect(status().isOk())
      .andExpect(jsonPath("$.id").value(1L))
      .andExpect(jsonPath("$.firstName").value("John"))
      .andExpect(jsonPath("$.lastName").value("Smith"));

    verify(userService, times(1)).updateUser(eq(1L), anyMap());
  }

  // DELETE 요청 테스트
  @Test
  void shouldDeleteUser() throws Exception {
    doNothing().when(userService).deleteUser(1L);

    mockMvc.perform(delete("/api/user/1"))
      .andExpect(status().isNoContent());

    verify(userService, times(1)).deleteUser(1L);
  }
}

위의 코드처럼 mockMvc에 가짜 객체를 미리 설정하고, 테스트코드를 통해 검증할 수 있다.

  • @ExtendWith(MockitoExtension.class): JUnit 5에서 Mockito를 확장하여 사용할 수 있도록 설정하는 어노테이션. @Mock@InjectMocks을 활용할 수 있게된다.
  • MockMvcBuilders.standaloneSetup(): MockMvc를 설정하는 방법 중 하나로, 컨트롤러 단위로 테스트할 때 사용한다.
  • @Mock, @InjectMocks: UserService를 모킹하고, 모킹된 UserService를 UserController에 주입해주는 어노테이션.
  • when(): Mock 객체의 메서드가 호출되었을 때 미리 응답할 return 값을 설정한다.
  • mockMvc.perform(): 컨트롤러 매핑 URL로 요청을 모킹한다.
  • andExpect(): 요청에 대한 예상 응답 및 상태를 확인한다.
  • verify(): 테스트 중에 특정 메서드가 예상대로 호출되었는지 검증한다.

이처럼 컨트롤러 인터페이스 API를 서블릿 컨테이너를 띄우지 않고 간단하게 요청 및 응답을 테스트 해볼 수 있다.

중요한건 직접 서버를 띄우거나 스프링 컨테이너를 생성하지 않고, 가짜 객체를 통해 간단하고 빠르게 테스트 해볼 수 있다는 점이다.
DB까지 접근하지 않고 예상하는 서비스 return 값을 미리 설정해두어 응답하기 때문에 속도가 빠르다.
주로 API 명세서를 빠르게 작성하기 위해 컨트롤러 단위테스트를 진행하거나, DB 접근 없이 End-to-End 테스트가 필요할 때 용이하게 사용할 수 있다.

This post is licensed under CC BY 4.0 by the author.