[스프링부트 #9] TEST #1 - 단위 테스트와 통합 테스트

김지현·2023년 12월 4일
0

스프링 부트는 다양한 테스트 전략을 제공한다. 작은 단위에 대해 독립적인 테스트를 수행하는 단위테스트부터 모든 Bean을 올리고 테스트를 진행하는 통합 테스트까지 테스트 대상 레이어에 맞게 다른 테스트 전략을 가질 수 있다.

이전에 만들었던 Post Board 게시판에 테스트 코드를 추가하였다. 또한 각각의 테스트 코드는 given(준비)-when(실행)-then(검증) 3등분의 패턴을 가지도록 작성했다.

단위 테스트

특정 레이어에 대해 Bean을 최소한으로 등록시켜 테스트하고자 하는 부분에 최대한의 단위 테스트를 제공한다. 하나의 모듈이나 클래스에 대해 세밀한 부분까지 테스트가 가능하나 모듈간에 상호 작용 검증은 불가능하다. 외부 의존성은 모의(mock)로 대체하여 격리를 보장하며 테스트가 빠르게 실행되어야 한다.

POJO 테스트

POJO(Plain Old Java Object)는 특별한 제약이나 규약이 없는 순수한 자바 객체를 나타낸다. POJO 테스트는 주로 단위 테스트의 일부로서 간단한 객체의 동작을 확인할 때 유용하다.

가장 작은 단위인 Entity와 DTO에 대한 테스트 코드부터 우선적으로 작성해보았다. 대표적으로 validation이 존재하는 UserRequestDto 테스트를 예시로 들겠다.

@Test
    @DisplayName("유저 request dto 생성 실패 - 잘못된 username")
    void userRequestDtoTest(){
        // given
        UserRequestDto requestDto = new UserRequestDto("sa", "password");

        // when
        Set<ConstraintViolation<UserRequestDto>> violations = validate(requestDto);

        // then
        assertThat(violations).hasSize(1);
        assertThat(violations)
                .extracting("message")
                .contains("사용자 이름은 4자 이상 10자 이하여야 합니다.");
    }
    
private Set<ConstraintViolation<UserRequestDto>> validate(UserRequestDto userRequestDTO) {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();
        return validator.validate(userRequestDTO);
    }

우선 유효성 검증을 위해 validate 메서드를 작성해준다. 이 메서드는 UserRequestDto에 대한 유효성 검증에 실패할 경우 제약 위반에 대한 정보를 set에 담아 return 해준다.
테스트는 유효성 규칙을 어기도록 username이 2글자로 작성된 requestDto를 보내고 위반 결과와 메세지를 확인한다.

Service 테스트

Service에서는 Repository가 주입되어 사용된다. 그러나 우리는 서비스 클래스만을 테스트하길 원하며 레포지토리가 어떻게 동작하는지는 중요하지 않다. 이를 위해 가짜 객체 (Mock object)를 사용한다. MockRepository를 생성하면 실제 DB 작업은 이루어지지 않지만 이 작업이 이루어지는 것처럼 필요한 결과값만을 return 해준다.

Mockito

@ExtendWith(MockitoExtension.class) : Mock 사용을 위해 설정
@Mock : Mock 객체 생성
@InjectMock : Mock 객체를 자동으로 필드에 주입
@MockBean : Spring의 ApplicationContext에 빈(Mockito 목 객체)을 주입

@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
    @Mock
    ProductRepository productRepository;
    
    @InjectMock
    ProductService productService;
}

Mock 객체는 실제 동작을 수행하지 않으므로 Mock 객체에 대한 특정 메서드 호출에 대해 직접 동작을 정의해주어야한다. when-thenReturn 또는 given-willReturn을 사용한다.

@Test
    @DisplayName("피드 수정 성공")
    void createFeedTest(){
        // given
        feed = new Feed(requestDto, user);
        when(feedRepository.findById(feedId)).thenReturn(Optional.of(feed));

        // when
        FeedResponseDto responseDto = feedService.updateFeed(requestDto, user);

        // then
        assertEquals("제목", responseDto.getTitle());
        assertEquals("내용", responseDto.getContents());
     }

