본문 바로가기
TIL

[231204] 심화 주체 개인과제: 테스트

by 진진리 2023. 12. 4.
728x90

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()));
	}
}