Spring 심화 주체 개인 과제로 테스트 코드를 작성하는 과제가 나왔다.
지난번에 제출했던 todoapp 코드를 가지고 테스트 코드를 작성했다.
과제를 마무리하면서 이번에 작성해본 테스트 코드를 정리해보려고 한다.
깃허브: https://github.com/dlwls423/todoapp
Entity, Dto 테스트
Card에 있는 setComplete 매서드를 테스트
class CardTest {
Card card;
@BeforeEach()
void setUp() {
card = new Card();
}
@Test
@DisplayName("setComplete 테스트")
void test1() {
card.setComplete(true);
assertTrue(card.isComplete());
}
}
Service 테스트
CardSerice 테스트 - Mockito 사용
@ExtendWith(MockitoExtension.class)
class CardServiceTest {
@Mock
CardRepository cardRepository;
@Mock
UserRepository userRepository;
CardService cardService;
User user1;
User user2;
Card card;
Card privateCard;
@BeforeEach
void setup() {
// given
cardService = new CardService(cardRepository, userRepository);
user1 = new User("user1", "1234");
user2 = new User("user2", "1234");
card = new Card("제목", "내용", false, false, user1);
privateCard = new Card("제목", "내용", false, true, user1);
}
@Nested
@DisplayName("반복 사용되는 매서드")
class Test1 {
@Test
@DisplayName("카드 id로 카드 찾기")
void test1() {
// given
Long cardId = 1L;
card.setCardId(cardId);
given(cardRepository.findById(cardId)).willReturn(Optional.of(card));
// when
Card findCard = cardService.getCardEntity(cardId);
// then
assertEquals(card, findCard);
}
@Test
@DisplayName("카드 id로 카드 찾기 - 완료된 카드")
void test2() {
// given
Long cardId = 1L;
card.setCardId(cardId);
card.setComplete(true);
given(cardRepository.findById(cardId)).willReturn(Optional.of(card));
// when
BadAccessToCardException exception = assertThrows(BadAccessToCardException.class, () ->
cardService.getCardEntity(cardId)
);
// then
assertEquals("완료된 카드입니다.", exception.getMessage());
}
@Test
@DisplayName("카드 권한 없음")
void test3() {
//when
AuthorizeException exception = assertThrows(AuthorizeException.class, () ->
cardService.checkUser(card, user2)
);
//then
assertEquals("작성자만 삭제/수정할 수 있습니다.", exception.getMessage());
}
@Test
@DisplayName("비공개된 카드 - 타인이 접근")
void test4() {
//when
BadAccessToCardException exception = assertThrows(BadAccessToCardException.class, () ->
cardService.checkPrivateCardAuthority(privateCard, user2)
);
//then
assertEquals("비공개된 카드입니다.", exception.getMessage());
}
@Test
@DisplayName("비공개된 카드 - 작성자가 접근")
void test5() {
//when
boolean hasNoAuthority = cardService.checkPrivateCardAndUser(privateCard, user1);
//then
assertFalse(hasNoAuthority);
}
}
@Nested
@DisplayName("기능별 테스트")
class Test2 {
@Test
@DisplayName("카드 생성")
void test6() {
// given
CardRequestDto cardRequestDto = new CardRequestDto("카드 제목", "카드 내용", false);
card.setCardId(1L);
CardResponseDto cardResponseDto = new CardResponseDto(card);
when(cardRepository.save(any(Card.class))).thenReturn(card);
// when
CardResponseDto responseDto = cardService.createCard(cardRequestDto, user1);
// then
assertEquals(cardResponseDto.getCardId(), responseDto.getCardId());
}
@Test
@DisplayName("카드 단일 조회")
void test7() {
// given
Long cardId = 1L;
CardRequestDto requestDto = new CardRequestDto("제목", "내용", false);
given(cardRepository.findById(cardId)).willReturn(Optional.of(card));
// when
CardResponseDto responseDto = cardService.getCard(cardId, user1);
// then
assertEquals(card.getCardId(), responseDto.getCardId());
}
// 생략
}
}
Repository 테스트
@DataJpaTest
test profile 설정 - h2 (참고: https://beaniejoy.tistory.com/66)
@EnableJpaAuditing을 위한 JpaConfig 생성 -> main 매서드가 있는 Application 클래스에서는 지워줌
참고: https://developer-ping9.tistory.com/258
@Configuration // 아래 설정을 등록하여 활성화 합니다.
@EnableJpaAuditing // 시간 자동 변경이 가능하도록 합니다.
public class JpaConfig {
}
@ActiveProfiles("test")
@DataJpaTest
@Import(JpaConfig.class )
class CardRepositoryTest {
@Autowired
CardRepository cardRepository;
@Autowired
UserRepository userRepository;
User user1;
User user2;
Card card1;
Card card2;
Card card3;
@BeforeEach
void setup() {
// given
user1 = new User("사용자1", "abcd");
user2 = new User("사용자2", "abcd");
userRepository.save(user1);
userRepository.save(user2);
card1 = new Card("코딩 연습하기", "내용", false, false, user1);
card2 = new Card("조깅하기", "내용", false, false, user1);
card3 = new Card("코딩 테스트 문제 풀기", "내용", false, false, user2);
cardRepository.save(card1);
cardRepository.save(card2);
cardRepository.save(card3);
}
@Test
@DisplayName("사용자별 카드 조회 테스트")
void test1(){
//when
List<Card> cardList = cardRepository.findAllByUserAndCompleteFalseOrderByCreatedAtDesc(this.user1);
//then
for (Card card : cardList) {
System.out.println("카드 제목: " + card.getTitle());
}
assertEquals(card2, cardList.get(0));
}
@Test
@DisplayName("검색어별 카드 조회 테스트")
void test2(){
//when
List<Card> cardList = cardRepository.findAllByTitleContainsAndCompleteFalseOrderByCreatedAtDesc("코딩");
//then
for (Card card : cardList) {
System.out.println("카드 제목: " + card.getTitle());
}
assertEquals(cardList.get(0), card3);
}
}
Controller 테스트
@WebMvcTest
Spring security 필터 제외
컨트롤러에서 페이지가 아닌 데이터를 반환하는 경우 ObjectMapper를 사용해야 함
@WebMvcTest(
controllers = {CardController.class},
excludeFilters = {
@ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = WebSecurityConfig.class
)
}
)
public class CardControllerTest {
private MockMvc mvc;
private Principal mockPrincipal;
@Autowired
private WebApplicationContext context;
@Autowired
private ObjectMapper objectMapper;
@MockBean
CardService cardService;
static User testUser;
CardRequestDto requestDto;
@BeforeEach
public void setup() {
mvc = MockMvcBuilders.webAppContextSetup(context)
.apply(springSecurity(new MockSpringSecurityFilter()))
.build();
requestDto = new CardRequestDto("카드 제목", "카드 내용", false);
}
private void mockUserSetup() {
String username = "leeyejin";
String password = "11111111";
testUser = new User(username, password);
UserDetailsImpl testUserDetails = new UserDetailsImpl(testUser);
mockPrincipal = new UsernamePasswordAuthenticationToken(testUserDetails, "",
testUserDetails.getAuthorities());
}
@Test
@DisplayName("카드 생성")
void test1() throws Exception {
//given
mockUserSetup();
//when - then
mvc.perform(post("/api/cards")
.content(objectMapper.writeValueAsString(requestDto))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.principal(mockPrincipal)
)
.andExpect(status().is2xxSuccessful());
}
//생략
}
통합 테스트
CardService와 UserRepository 테스트
@SpringBootTest
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@TestInstance(Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class CardServiceIntegrationTest {
@Autowired
CardService cardService;
@Autowired
UserRepository userRepository;
User user1;
CardResponseDto createdCard;
@Test
@Order(1)
@DisplayName("카드 생성")
void test1() {
// given
CardRequestDto requestDto = new CardRequestDto("제목", "내용", false);
user1 = userRepository.findById(1L).orElse(null);
// when
CardResponseDto responseDto = cardService.createCard(requestDto, user1);
// then
assertNotNull(responseDto.getCardId());
assertEquals(requestDto.getTitle(), responseDto.getTitle());
createdCard = responseDto;
}
// 생략
}
Jacoco 라이브러리
builde.gradle에 jacoco 추가
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.5'
id 'io.spring.dependency-management' version '1.1.3'
id 'jacoco'
}
테스트 후 오른쪽의 Gradle > Tasks > verification > jacocoTestReport 실행
왼쪽의 build > reports > jacoco > index.html 실행
과제 제출
1. 통합 테스트와 단위 테스트의 차이점에 대해서 설명해주세요.
단위 테스트는 하나의 모듈이나 클래스에 대한 에러 검증에 집중하고 통합 테스트는 모듈 간의 연결에서 발생할 수 있는 에러를 검증하는데에 집중한다.
2. 통합 테스트과 단위 테스트의 장/단점에 대해서 설명해주세요
단위 테스트 - 각 모듈이 제 기능을 잘 수행하는지 테스트하는데 집중 가능하고 테스트 속도가 빠르지만 모듈 간의 상호작용이 잘 이루어지는지는 알 수 없다.
통합 테스트 - 테스트를 작성하고 실행하는데 더 많은 시간이 걸리고 디버깅이 어렵지만 테스트의 신뢰성이 더 높다.
실제 상황과 유사하게 테스트 가능하다.
3. 레이어별로 나누어서 Slice Test 를 하는 이유에 대해서 설명해주세요.
특정 레이어에 대해서 Bean을 최소한을 등록시켜 테스트하고자 하는 부분을 독립적으로 효율적으로 테스트 가능하ㅏ다. 각 레이어별로 테스트하여 에러를 검증할 수 있다.
4. 테스트 코드를 직접 짜보고 나서 느낀 테스트 필요성을 적어주세요.
전체 프로그램을 실행시켰을 때 에러가 발생하면 어디서 에러가 발생했는지 알기 어려운데 슬라이스 테스트를 진행하면 어떤 계층에서 에러가 발생했는지 파악하고 수정하기 용이할 것 같다.
프로그램 전체를 돌리고 포스트맨으로 요청과 응답을 받는 데에는 시간이 오래 걸리는데 테스트 코드를 작성해두면 빠르게 여러 번 검증할 수 있다.
5. 테스트 코드를 짜면서 어려웠던 점을 적어주세요
각 테스트별로 환경 설정을 하는 과정이 가장 어려웠다. 테스트 코드를 작성하면서도 어떤 테스트 코드가 좋은 테스트 코드인지 궁금했다.
심화 과제 질문
테스트 코드를 작성하면서 궁금했던 점들에 대하여 깃허브의 이슈에 남겨두었다.
추후에 답변을 받은 후에 정리해보려고 한다.
https://github.com/dlwls423/todoapp/issues
심화 과제 해설 영상
해설 영상을 보면서 배우게 된 내용이 많았는데 기존의 내 코드에 적용해보려고 했지만
오류도 나고... 시간이 부족해서 일부밖에 적용해보지 못했다.
모범 답안 깃허브 주소: https://github.com/thesun4sky/todoParty
JwtUtil 테스트
우선 application-test.properties에 jwt key가 있어야 한다.
@SpringBootTest
@ActiveProfiles("test")
public class JwtUtilTest {
@Autowired
JwtUtil jwtUtil;
@Mock
private HttpServletRequest request;
@BeforeEach
void setup(){
jwtUtil.init();
}
@DisplayName("토큰 생성")
@Test
void createToken() {
// when
String token = jwtUtil.createToken("test-user", null);
// then
assertNotNull(token);
}
@DisplayName("토큰 추출")
@Test
void resolveToken() {
// given
String token = "test-token";
String bearerToken = BEARER_PREFIX + token;
// when
given(request.getHeader(JwtUtil.AUTHORIZATION_HEADER)).willReturn(bearerToken);
String resolvedToken = jwtUtil.getJwtFromHeader(request);
// then
assertEquals(token, resolvedToken);
}
@DisplayName("토큰 검증")
@Nested
class validateToken {
@DisplayName("토큰 검증 성공")
@Test
void validateToken_success() {
// given
String token = jwtUtil.createToken("test-user", null).substring(7);
// when
boolean isValid = jwtUtil.validateToken(token);
// then
assertTrue(isValid);
}
@DisplayName("토큰 검증 실패 - 유효하지 않은 토큰")
@Test
void validateToken_fail() {
// given
String invalidToken = "invalid-token";
// when
boolean isValid = jwtUtil.validateToken(invalidToken);
// then
assertFalse(isValid);
}
}
@DisplayName("토큰에서 UserInfo 조회")
@Test
void getUserInfoFromToken() {
// given
String token = jwtUtil.createToken("test-user", null).substring(7);
// when
String username = jwtUtil.getUserInfoFromToken(token).getSubject();
// then
assertNotNull(username);
assertEquals("test-user", username);
}
}
UserRequestDto 테스트 - Validation
public class UserRequestDtoTest {
@DisplayName("dto 생성")
@Nested
class createUserRequestDto {
@DisplayName("성공")
@Test
void createUserRequestDto_success() {
// given
UserRequestDto requestDto = new UserRequestDto("username", "password");
// when
Set<ConstraintViolation<UserRequestDto>> violations = validate(requestDto);
// then
assertThat(violations).isEmpty();
}
@DisplayName("실패 - 잘못된 username")
@Test
void createUserRequestDto_wrongUsername(){
// given
UserRequestDto requestDto = new UserRequestDto("user name", "password");
// when
Set<ConstraintViolation<UserRequestDto>> violations = validate(requestDto);
// then
System.out.println(violations);
assertThat(violations).hasSize(1);
assertThat(violations)
.extracting("message")
.contains("must match \"^[a-z0-9]*$\"");
}
@DisplayName("실패 - 잘못된 password")
@Test
void createUserRequestDto_wrongPassword(){
// given
UserRequestDto requestDto = new UserRequestDto("username", "wrong**password");
// when
Set<ConstraintViolation<UserRequestDto>> violations = validate(requestDto);
// then
assertThat(violations).hasSize(1);
assertThat(violations)
.extracting("message")
.contains("must match \"^[a-zA-Z0-9]*$\"");
}
}
private Set<ConstraintViolation<UserRequestDto>> validate(UserRequestDto userRequestDTO) {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
return validator.validate(userRequestDTO);
}
}
추가 정리
- 서비스 테스트에서 @InjectMocks 사용
- 기존에는 service에서 필요한 mock을 생성자로 대입
@ExtendWith(MockitoExtension.class)
class CardServiceTest {
@Mock
CardRepository cardRepository;
@Mock
UserRepository userRepository;
@InjectMocks
CardService cardService;
//생략
}
- dto을 필드값을 기준으로 비교하고자 할 때
- @EqualsAndHashCode 사용
- 필드값이 같으면 같은 해시코드
@Setter
@Getter
@Builder
@EqualsAndHashCode(callSuper = false)
@AllArgsConstructor
public class TodoResponseDTO extends CommonResponseDTO {
private Long id;
private String title;
private String content;
private Boolean isCompleted;
private UserDTO user;
private LocalDateTime createDate;
public TodoResponseDTO(String msg, Integer statusCode) {
super(msg, statusCode);
}
public TodoResponseDTO(Todo todo) {
this.id = todo.getId();
this.title = todo.getTitle();
this.content = todo.getContent();
this.isCompleted = todo.getIsCompleted();
this.user = new UserDTO(todo.getUser());
this.createDate = todo.getCreateDate();
}
}
- 테스트용 객체를 생성: 외부에서 setter로 값을 넣어줄 수 없는 필드에 값을 넣을 때
- ReflectionTestUtils 사용
- 객체를 복제해서 응답값으로 주는 Serializationutils.clone(객체)를 사용
- 해당 객체 클래스가 Serializable의 구현체여야 함
public class TodoTestUtils {
public static Todo get(Todo todo, User user) {
return get(todo, 1L, LocalDateTime.now(), user);
}
/**
* 테스트용 할일 객체를 만들어주는 메서드
* @param todo 복제할 할일 객체
* @param id 설정할 아이디
* @param createDate 설정할 생성일시
* @param user 연관관계 유저
* @return 테스트용으로 생성된 할일 객체
*/
public static Todo get(Todo todo, Long id, LocalDateTime createDate, User user) {
var newTodo = SerializationUtils.clone(todo);
ReflectionTestUtils.setField(newTodo, Todo.class, "id", id, Long.class);
ReflectionTestUtils.setField(newTodo, Todo.class, "createDate", createDate, LocalDateTime.class);
newTodo.setUser(user);
return newTodo;
}
}
public class Todo implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String title;
@Column
private String content;
@Column
private LocalDateTime createDate;
// 생략
}
- 컨트롤러 테스트에서 값을 검증하는 다양한 방법
- verify
- accept()
- jsonPath().value()
@WebMvcTest(TodoController.class)
class TodoControllerTest extends ControllerTest implements TodoTest {
@MockBean
private TodoService todoService;
@DisplayName("할일 생성 요청")
@Test
void postTodo() throws Exception {
// given
// when
var action = mockMvc.perform(post("/api/todos")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(TEST_TODO_REQUEST_DTO)));
// then
action.andExpect(status().isCreated());
verify(todoService, times(1)).createTodo(any(TodoRequestDTO.class), eq(TEST_USER));
}
@Nested
@DisplayName("할일 조회 요청")
class getTodo {
@DisplayName("할일 조회 요청 성공")
@Test
void getTodo_success() throws Exception {
// given
given(todoService.getTodoDto(eq(TEST_TODO_ID))).willReturn(TEST_TODO_RESPONSE_DTO);
// when
var action = mockMvc.perform(get("/api/todos/{todoId}", TEST_TODO_ID)
.accept(MediaType.APPLICATION_JSON));
// then
action
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value(TEST_TODO_TITLE))
.andExpect(jsonPath("$.content").value(TEST_TODO_CONTENT));
}
@DisplayName("할일 조회 요청 실패 - 존재하지 않는 할일ID")
@Test
void getTodo_fail_todoIdNotExist() throws Exception {
// given
given(todoService.getTodoDto(eq(TEST_TODO_ID))).willThrow(new IllegalArgumentException());
// when
var action = mockMvc.perform(get("/api/todos/{todoId}", TEST_TODO_ID)
.accept(MediaType.APPLICATION_JSON));
// then
action
.andExpect(status().isBadRequest());
}
}
@DisplayName("할일 목록 조회 요청")
@Test
void getTodoList() throws Exception {
// given
var testTodo1 = TodoTestUtils.get(TEST_TODO, 1L, LocalDateTime.now(), TEST_USER);
var testTodo2 = TodoTestUtils.get(TEST_TODO, 2L, LocalDateTime.now().minusMinutes(1), TEST_USER);
var testAnotherTodo = TodoTestUtils.get(TEST_TODO, 3L, LocalDateTime.now(), TEST_ANOTHER_USER);
given(todoService.getUserTodoMap()).willReturn(
Map.of(new UserDTO(TEST_USER), List.of(new TodoResponseDTO(testTodo1), new TodoResponseDTO(testTodo2)),
new UserDTO(TEST_ANOTHER_USER), List.of(new TodoResponseDTO(testAnotherTodo))));
// when
var action = mockMvc.perform(get("/api/todos")
.accept(MediaType.APPLICATION_JSON));
// then
action
.andExpect(status().isOk())
.andExpect(jsonPath("$[?(@.user.username=='" + TEST_USER.getUsername() + "')].todoList[*].id")
.value(Matchers.containsInAnyOrder(testTodo1.getId().intValue(), testTodo2.getId().intValue())))
.andExpect(jsonPath("$[?(@.user.username=='" + TEST_ANOTHER_USER.getUsername() + "')].todoList[*].id")
.value(Matchers.containsInAnyOrder(testAnotherTodo.getId().intValue())));
verify(todoService, times(1)).getUserTodoMap();
}
// 생략
}
공통 코드 정리
- 공통으로 필요한 변수들을 인터페이스로 생성하여 사용
- CommonTest
public interface CommonTest {
String ANOTHER_PREFIX = "another-";
Long TEST_USER_ID = 1L;
Long TEST_ANOTHER_USER_ID = 2L;
String TEST_USER_NAME = "username";
String TEST_USER_PASSWORD = "password";
User TEST_USER = User.builder()
.username(TEST_USER_NAME)
.password(TEST_USER_PASSWORD)
.build();
User TEST_ANOTHER_USER = User.builder()
.username(ANOTHER_PREFIX + TEST_USER_NAME)
.password(ANOTHER_PREFIX + TEST_USER_PASSWORD)
.build();
}
- 도메인 별로 상속받아 인터페이스 구현
- TodoTest
public interface TodoTest extends CommonTest {
Long TEST_TODO_ID = 1L;
String TEST_TODO_TITLE = "title";
String TEST_TODO_CONTENT = "content";
TodoRequestDTO TEST_TODO_REQUEST_DTO = TodoRequestDTO.builder()
.title(TEST_TODO_TITLE)
.content(TEST_TODO_CONTENT)
.build();
TodoResponseDTO TEST_TODO_RESPONSE_DTO = TodoResponseDTO.builder()
.title(TEST_TODO_TITLE)
.content(TEST_TODO_CONTENT)
.build();
Todo TEST_TODO = Todo.builder()
.title(TEST_TODO_TITLE)
.content(TEST_TODO_CONTENT)
.build();
Todo TEST_ANOTHER_TODO = Todo.builder()
.title(ANOTHER_PREFIX + TEST_TODO_TITLE)
.content(ANOTHER_PREFIX + TEST_TODO_CONTENT)
.build();
}
- 컨트롤러 테스트에서 필요한 코드 정리
- ControllerTest
- 각 컨트롤러 테스트에서 상속받아 사용
public class ControllerTest implements CommonTest {
@Autowired
private WebApplicationContext context;
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders
.webAppContextSetup(context)
.build();
// Mock 테스트 UserDetails 생성
UserDetailsImpl testUserDetails = new UserDetailsImpl(TEST_USER);
// SecurityContext 에 인증된 사용자 설정
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
testUserDetails, testUserDetails.getPassword(), testUserDetails.getAuthorities()));
}
}
'TIL' 카테고리의 다른 글
[231207] 심화 주차 조별 과제 (1) | 2023.12.07 |
---|---|
[231206] 프로그래머스 피로도 (Java) (0) | 2023.12.06 |
[231201] JPA와 영속성 컨텍스트 (0) | 2023.12.01 |
[231130] 팀과제 수정 (0) | 2023.11.30 |
[231129] 프록시와 영속성, 스프링 데이터 JPA (0) | 2023.11.29 |