위의 코드에서는 feedRepository에 findById 메서드를 호출할 경우 미리 만들어둔 feed를 반환하도록 지정한다. 서비스 테스트에서 중요한 것은 request와 그에 따른 response이지 레포지토리의 동작 수행은 중요하지 않다. 그러므로 로직을 수행하다가 레포지토리를 호출할 경우 이렇게 동작할 것이라고 지정해주는 것이다.
이에 대한 장단점도 존재한다. 외부 의존도가 낮기 때문에 테스트하고자 하는 부분만 명확히 테스트가 가능하고 예외 발생시 발견과 처리가 쉬워지지만 실제 환경에서 제대로 동작하지 않을 가능성도 있다.

Repository 테스트

Repository 테스트에서는 일반적으로 기본 제공되는 findById, findALl 같은 메서드는 테스트하지 않고 커스텀한 쿼리 메서드 등을 테스트한다. @DataJpaTest 애너테이션을 사용하여 레포지토리에 대한 Bean만 등록하며 메모리 DB에 대한 테스트를 진행한다.

@Test
    @DisplayName("사용자의 완료되지 않은 피드 최신순 조회")
    void findAllTest(){
        // given
        FeedRequestDto requestDto = new FeedRequestDto("제목", "내용");
        Feed feed1 = new Feed(requestDto, user);
        Feed feed2 = new Feed(requestDto, user);
        Feed feed3 = new Feed(requestDto, user);
        feedRepository.saveAll(List.of(feed1, feed2, feed3));

        feed3.setComplete(true);

        // when
        List<Feed> feedList = feedRepository.findAllByUserAndCompleteOrderByCreatedAtDesc(user, false);

        // then
        assertThat(feedList).containsExactly(feed2, feed1);		// 순서 확인
    }

이 테스트에서는 findAllByUserAndCompleteOrderByCreatedAtDesc() 메서드를 테스트한다. 특정한 User의 feed들 중 complete가 false인 feed들을 최신순으로 조회한다.

Controller 테스트

개인적으로 spring security 때문에 가장 까다롭다고 생각한 테스트이다. 우선 @WebMvcTest 애너테이션을 사용하여 테스트하고자 하는 class를 지정해준다. 또한 컨트롤러에서는 @AutenticationPrincipal 애너테이션을 사용하므로 mock principal 객체를 생성해주어야 한다. setup에서 테스트에 필요한 mock mvc 객체를 설정하고 빌드해준 뒤, 테스트를 진행한다.

@WebMvcTest(
        controllers = {FeedController.class},
        excludeFilters = {
                @ComponentScan.Filter(
                        type = FilterType.ASSIGNABLE_TYPE,
                        classes = WebSecurityConfig.class
                )
        }
)
public class FeedControllerTest {
    private MockMvc mvc;

    private Principal mockPrincipal;

    @Autowired
    private WebApplicationContext context;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    FeedService feedService;

    @BeforeEach
    public void setup() {
        mvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(springSecurity(new MockSpringSecurityFilter()))
                .build();

        mockUserSetup();
    }

    private void mockUserSetup() {
        String username = "username";
        String password = "password";
        User testUser = new User(username, password, UserRoleEnum.USER);
        UserDetailsImpl testUserDetails = new UserDetailsImpl(testUser);
        mockPrincipal = new UsernamePasswordAuthenticationToken(testUserDetails, "",
        testUserDetails.getAuthorities());
    }

spring security의 WebSecurityConfig는 제외하고 가짜 필터(MockSpringSecurityFilter)를 만들어 사용하도록 한다.

public class MockSpringSecurityFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) {}

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
    throws IOException, ServletException {
        SecurityContextHolder.getContext()
                .setAuthentication((Authentication) ((HttpServletRequest) req).getUserPrincipal());
        chain.doFilter(req, res);
    }

    @Override
    public void destroy() {
        SecurityContextHolder.clearContext();
    }
}

