Mockito๋ก ํ ์คํธ๋ฅผ ์์ฑํด๋ณด์
ํ๋ก์ ํธ ํ๊ฒฝ: Spring-boot, Junit5, Mockito
๋ชจ๋ ์ฝ๋๋ Github์ ์์ต๋๋ค :)
Mockito
Mockito๋? ํ ์คํธ ํ๋ ์์ํฌ๋ก ํ ์คํธํ ๋ ๋ชจ๋ ๋น์ ์ผ์ผํ ์ฃผ์ ์ํค์ง ์๊ณ , Mock ๊ฐ์ฒด(๊ฐ์ง ๊ฐ์ฒด)๋ฅผ ์ฃผ์ ์์ผ ํ์๋ฅผ ํ ์คํธํ ์ ์๊ณ , ์์ง ์์ฑ๋์ง ์์ ์ฝ๋๋ค(์์กด์ฑ ๊ฐ์ฒด) ๋๋ ๊ตฌํํ๊ธฐ ์ด๋ ค์ด ์์กด์ฑ ๊ฐ์ฒด๋ค์ Mockingํ์ฌ ํ ์คํธ ํ ์ ์์ต๋๋ค.
์์กด์ฑ (dependency)
spring boot๋ฅผ ์ฌ์ฉ์ค์ด๋ผ๋ฉด, spring-boot-starter-test ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ ์ด๋ฏธ JUnit5์ Mockito๊ฐ ํฌํจ๋์ด์์ผ๋ฏ๋ก ์์กด์ฑ์ ์ถ๊ฐํด์ฃผ์ง ์์๋ ๋ฉ๋๋ค.
Mock ํ ์คํธ ์์
- mock ๊ฐ์ฒด ์์ฑ ๋ฐ ์ฃผ์
- Stubbing : Mock์ ํ๋์ ๊ตฌ์ฒด์ ์ผ๋ก ๋ช ์ธํฉ๋๋ค. ์ฌ์ ์ ์ ์๋ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํํฉ๋๋ค.
- Verify : Mock์ ํ๋์ ๊ฒ์ฆํฉ๋๋ค.
ํ ์คํธ์ฉ BookService
@Service
public class BookService {
@Autowired
private BookRepository bookRepository;
public Book findAll() {
List<Book> bookList = bookRepository.findAll();
if(bookList.size() == 0) {
throw new BookEmptyException();
}
return bookList;
}
public Book findById(Long id) {
Book book = bookRepository.findById(id);
if(book == null) {
throw new BookNotFoundException();
}
return book;
}
..
}
์ ๋ง ๊ฐ๋จํ ๋ชจ๋ Book์ ์กฐํํ๊ณ , ํ๋์ book์ ๊ฐ์ ธ์ค๋ ๊ธฐ๋ฅ์ด ๊ตฌํ๋์ด์์ต๋๋ค.
Mockito๋ฅผ ์ฌ์ฉํ๊ธฐ ์ํ ์ค์
JUnit 5๋ MockitoExtension์ ์ถ๊ฐํด์ฃผ๋ฉด ๋ฉ๋๋ค.
@Extendwith(MockitoExtension.class)
public class BookServiceTest {
}
Mock ๊ฐ์ฒด ์์ฑ
Mock ๊ฐ์ฒด๋ฅผ ์์ฑํ๊ณ ํ๋์ ์ฐธ์กฐํ๊ณ ์๋ ๊ฐ์ฒด๋ค์ Mock์ผ๋ก ์ฃผ์ ์์ผ์ค์ผ ํฉ๋๋ค.
์ด๋ ์ฌ์ฉํ๋ ๊ฒ์ด ๋ฐ๋ก @Mock
, @InjectMocks
์
๋๋ค.
@Extendwith(MockitoExtension.class)
public class BookServiceTest {
// ํด๋น ๊ฐ์ฒด๋ฅผ Mock ์ฒ๋ฆฌํ๋ค. (mock(BookRepository.class)์ ๊ฐ์ ํํ์
๋๋ค.)
@Mock
private BookRepository bookRepository;
// ํด๋น ์ด๋
ธํ
์ด์
์ด ๋ถ์ ๊ฐ์ฒด์ @Mock์ด ๋ถ์ ๊ฐ์ฒด๋ค์ ์ฃผ์
์์ผ์ค๋๋ค. (ํ๋์ ์๋ ๊ฐ์ฒด๋ค๊ณผ ๋์ผํ๋ค๋ฉด)
@InjectMocks
private BookService bookService;
}
@Spy
์ @Mock
์ ์ฐจ์ด?
@Mock
๊ฐ์ฒด๋ ๊ฐ์ง ๊ฐ์ฒด๋ฅผ ์์ฑํ๊ธฐ ๋๋ฌธ์ ์ฐ๋ฆฌ๊ฐ ํ
์คํธํ๋ ค๋ ๊ธฐ๋ฅ์ ๋ํด์ Stub์ ํด์ค์ผ ํฉ๋๋ค. ๋ง์ฝ Stub์ ํด์ฃผ์ง ์์ผ๋ฉด Mockito ๊ธฐ๋ณธ์ ๋ต์ธ Answers.RETURNS_DEFAULT
์ ์ํด ๋ฆฌํดํ์
์ ๋ง๋ ๊ธฐ๋ณธ ๋ฉ์๋๊ฐ ์คํ๋ฉ๋๋ค.
Mockito - mockito-core 4.5.1 javadoc
๋ฐ๋ฉด์ @Spy
๊ฐ์ฒด๋ฅผ ์ฌ์ฉํ๋ฉด ์ค์ ๊ฐ์ฒด๋ฅผ ์ฃผ์
ํด์ค๋๋ค. ๊ทธ๋ ๊ธฐ ๋๋ฌธ์ ์ฐ๋ฆฌ๊ฐ ๋ฐ๋ก Stub์ ํด์ฃผ์ง ์์ผ๋ฉด ๊ธฐ์กด์ ์๋ ๊ธฐ๋ฅ๋ค์ ์ฌ์ฉํ๊ฒ ๋ฉ๋๋ค.
Stubbing
BookService์ findAll()
๋ฉ์๋์ ํ๋์ ๊ฒ์ฆํ๊ธฐ ์ํด Stubbingํ ๊ฒ์
๋๋ค.
findAll() ๋ฉ์๋ ๋ด๋ถ์ ๋ณด๋ฉด bookRepository.findAll()
๋ฉ์๋๋ฅผ ํธ์ถํฉ๋๋ค.
์ฐ๋ฆฌ๋ ํด๋น ๋ฉ์๋์ ํ๋์ ์ ์ํด์ค์ผ ํฉ๋๋ค.
bookRepository์ ์ด๋ค ๋ฉ์๋๋ฅผ ์ด๋ค ๋งค๊ฐ๋ณ์์ ํจ๊ป ํธ์ถ ํ๊ณ , ์ด๋ค ๊ฐ์ ๋ฆฌํดํด์ฃผ๋์ง๋ฅผ ์ ์ํด์ฃผ๋ ๊ฒ์ ๋๋ค.
findAll() stubbing
List<Book> bookList = new ArrayList<>();
bookList.add(new Book(1L, "title"));
bookList.add(new Book(2L, "title2"));
// stubbing
when(bookRepository.findAll()).thenReturn(bookList);
findById() stubbing
Book book = new Book(1L, "title");
// stubbing
when(bookRepository.findById(anyLong()).thenReturn(book);
Verify
ํ์์ ๋ํด ๊ฒ์ฆ
// ํธ์ถ๋์ง ์์
verify(bookRepository, never()).findAll();
verify(bookRepository, times(0)).findAll(); // never()๊ณผ ๋์ผ
// 1๋ฒ ํธ์ถ ๋จ
verify(bookRepository).findAll(); // times(1)๊ณผ ๋์ผ
// ์ ์ด๋ 1๋ฒ์ด์ ํธ์ถ ๋จ
verify(bookRepository, atLeastOnce()).findAll();
verify(bookRepository, atLeast(1)).findAll(); // ๋์ผ
// ์ต๋ N๋ฒ์ดํ ํธ์ถ ๋จ
verify(bookRepository, atMost(N)).findAll();
์์์ ๋ํด ๊ฒ์ฆ
์๋ฅผ ๋ค์ด, Book์ ์ถ๊ฐํ ๋ BookRepository์ ํด๋น id๋ก ์ด๋ฏธ ์ถ๊ฐ๋ ์ฑ
์ด ์๋์ง 1.์กฐํ
ํด๋ณด๊ณ ์ค๋ณต๋์ง ์์์ผ๋ฉด 2.์ถ๊ฐ
๋ฅผ ํฉ๋๋ค.
ํด๋น ์์๋ฅผ ๊ฒ์ฆํ๋ค๊ณ ํ์ ๋ ๊ฒ์ฆ ์ฝ๋๋ ์๋์ ๊ฐ์ต๋๋ค.
// ํธ์์ ๋ฉ์๋ ์ด๋ฆ์ ์กฐํ, ์ถ๊ฐ๋ก ์ค์
InOrder inOrder = inOrder(bookRepository);
inOrder.verify(bookRepository).์กฐํ(anyLong());
inOrder.verify(bookRepository).์ถ๊ฐ(any(Book.class));
Exception ๊ฒ์ฆ
bookService.findById()๊ฐ ์์ธ๋ฅผ ๋์ง๋ ค๋ฉด ์ด๋ป๊ฒ ํด์ผํ ๊น์?
bookRepository.findById()์ด null
์ ๋ฆฌํดํ๊ฒ ํ๋ฉด ๋ฉ๋๋ค.
๊ทธ๋ฆฌ๊ณ , findById
๊ฐ ์์ธ๋ฅผ ์ ๋์ง๋์ง ํ์ธํ๋ฉด ๋ฉ๋๋ค.
when(bookRepository.findById(anyLong())).thenReturn(null);
assertThrows(NotFoundException.class, () -> bookService.findById(1L));
Controller ํ ์คํธ ํ๊ธฐ
ํ ์คํธ๋ฅผ ์ํ BookController
@RestController
public class BookController {
@Autowired
private BookService bookService;
@GetMapping("/books/{book_id}")
public Book findBook(@PathVariable Long book_id) {
return bookService.findBook(book_id);
}
@GetMapping("/books")
public List<Book> findAll() {
return bookService.findBooks();
}
@PostMapping("/books")
public Long addBook(@RequestBody Book book) {
return bookService.saveBook(book);
}
@ExceptionHandler(BookDuplicateException.class)
public ResponseEntity _400() {
return ResponseEntity.badRequest().build();
}
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity _404() {
return ResponseEntity.notFound().build();
}
}
BookControllerTest
// ๋ชจ๋ ๋น๋ค์ ์ปจํ
์คํธ์ ์ฌ๋ฆฌ์ง ์๊ณ , MVCํ
์คํธ๋ฅผ ์ํ Spring MVC components (i.e. @Controller, @ControllerAdvice, @JsonComponent ๋ฑ)๋ง ์ฌ๋ผ๊ฐ๋ค.
@WebMvcTest(controllers = BookController.class) // ํด๋น ์ปจํธ๋กค๋ฌ๋ง context์ ์ฌ๋ ค์ค๋ค.
//@ExtendWith(MockitoExtension.class)
class BookControllerTest {
// WebMvcTest์๋ @AutoConfigureMockMvc๊ฐ ๋ถ์ด์๋ค.
@Autowired
private MockMvc mockMvc;
// bookService๋ฅผ mockBean์ผ๋ก ๋ฑ๋ก์ํจ๋ค.
@MockBean
private BookService bookService;
@Autowired
private ObjectMapper objectMapper;
private List<Book> bookList;
@BeforeEach
void setup() {
// .. ์๋ต
}
@Test
void book_์ ์ฅ_200() throws Exception {
// given
Book book = Book.builder()
.id(100L)
.title("title100")
.price(1000).build();
// stubbing
given(bookService.saveBook(any(Book.class))).willReturn(anyLong());
// when & then
mockMvc.perform(post("/books")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(book)))
.andDo(print())
.andExpect(status().isOk());
}
@Test
void ์ค๋ณต๋_book_์ ์ฅ_400() throws Exception{
// given
Book book = bookList.get(0);
given(bookService.saveBook(any(Book.class))).willThrow(BookDuplicateException.class);
// when & then
mockMvc.perform(post("/books")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(book)))
.andDo(print())
.andExpect(status().isBadRequest());
}
@Test
void ๋ชจ๋ _book_์กฐํ_200() throws Exception {
// given
given(bookService.findBooks()).willReturn(bookList);
// when & then
mockMvc.perform(get("/books")
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("[0].id").exists())
.andExpect(jsonPath("[1].id").exists())
.andExpect(jsonPath("[2].id").exists());
}
@Test
void ํ๋์_book_์กฐํ_200() throws Exception {
// given
Book book = bookList.get(0);
given(bookService.findBook(anyLong())).willReturn(book);
// when & then
mockMvc.perform(get("/books/{id}", anyLong())
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("id").exists())
.andExpect(jsonPath("title").exists());
}
@Test
void ์๋_book_์กฐํ_404() throws Exception {
// given
Book book = bookList.get(0);
given(bookService.findBook(anyLong())).willThrow(BookNotFoundException.class);
// when & then
mockMvc.perform(get("/books/{id}", anyLong())
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isNotFound());
}
}
@WebMvcTest
๋ฅผ ๋ถ์ฌ์ฃผ๊ฒ ๋๋ฉด, MVC ํ ์คํธ๋ฅผ ํ๊ธฐ ์ํ ์ต์์ ๋น๋ค์ ์ปจํ ์คํธ์ ๋ฑ๋กํด์ค๋๋ค. (@SpringBootTest
๋ณด๋ค ๊ฐ๋ณ์ต๋๋ค.)- ์ฌ๊ธฐ์ controllers ์ต์ ์ ์ฃผ๊ฒ๋๋ฉด, ํด๋น ์ปจํธ๋กค๋ฌ๋ง ํ ์คํธ๋ฅผ ํ ์ ์์ต๋๋ค.
- ์ด์ ์ฌ์ฉํ๊ธฐ ์ํ ๊ฐ์ฒด๋ค์ ๋น์ผ๋ก ๋ฑ๋ก์ํฌ ๋๋
@MockBean
์ ์ฌ์ฉํ์ฌ ๋ฑ๋กํด์ฃผ๋ฉด ๋ฉ๋๋ค.