java 강의도 들어야 하고 숙제도 해야 하고, 과제도 제출해야 하고 시간 가는 줄 모르고 공부했다. 시작한지 얼마 되지 않아 java언어도 아직 미숙한데 진도는 빠르고 숙제 과제 해야 할일이 많으니 전부 이해하지 못하더라도 진도를 강행 할 수 밖에 없었다... 4주차 강의 예외, 제네릭이 감은 잡히는데 코드로 작성을 할 때 어떤 형식으로 써야하는 지를 잘 몰라서 찾아보고 어떤 형태로 쓰이는지 예문을 통해 조금 알게 되었다.
스프링 입문 강의가 시작 되었을때 시작 하기 전에 CRUD 기능을 하는 API 코드에 대한 테스트 코드작성 숙제를 해야해서 java강의 복습을 좀 더 하지 못하고 스프링 입문 강의를 미리 듣고 코드 작성을 했다. 처음에는 아무 것도 몰라 어떻게 해야 할지 막막했다. 강의를 들어도 직접 작성을 해보려 하니 어떻게 작성 해야 할지 몰랐다. 숙제 검사는 점점 다가오고 해서 GPT친구한테 코드작성을 맡기고 코드에 대한 이해를 하려고 남은 시간을 쏟아부었다.
// Coverage
// 코드 커버리지란, 테스트 코드가 프로덕션 코드를 얼마나 실행했는지를 백분율로 나타내는 지표이다.
// 즉, 테스트 코드가 실제로 프로덕션 코드를 얼마나 몇 퍼센트 검증하고 있는지를 나타낸다.
// 코드 커버리지를 통해 현재 작성된 테스트 코드의 수가 충분한것인지 논의할 수 있다.
//
// 예를 들어 코드 커버리지 측정 기준이 실행된 함수 개수라고 하자. 프로덕션 코드에 총 100개의 함수가 있고,
// 테스트 코드가 그 중 50개를 실행했다면 코드 커버리지는 50%가 된다.
// class 커버리지
// 모든 메서드가 테스드 되었고, 라인커버리지와 브랜치커버리지 가 80% 이상이면 성공으로 간주
// 함수(Function) 커버리지 == Method 커버리지
// 어떤 함수가 최소 1번 이상 호출되었는지를 기준으로 커버리지를 계산한다. 함수 내부의 모든 코드가 실행되었는지는 판단 기준에서 제외된다.
// 구문(Statement) 커버리지 == Line 커버리지
// 프로덕션 코드의 전체 구문 중 몇 줄의 구문이 실행되었는지를 기준으로 판단한다.
// 결정(Decision) 커버리지 == Branch 커버리지
// 프로덕션 코드에 조건문이 있는 경우, 조건문의 전체 조건식이 True인 케이스, False인 케이스 2가지가
// 최소한 한번 실행되면 충족된다. 개별 조건식의 개수와는 상관없이 최소 2개의 테스트 케이스로 충족이 가능하다.
// 테스트 코드 작성 방법
// 테스트 할 데이터 생성
// given -> 테스트에 필요한 입력값 , 리턴값 등을 작성
// when -> 테스트할 로직 작성
// then -> 검증
// BoardController를 테스트하는 데 필요한 구성 요소만 로드합니다.
// 스프링 MVC 컨트롤러 테스트에 필요한 최소한의 빈만 로드하므로, 빠르고 효율적인 테스트가 가능함
@WebMvcTest(BoardController.class)
public class BoardControllerTest {
// @Autowired 어노테이션은 스프링 컨테이너에 의해 관리되는 빈 중에서 타입에 맞는 빈을 찾아 의존성을 주입해주는 역할을 함
// 여기서는 @WebMvcTest 어노테이션에 의해 설정된 MockMvc 객체를 주입받음
@Autowired
// MockMvc는 스프링 MVC를 테스트하기 위한 주요 클래스,. 이를 통해 HTTP 요청을 시뮬레이션하고 응답을 검증할 수 있음
private MockMvc mockMvc;
// 실제 서버를 실행하지 않고도 spring MVC의 동작을 테스트 할숭있음
// Spring Boot에서 제공하는 어노테이션으로, 스프링 애플리케이션 컨텍스트에 모킹된(mock) 빈을 등록함, (spring Container 의 구현체중 하나 Context)
@MockBean
// BoardService를 모킹하여 실제 구현 대신 테스트에서 사용됨
private BoardService boardService;
// 각각의 테스트가 실행되기 전에 실행되는 어노테이션
@BeforeEach
void setUp() {
// @Mock과 @InjectMocks 어노테이션이 붙은 객체들을 초기화함
MockitoAnnotations.openMocks(this);
}
// JUnit 에서 테스트 메서드를 표시하는 데 사용, JUnit 프레임워크가 해당 메서드를 테스트 메서드로 인식하고,
// 테스트 실행 시 이를 자동으로 호출하여 테스트를 수행함
//
// thenReturn 을 왜 사용해야하나
@Test
public void testCreateBoard() throws Exception {
// 테스트할 데이터를 가진 객체를 생성
BoardEntity board = new BoardEntity("Title", "Information");
// boardService.saveBoard() 호출 시 모킹된 객체를 반환하도록 설정
// 메서드에 어떤 BoardEntity가 전달되더라도 board 객체를 리턴
when(boardService.saveBoard(any(BoardEntity.class))).thenReturn(board);
// mockMvc 메서드 체이닝을 위해 설계된 API,
// POST 요청을 /board/ 엔드포인트로 보내고 JSON 형식의 본문을 전달함
// perform() 실제 HTTP 요청을 시뮬레이션 하는 역할, 실제로 서버를 실행하지 않고도 HTTP 요청을 보내고 응답을 검증 할 수 있음
// mockMvc.perform() 은 POST, GET 등의 HTTP 요청을 보낼 수 있는 메서드를 인자로 받음
mockMvc.perform(post("/board/")
// 요청의 Content-Type을 JSON으로 설정함.
.contentType(MediaType.APPLICATION_JSON)
// content 메서드는 요청 본문에 JSON 데이터를 설정함
.content("{\"title\": \"Title\", \"information\": \"Information\"}"))
// HTTP 상태 코드가 201 (Created)인지 검증함 (201 : 새로운 리소스 생성이 성공적으로 완료되었을때 나타나는 상태코드) (200 : 요청이 성공적, 조회경우 리소스생성 X)
.andExpect(status().isCreated())
// JSON 응답의 title 필드 값이 "Title"인지 검증함
.andExpect(jsonPath("$.title").value("Title"))
// JSON 응답의 information 필드 값이 "Information"인지 검증함
.andExpect(jsonPath("$.information").value("Information"));
}
@Test
public void testGetAllBoards() throws Exception {
// 테스트할 데이터를 생성
BoardEntity board1 = new BoardEntity("Title1", "Information1");
BoardEntity board2 = new BoardEntity("Title2", "Information2");
// boardService.getAllBoards() 호출 시 모킹된 데이터를 반환하도록 설정함
when(boardService.getAllBoards()).thenReturn(Arrays.asList(board1, board2));
// GET 요청을 /board/ 엔드포인트로 보냄
// MockMvc의 perform 메서드를 통해 ResultActions 객체가 생성되고 ResultActions 객체를 반환함,
// HTTP 요청의 결과를 검증하는 여러 메서드를 제공함
mockMvc.perform(get("/board/"))
// HTTP 상태 코드가 200 (OK)인지 검증
.andExpect(status().isOk())
// jsonPath("$.title").value("Title1")와 같은 검증은 ResultActions 객체를 통해 수행됨
// jsonPath 메서드는 JSON 응답 본문에서 특정 경로의 값을 추출하고 이를 검증하는 데 사용됨
// JSON 응답 배열의 첫 번째 요소의 title 필드 값이 "Title1"인지 검증
.andExpect(jsonPath("$[0].title").value("Title1"))
// JSON 응답 배열의 두 번째 요소의 title 필드 값이 "Title2"인지 검증
.andExpect(jsonPath("$[1].title").value("Title2"));
}
@Test
public void testDeleteBoard() throws Exception {
// boardService.existsById() 호출 시 true를 반환하도록 설정함
when(boardService.existsById(anyLong())).thenReturn(true);
// DELETE 요청을 /board/{id} 엔드포인트로 보냄
mockMvc.perform(delete("/board/{id}", 1L))
// HTTP 상태 코드가 200 OK인지 검증
.andExpect(status().isOk());
}
@Test
public void testUpdateBoard() throws Exception {
// 업데이트할 데이터를 생성함
BoardEntity board = new BoardEntity("Updated Title", "Updated Information");
// boardService.existsById() 호출 시 true를 반환하도록 설정
when(boardService.existsById(anyLong())).thenReturn(true);
// boardService.updateBoard() 호출 시 모킹된 객체를 반환하도록 설정
when(boardService.updateBoard(anyLong(), any(BoardEntity.class))).thenReturn(board);
// PUT 요청을 /board/{id} 엔드포인트로 보내고 JSON 형식의 본문을 전달
mockMvc.perform(put("/board/{id}", 1L)
// 요청의 Content-Type을 JSON으로 설정
.contentType(MediaType.APPLICATION_JSON)
// // 요청 본문에 JSON 데이터를 설정
.content("{\"title\": \"Updated Title\", \"information\": \"Updated Information\"}"))
// HTTP 상태 코드가 200 OK인지 검증
.andExpect(status().isOk())
// JSON 응답의 title 필드 값이 "Updated Title"인지 검증
.andExpect(jsonPath("$.title").value("Updated Title"))
// JSON 응답의 information 필드 값이 "Updated Information"인지 검증
.andExpect(jsonPath("$.information").value("Updated Information"));
}
}
public class BoardServiceTest {
// 둘 다 자바 단위 테스트를 위한 라이브러리
// JUnit : 단위 테스트 프레임워크 : 테스트 케이스 작성 및 실행, 테스트 결과 점증, 테스트 수명 주기 관리(setup, teardown 등), 다양한 어노테이션 제공(@Test, @BeforeEach, @AfterEach 등)
// Mockito : Mock 객체를 생성하고 관리하는데 사용되는 테스트 프레임워크 : mock 객체 생성, mock 객체의 동작 정의, 목 객체의 동작 검증, 의존성 주입과 관련된 테스트 용이성 제공
// boardRepository 클래스의 mock(모조) 인스턴스를 생성하고 주입하는 데 사용됨
// 실제 boardRepository 인스턴스 대신 mock 객체가 사용되어 실제 데이터베이스와 상호작용 하지 않고도 boardRepository 동작을 모방함
@Mock
private BoardRepository boardRepository;
// mock 객체들을 주입할 클래스를 지정하는데 사용되며 모조가 아닌 실제 객체가 생성.
// boardService 에 boardRepository 객체를 (의존성을) 주입하기 위해 사용
@InjectMocks
private BoardService boardService;
// 각각의 테스트가 실행되기 전에 실행되는 어노테이션
@BeforeEach
// @Mock, @InjectMocks 를 초기화 하는 메서드
public void setUp() {
// @Mock, @InjectMocks 를 사용할때
// this : 이 테스트 클래스에 있는 @Mock, @InjectMocks 붙어있는 인스턴스의 필드를 가리킴
MockitoAnnotations.openMocks(this);
// @Mock, @InjectMocks 를 사용하지 않을 때 직접 생성 하는 방법
// boardRepository = mock(BoardRepository.class); // 직접 mock 객체 생성
// boardService = new BoardService(boardRepository); // boardService 에 boardRepository 를 주입(의존성을)
}
// JUnit 에서 테스트 메서드를 표시하는 데 사용, JUnit 프레임워크가 해당 메서드를 테스트 메서드로 인식하고,
// 테스트 실행 시 이를 자동으로 호출하여 테스트를 수행함
@Test
public void testSaveBoard() {
// 테스트할때 저장 할 데이터를 가지는 객체 생성.
BoardEntity boardEntity = new BoardEntity("Title", "Information");
// 저장 메서드가 호출될때 모킹된 객체를 반환값으로 설정
// when 은 mockito 메서드
when(boardRepository.save(boardEntity)).thenReturn(boardEntity);
// 실행 단계 : 서비스의 saveBoard 메서드를 호출하여 boardEntity를 저장
BoardEntity savedBoard = boardService.saveBoard(boardEntity);
// 검증 단계 : 테스트 데이터를 나타내는 객체과, 실제 데이터 객체
assertEquals(boardEntity, savedBoard);
// 검증 단계 : svae 메서드가 한번 호출되었는지 확인
// Mockito의 verify 메서드를 사용하여 boardRepository (모조객체의) save메서드가 한번 호출되었는지 검증
// 데이터 저장 메서드가 한번 호출 되어야하는데 두번 호출되면 중복 저장되거나 에상치 못한 동작이 발생할 수 있음
// 성능 및 자원 효율성이 떨어짐
verify(boardRepository, times(1)).save(boardEntity);
}
@Test
public void testExistsById() {
// 테스트 데이터 생성
Long id = 1L;
// boardRepository.existsById(id)가 호출되면 true를 반환하도록 모킹함
when(boardRepository.existsById(id)).thenReturn(true);
// boardService.existsById(id)를 호출하여 실제 서비스 메서드가 반환하는 값을 저장함
boolean exists = boardService.existsById(id);
// 반환된 값이 true인지 확인함
assertTrue(exists);
// Mockito의 verify 메서드를 사용하여 boardRepository (모조객체의) existsById메서드가 한번 호출되었는지 검증
verify(boardRepository, times(1)).existsById(id);
}
@Test
public void testDeleteById() {
// 테스트 데이터 생성
Long id = 1L;
// Mockito의 doNothing 메서드를 이용해 boardRepository.deleteById(id) 호출 시 아무 동작도 하지 않도록 설정
// 테스트의 목적이 데이터베이스 상호작용이 아닌, 메서드 호출의 논리적 흐름을 검증하는 데 중점을 둔다
doNothing().when(boardRepository).deleteById(id);
// 실제로 데이터베이스에 접근하거나 데이터를 삭제하지 않고도 deleteByid메서드가 잘 호출되는지 여부를 확인하기 위해.
// boardService.deleteById(id) 메서드를 호출하여 id로 지정된 데이터를 삭제
boardService.deleteById(id);
// boardRepository.deleteById(id) 메서드가 정확히 한 번 호출되었는지 검증
verify(boardRepository, times(1)).deleteById(id);
}
@Test
public void testGetAllBoards() {
// 테스트 데이터 생성
BoardEntity board1 = new BoardEntity("Title1", "Information1");
BoardEntity board2 = new BoardEntity("Title2", "Information2"); // 고정크기, 요소 추가 제거X != 동적크기, 요소추가 제거 가능
// java.util.Arrays$ArrayList != java.util.ArrayList
// 인자를 포함하는 고정 크기 리스트를 생성해 반환해주는 메서드. 일반적인 ArrayList와는 다름.
List<BoardEntity> boards = Arrays.asList(board1, board2);
// findAll 메서드가 호출되면 boards 리스트를 반환하도록 설정
when(boardRepository.findAll()).thenReturn(boards);
// 서비스의 getAllBoards 메서드를 호출하여 결과를 저장
List<BoardEntity> result = boardService.getAllBoards();
// 결과 검증: 반환된 리스트의 크기가 2인지 확인
assertEquals(2, result.size());
// 결과 검증: 첫 번째 요소가 board1인지 확인
assertEquals(board1, result.get(0));
// 결과 검증: 두 번째 요소가 board2인지 확인
assertEquals(board2, result.get(1));
// findAll 메서드가 정확히 한 번 호출되었는지 검증
verify(boardRepository, times(1)).findAll();
}
@Test
public void testUpdateBoard() {
Long id = 1L;
// 기존 보드 엔티티 생성 및 ID 설정 (업데이트 되기전)
BoardEntity existingBoard = new BoardEntity("Old Title", "Old Information");
// 테스트를 위해 ID 를 설정 하는 메서드 호출
setIdForTest(existingBoard, id);
// 업데이트할 보드 엔티티 생성
BoardEntity updatedBoard = new BoardEntity("New Title", "New Information");
// findById 메서드가 호출되면 existingBoard를 포함하는 Optional을 반환하도록 설정 (findById메서드는 해당 변수의 데이터가 있는지 없는지 확인하기 때문에 없을경우를 생각해서)
// Optional 클래스는 값을 포함할수도 null을 가질수도 있는 컨테이너 객체, 주로 NPR(nullPinterException)을 방지하기 위해 사용
when(boardRepository.findById(id)).thenReturn(Optional.of(existingBoard));
// save 메서드가 호출되면 existingBoard를 반환하도록 설정
when(boardRepository.save(existingBoard)).thenReturn(existingBoard);
// 서비스의 updateBoard 메서드를 호출하여 보드를 업데이트
BoardEntity result = boardService.updateBoard(id, updatedBoard);
// 결과 검증: 업데이트된 보드의 타이틀과 정보를 확인
assertEquals("New Title", result.getTitle());
assertEquals("New Information", result.getInformation());
// findById 메서드가 정확히 한 번 호출되었는지 검증
verify(boardRepository, times(1)).findById(id);
// save 메서드가 정확히 한 번 호출되었는지 검증
verify(boardRepository, times(1)).save(existingBoard);
}
@Test
// 존재하지 않는 BoardEntity를 업데이트하려고 시도할 때의 동작을 테스트함
public void testUpdateBoard_NotFound() {
// 테스트 데이터 생성
Long id = 1L;
// 업데이트할 데이터를 포함하는 BoardEntity 객체
BoardEntity updatedBoard = new BoardEntity("New Title", "New Information");
// 빈 Optional 객체를 반환하도록 설정
when(boardRepository.findById(id)).thenReturn(Optional.empty());
// 메서드 호출시 잘못된 인수를 전달했을때 발생.
// IllegalArgumentException 예외가 발생하는지 확인후 발생한다면 exception 변수에 저장됨
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
// 이 메서드가 호출될 때
boardService.updateBoard(id, updatedBoard);
});
// 예상되는 예외 메세지가 밸상한 예외 메세지가 일치하는지 검증함
assertEquals("선택한 메모는 존재하지 않습니다.", exception.getMessage());
// boardRepository.findById(id) 메서드가 한 번 호출되었는지 검증
verify(boardRepository, times(1)).findById(id);
}
// java.lang.reflect.Field 는 특정 필드를 나타내는 단일 객체입니다.
// Field[] fields 는 클래스에 선언된 모든 필드를 나타내는 배열입니다.
// getDeclaredField(String name) 메서드는 특정 이름을 가진 필드 하나를 반환합니다.
// getDeclaredFields() 메서드는 클래스의 모든 필드를 배열로 반환합니다.
private void setIdForTest(BoardEntity boardEntity, Long id) {
try {
// BoardEntity 클래스의 'id' 필드를 가진 객체를 가져옴
java.lang.reflect.Field field = BoardEntity.class.getDeclaredField("id");
// 'id' 필드에 접근할 수 있도록 설정함 (private 으로 선언되어있을 경우)
field.setAccessible(true);
// boardEntity 객체의 'id' 필드 값을 설정함
field.set(boardEntity, id);
// private 설정되어있는데 field.setAcceesible(true); 로 접근가능하도록 해주지 않았을경우
// 리플레션 과정에서 발생할수 있는 예외 (NoSuchFieldException, IllegalAccessException)
} catch (Exception e) {
// 예외가 발생하면 RuntimeException으로 래핑하여 던짐
// 보통 테스트 할때는 RuntimeException으로 던짐 예외처리를 강제하지 않아도 되므로 코드가 단순해짐
throw new RuntimeException(e);
}
}
IT 세계에 발들인지 두달조금 넘은 것 같은데 벌써 내가 스프링을 공부하고 있다니... 믿겨 지지도 않고 이 방대한 양의 내용을 어떻게 다 학습해야 할지 조금은 알 것 같다. 처음에는 하나부터 열까지 다 이해하고 넘어가야 한다는 생각이 심해서 진도 보다는 이해가 안가는 부분을 이해하려고 시간을 많이 투자했다. 이번에 과제 숙제 등 많은 걸 해보니 전체적인 큰그림이 어떤지 이해하면서 그 큰그림을 바탕으로 세세히 나의 지식들을 그려나가면 될 것 같다는 생각이 든다. 이번 한주는 정말 도움이 많이 되었다.
앞으로도 더 많은 걸 학습해야하지만 일주일 동안 고생했다 정재야-