setup 후 본격적인 테스트를 진행한다.

	@Test
    @DisplayName("게시글 작성")
    void createFeedTest() throws Exception {
        // given
        FeedRequestDto requestDto = new FeedRequestDto("제목", "내용");
        String feedInfo = objectMapper.writeValueAsString(requestDto);
        FeedResponseDto responseDto = new FeedResponseDto(feed);
        
        when(feedService.createFeed(feedId)).thenReturn(responseDto);

        // when - then
        mvc.perform(post("/feeds")
                        .content(feedInfo)
                        .contentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8))
                        .accept(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8))
                        .principal(mockPrincipal)
                )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title").value(responseDto.getTitle()))
                .andExpect(jsonPath("$.contents").value(responseDto.getContents()))
                .andDo(print());
    }
  1. requestDto를 생성하고 objectMapper를 통해 JSON 문자열로 변환한다.
  2. "/feeds"의 엔드포인트에 POST 요청으로 보낸다.
    2-1. 이때 본문에 변환된 JSON 문자열을 담고 이 컨텐츠의 타입을 JSON으로 설정한다.
    2-2. 요청 또한 JSON으로 받도록 설정한다.
    2-3. 요청에 사용될 인증 객체는 setup에서 생성한 가짜 인증 객체를 활용한다.
  3. 해당 요청이 성공정으로 수행되면 HTTP 응답 상태가 200이므로 andExpect로 이를 확인한다.
  4. 응답 JSON에서 title 필드의 값이 반환받기로 지정한 responseDto의 title과 일치하는지 확인한다.

⚠️ Application에 @EnableJpaAuditing 애너테이션이 존재하는 경우 controller 테스트가 방해를 받아 제대로 수행되지 않으므로 JpaConfig 설정을 따로 해주어야 한다.

통합 테스트

통합 테스트는 spring이 동작하는 테스트로 모든 Bean을 올리고 테스트를 진행하므로 Mock 객체를 만들 필요 없이 쉽게 테스트 가능하다. 모듈 간의 상호 작용을 확인하고 실제 운영 환경과 가장 유사하게 전체적인 테스트를 할 수 있다. 그러나 테스트 시간이 오래 걸리고 단위가 크므로 실패시 디버깅이 어렵다는 단점이 있다.

@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)	 // 랜덤 포트 설정
@TestInstance(TestInstance.Lifecycle.PER_CLASS)		// 테스트 내에서 하나의 인스턴스 공유
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)	// 테스트 순서 지정 가능
class PostBoardApplicationTests {
}

통합 테스트의 코드는 단위 테스트와 유사해보이지만 다른 레이어가 가짜 객체로 대체되지 않고 실제로 수행된다는 점에서 차이가 있다.

		@Test
        @Order(1)
        @DisplayName("회원가입 성공")
        void signupTest1() throws Exception {
            // given
            UserRequestDto requestDto = new UserRequestDto("username", "password");
            String userInfo = objectMapper.writeValueAsString(requestDto);

            // when - then
            mvc.perform(post("/user/signup")
                            .content(userInfo)
                            .contentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8))
                            .accept(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8))
                    )
                    .andExpect(status().isOk())
                    .andExpect(content().string("회원가입 성공"))
                    .andDo(print());
        }

Post Board 게시판에 테스트 코드를 하나씩 추가해보며 테스트 코드에 대해 익히는 시간을 가져보았다. 어디서부터 어디까지 테스트를 진행해야 하는지, 어떠한 값까지 검증해야 하는지에 대한 범위를 가늠하거나 mock을 활용하는 부분이 헷갈렸지만 우선은 최대한 모든 메서드와 응답들에 대해 테스트가 진행될 수 있도록 하였다.

github : https://github.com/zomeong/Post-Borad

0개의 댓글