본문 바로가기
Testing

단위 테스트(Unit Test) & 통합 테스트(Integration Test)

by WhoamixZerOne 2025. 4. 9.

https://bap-software.net/en/knowledge/testing-and-debugging-the-important-difference/

 

지금까지 테스트 작성을 거의 하지 않았다. 애플리케이션을 실행해서 직접 확인했는데 코드의 변경이 있을 때마다 많은 예외와 잦은 변경 시 처음부터 하나하나 다시 확인을 해봐야 하는 번거로움과 시간이 오래 걸린다는 문제로 테스트의 중요성을 많이 느꼈다.

 

그래서 현재 진행하고 있는 프로젝트는 실제 운용을 하기 때문에 필수적으로 테스트를 작성하기로 팀원들과 얘기를 했다. 테스트에 대해서 아는 내용이 없고 구현 경험이 적기 때문에 처음에는 구글 검색과 AI 등으로 간단히 구현했다. 그러다 보니 잘 구현한 게 맞는지 의문점과 단위 테스트(Unit Test), 통합 테스트(Integration Test)의 경계점이 많이 혼동 돼서 개념과 범위 등을 공부하고 기록을 남기려고 한다. 

테스트의 중요성

실제로 테스트 없이 개발을 진행하면서 많이 느꼈지만 테스트가 없으면 소프트웨어의 품질을 보장할 수 없어 신뢰도가 떨어질 수 있습니다. 내가 구현한 기능이 정상적인지 매번 애플리케이션을 실행해 확인을 한다면 많이 시간을 투자해야 하고, 리팩토링 시 의존하는 다른 서비스에 문제가 있는지 확인하기 힘듭니다. 그러므로 테스트를 통해 코드의 품질을 보장하고, 버그를 사전에 발견할 수 있습니다.

 

테스트에는 크게 단위 테스트(Unit Test)와 통합 테스트(Integration Test)로 나눌 수 있습니다. 각각의 테스트는 목적과 역할이 다르기 때문에 이를 잘 이해하고 적절히 활용하는 것이 좋습니다.

단위 테스트(Unit Test)

단위 테스트(Unit Test)는 가장 작은 단위(주로 클래스나 클래스의 메서드 단위)로 테스트하는 방법입니다.

 

단위 테스트에서 테스트 대상 단위의 크기 범위가 정해져 있지 않습니다. 대부분 개별 컴포넌트 또는 클래스의 기능을 격리된 환경(독립적)에서 검증하는 것으로 특정 기능이 설계된 대로 동작하는지 확인하는 것을 목표로 합니다.

 

테스트 대상이 외부 의존성(ex. 데이터베이스, 네트워크, 파일 시스템 등)에 의존하지 않도록 Mock 객체나 Stub을 사용해 의존성을 대체해서 순수한 로직만 테스트합니다. 외부 시스템과 연결되지 않아 실행 속도가 매우 빠르기 때문에 빠른 피드백을 받을 수 있고 또한 단위가 작기 때문에 특정 모듈의 문제를 좀 더 정확히 추적할 수 있습니다.
다만 전체 시스템의 동작을 검증할 수 없어 실제 환경과의 상호작용을 검증하지 못하기 때문에 신뢰도가 떨어집니다.

 

Spring Boot에서 단위 테스트를 작성할 때 Spring 컨텍스트를 로드하지 않고 테스트를 진행합니다. 즉, @SpringBootTest 같은 애너테이션을 사용하지 않고, 순수 Java 코드 수준에서 테스트를 작성합니다. Spring 컨텍스트를 로드하면 실행 속도가 많이 느려지기 때문입니다.

통합 테스트(Integration Test)

통합 테스트(Integration Test)는 여러 컴포넌트나 모듈이 함께 동작하는지 확인하는 테스트입니다.

 

단위 테스트가 개별 단위를 격리해서 테스트한다면, 통합 테스트는 이 단위들이 서로 연결되어 올바르게 작동하는지 검증합니다.
통합 테스트는 여러 구성 요소 간의 상호작용을 검증하는 데 초점을 맞추기 때문에 실행 속도가 단위 테스트보다 길어질 수 있지만 더 넓은 범위의 상호작용을 검증할 수 있습니다. 통합 테스트는 실행 속도가 현저히 느리기 때문에 모든 시나리오를 검증하지 않고 핵심 시나리오들을 검증합니다.

 

데이터베이스, 외부 API, 메시지 큐 등 실제 의존성을 사용하거나 테스트용 의존성(ex. H2 인메모리 DB)을 사용해서 전체 시스템을 검증하거나 Layer(Controller, Service, Repository 등)가 함께 동작하는 시나리오를 테스트합니다.

 

Spring Boot에서는 @SpringBootTest 애너테이션을 사용해 Spring 컨텍스트를 로드하고, 실제 애플리케이션 환경에 가까운 상태에서 테스트를 진행합니다. 필요에 따라 @Testcontainers로 실제 DB를 띄우거나, @MockitoBean으로 일부 의존성을 모킹 합니다.

단위 테스트 vs 통합 테스트

구분 단위 테스트 통합 테스트
범위 하나의 클래스 / 메서드 여러 모듈 / 레이어 간 상호작용
의존성 Mock / Stub으로 격리 실제 or 테스트용 의존성 사용
속도 매우 빠름  상대적으로 느림
Spring Context @SpringBootTest 로드 
목적 로직 검증 시스템 통합 검증
도구 JUnit, Mockito JUnit, Spring Test, Testcontainers 등 

단위 테스트(Unit Test) 작성

Service Layer Test

외부 의존성을 의존하지 않도록 Mock, Stub을 사용해 빠른 피드백을 받도록 했습니다.

@ExtendWith(MockitoExtension.class)
class NoticeServiceTest {

    @Mock
    private MemberRepository memberRepository;

    @Mock
    private NoticeRepository noticeRepository;

    @InjectMocks
    private NoticeService noticeService;

    @DisplayName("공지사항 전체를 조회한다.")
    @Test
    void findAllNotice() {
        // Arrange
        Notice notice1 = createNotice("title 1", "content 1");
        Notice notice2 = createNotice("title 2", "content 2");
        ReflectionTestUtils.setField(notice1, "createdAt", LocalDateTime.of(2025, 1, 1, 0, 0, 0));
        ReflectionTestUtils.setField(notice2, "createdAt", LocalDateTime.of(2025, 1, 1, 1, 0, 0));

        List<Notice> notices = List.of(notice2, notice1);

        given(noticeRepository.findAllByIsDeletedFalseOrderByCreatedAtDescIdDesc())
                .willReturn(notices);

        // Act
        List<NoticeResponse> result = noticeService.findAll();

        // Assert
        NoticeResponse response = result.get(0);

        assertThat(result).hasSize(2);
        assertThat(response.title()).isEqualTo(notice2.getTitle());
        assertThat(response.content()).isEqualTo(notice2.getContent());

        then(noticeRepository).should(times(1))
                .findAllByIsDeletedFalseOrderByCreatedAtDescIdDesc();
    }
    ...
    
    @DisplayName("공지사항을 정상적으로 수정한다.")
    @Test
    void upatedNotice() {
        // Arrange
        long noticeId = 1L;
        NoticeUpdateRequest request = createUpdateRequest();

        Notice noticeMock = mock(Notice.class);

        given(noticeRepository.findById(noticeId)).willReturn(Optional.of(noticeMock));
        
        // Act
        noticeService.update(noticeId, request);
        
        // Assert
        then(noticeMock).should(times(1))
                .update(request);
    }
    ...
}

 

@ExtendWith(MockitoExtension.class)는 JUnit 5에서 Mockito를 사용할 때 필요한 설정을 제공합니다. 즉, 테스트 클래스에서 Mockito 기능을 활성화하고 테스트 환경을 설정하는 역할을 합니다.

NoticeService에서 의존하는 객체들을 @Mock으로 Mockito가 실제 객체 대신 가짜(mock) 객체를 생성하고, @InjectMocks로 필요한 의존성들을 자동으로 주입(injection)합니다.

 

given(), then()은 BDDMockito로 Stub, 검증을 했습니다. Mockito의 when(), verify()를 사용하지 않고 BDDMockito를 사용한 이유는 테스트를 작성할 때 given, when, then 패턴으로 가독성 좋게 많이들 작성하는데 그 이유로 더 가독성 좋게 BDDMockito를 사용했습니다.

 

update, delete(soft delete) 로직은 entity의 메서드에서 값을 변경하고 있는데 값이 변경된 검증은 Entity 테스트에서 검증하는 게 좋을 것 같다고 판단해 Entity 테스트에서 진행했습니다.

Entity Test

class NoticeTest {

    @DisplayName("공지사항 수정시 title, content가 변경되어야 한다.")
    @Test
    void changeTitleAndContent() {
        // Arrange
        Notice notice = createNotice("title 1", "content 1");
        NoticeUpdateRequest request = createUpdateRequest();
        
        // Act
        notice.update(request);
        
        // Assert
        assertThat(notice).extracting("title", "content")
            .containsOnly(request.title(), request.content());
    }

    @DisplayName("공지사항 삭제시 isDeleted가 true로 변경되어야 한다.")
    @Test
    void changeIsDeletedValueFalse() {
        // Arrange
        Notice notice = createNotice("title 1", "content 2");
        
        // Act
        notice.changeDeleted(true);
        
        // Assert
        assertThat(notice.isDeleted()).isTrue();
    }
    ...
}

Controller Layer Test

현재 프로젝트는 Spring Boot, Thymeleaf, Spring Security, OAuth2를 사용하고 있습니다.

Web Layer만 테스트하기 위해 @WebMvcTest, MockMvc로 테스트를 사용했습니다.

If you want to focus only on the web layer and not start a complete ApplicationContext, consider using @WebMvcTest instead.
spring.io docs

 

@WebMvcTest란?

@WebMvcTest는 Controller Layer의 테스트를 위한 애너테이션입니다.

To test whether Spring MVC controllers are working as expected, use the @WebMvcTest annotation. @WebMvcTest auto-configures the Spring MVC infrastructure and limits scanned beans to @Controller, @ControllerAdvice, @JsonComponent, Converter, GenericConverter, Filter, HandlerInterceptor, WebMvcConfigurer, WebMvcRegistrations, and HandlerMethodArgumentResolver. Regular @Component and @ConfigurationProperties beans are not scanned when the @WebMvcTest annotation is used. @EnableConfigurationProperties can be used to include @ConfigurationProperties beans.
spring.io docs

 

Spring Team은 Spring MVC Controllers가 예상대로 동작하는지 테스트하려면 @WebMvcTest 애너테이션을 사용하라고 얘기하고 있습니다. @WebMvcTest는 Spring MVC를 자동으로 구성하고 @Controller, @ControllerAdvice, @JsonComponent, Converter, GenericConverter, Filter, HandlerInterceptor, WebMvcConfigurer, WebMvcRegistrations, HandlerMethodArgumentResolver bean 스캔을 제한한다고 합니다.

즉, @SpringBootTest는 Spring 전체 컨텍스트를 로드하는데 @WebMvcTest는 Web Layer 관련 빈들만 등록하기 때문에 Controller만 테스트하기에 더욱 빠른 피드백을 받을 수 있습니다.

Controller Test 작성

@WebMvcTest는 Slice Test로 단위 테스트와 통합 테스트 사이 정도로 구분되는데 Spring Context를 로드하긴 하지만 일부만 로드해 더 빠르고, 외부 의존성 등을 Mock으로 대체해서 사용하므로 단위 테스트로 생각하고 작성했습니다.

@WebMvcTest(value = NoticeController.class, excludeAutoConfiguration = ThymeleafAutoConfiguration.class)
class NoticeControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private NoticeService noticeService;

    @MockitoBean
    private MemberService memberService;

    @DisplayName("공지사항 list 페이지 뷰를 반환한다.")
    @Test
    void returnNoticeListView() throws Exception {
        // Arrange
        NoticeResponse response1 =
            createNoticeResponse("title 1", "content 1", LocalDateTime.of(2025, 1, 1, 0, 0, 0));
        NoticeResponse response2 =
            createNoticeResponse("title 2", "content 2", LocalDateTime.of(2025, 1, 1, 1, 0, 0));
        List<NoticeResponse> responses = List.of(response2, response1);

        given(noticeService.findAll()).willReturn(responses);
        
        // Act & Assert
        mockMvc.perform(get("/notices"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(view().name(VIEW_PATH + "/list"))
                .andExpect(model().attributeExists("notices"))
                .andExpect(model().attribute("notices", hasSize(2)));
    }
}

 

Controller의 HTTP 요청, 응답에 대해서 단위 테스트를 작성하기 위해 Thymeleaf 화면 렌더링 되는 부분을 @WebMvcTest(excludeAutoConfiguration = ThymeleafAutoConfiguration.class)로 제외시켰습니다.

그리고 인증/인가된 사용자만 허용하는 @PreAuthorize("isAuthenticated() and hasRole('ROLE_ADMIN')") 부분이 있는데 단위 테스트에서 인증, 인가를 테스트하기보단 통합 테스트에서 확인하도록 기준을 정하고 진행했습니다.

 

위와 같이 작성하고 테스트를 하면 실패가 떨어집니다.😢

실패 내용은 isOk()로 200으로 예상했지만 302로 login으로 리다이렉트 시키고 있습니다. Spring Security가 login으로 리다이렉트 시킨다고 예상됩니다. 그 이유는 다음과 같습니다.

By default, tests annotated with @WebMvcTest will also auto-configure Spring Security and MockMvc (include support for HtmlUnit WebClient and Selenium WebDriver).
Spring.io API Docs

 

@WebMvcTest 애너테이션을 사용하면 Spring Security을 자동으로 구성한다고 되어있습니다.

그래서 구성을 어떻게 하는지 확인하기 위해 디버깅을 통해 살펴보겠습니다.

@WebMvcTest@TypeExludeFilters(WebMvcTypeExcluderFilter.class)가 존재하고 WebMvcTypeExcluderFilter 클래스를 살펴보니 WebSecurityConfigurer, SecurityFilterChain을 옵션으로 포함하는 부분이 있었습니다.

 

Spring Security 의존성을 추가했었으니 SecurityAutoConfiguration 클래스에서 Spring Security 자동 구성을 해줍니다.

 

OAuth2를 사용하고 있어 DefaultSecurityFilterChainConfiguration 클래스가 아닌 OAuth2SecurityFilterChainConfiguration 클래스에서 기본으로 설정된 oauth2SecurityFilterChain으로 구성이 됩니다.

 

그래서 이미지와 같이 securityFilterChain에는 oauth2SecurityFilterChain을 참조하고 있는 것을 볼 수 있습니다.

 

그렇게 기본으로 구성된 oauth2SecurityFilterChain을 등록했기 때문에 모든 요청에 대해서 인증을 확인하고 인증이 안 됐으면 302 리다이렉트로 login 페이지로 보내고 있습니다.

1. Custom SecurityFilterChain

이에 대한 해결 방법은 커스텀한 securityFilterChain을 구현하고 @Import로 등록해 주면 됩니다.

@TestConfiguration
public class SecurityConfigTest {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authorizeRequests -> authorizeRequests
                        .requestMatchers("/**").permitAll()
                        .anyRequest().authenticated()
                )
                .oauth2Login(oauth -> oauth
                        .loginPage("/members/login").permitAll()
                )
                ...
        return http.build();
    }
}
@Import(SecurityConfigTest.class)
@WebMvcTest(value = NoticeController.class, excludeAutoConfiguration = ThymeleafAutoConfiguration.class)
class NoticeControllerTest {
    ...
}

 

@Import로 등록하면 구현한 securityFilterChain이 등록이 됩니다.

 

이미지와 같이 기본 oauth2SecurityFilterChain에서 구현한 securityFilterChain이 등록되는 것을 볼 수 있습니다.

그러면 상태가 200으로 응답되면서 테스트가 성공합니다.

2. Spring Security Auto Configuration 제외

@WebMvcTest(value = NoticeController.class,
        excludeAutoConfiguration = {
                ThymeleafAutoConfiguration.class, SecurityAutoConfiguration.class, OAuth2ClientAutoConfiguration.class
        })
class NoticeControllerTest {
    ...
}

 

코드와 같이 SecurityAutoConfiguration, OAuth2ClientAutoConfiguration을 제외시키면 테스트로 SecurityConfig 작성한 클래스를 @Import 안 해도 성공하게 됩니다. Controller Layer만을 대상으로 단위 테스트를 하기 때문에 Spring Security의 인가/인증 부분은 목적과 맞지 않다고 생각해서 제외시켰습니다.

 

단위 테스트에 대해서 알아보다가 지금까지 얘기한 내용 말고도 다른 방식으로 작성하는 방법도 알게 됐습니다.

해당 블로그 링크들 아래에 남깁니다.

 

나의 개발일지 - @WebMvcTest를 사용하지 않는 컨트롤러 테스트 작성하기

향로님 - @SpyBean @MockBean 의도적으로 사용하지 않기

 

통합 테스트(Integration Test) 작성

통합 테스트하는 방법도 여러 가지가 있겠지만 외부 의존성이 데이터베이스만 존재해서 Layer 간 상호작용을 검증하는 테스트를 작성했습니다. @SpringBootTest로 Spring Context를 전체 로드하도록 하고, MockMvc로 Controller, Service, Repository, Database 흐름이 잘 진행되는지 확인했습니다.

 

@SpringBootTest를 사용할 때 MockMvc는 의존성을 주입하지 않기 때문에 @AutoConfigureMockMvc를 같이 사용해야 합니다. 그리고 Slice Test에서 인증/인가를 검증하지 않은 부분을 통합 테스트에서 검증했습니다.

@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureMockMvc
class NoticeIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private NoticeRepository noticeRepository;

    @Autowired
    private MemberRepository memberRepository;

    private Member member;

    @BeforeEach
    void setUp() {
        member = Member.builder()
                .email("test@gmail.com")
                .name("name")
                .nickname("nickname")
                .providerType(NAVER)
                .providerId("test")
                .role(ROLE_ADMIN)
                .build();
        memberRepository.save(member);
    }

    @DisplayName("공지사항 리스트 페이지를 반환한다.")
    @Test
    void returnNoticeListView() throws Exception {
        // Arrange
        Notice notice1 = createNotice("title 1", "content 1");
        Notice notice2 = createNotice("title 2", "content 2");
        Notice notice3 = Notice.builder()
                .title("title 3")
                .content("content 3")
                .member(member)
                .isDeleted(true)
                .build();

        noticeRepository.saveAll(List.of(notice1, notice2, notice3));

        // Act & Assert
        ResultActions result = mockMvc.perform(get("/notices"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(view().name(VIEW_PATH + "/list"))
                .andExpect(model().attributeExists("notices"));

        List<NoticeResponse> response = getModelAttribute(result, "notices", List.class);

        assertThat(response).hasSize(2)
                .extracting(NoticeResponse::title, NoticeResponse::content)
                .containsExactly(
                        tuple(notice2.getTitle(), notice2.getContent()),
                        tuple(notice1.getTitle(), notice1.getContent()));
    }
    ...
    @WithMockMember
    @DisplayName("사용자 권한일 때 공지사항 등록 페이지 요청은 403 예외를 발생한다.")
    @Test
    void requestCreateViewUnAuthorizedRoleToErrorView() throws Exception {
        // Arrange

        // Act & Assert
        ResultActions result = mockMvc.perform(get("/notices/new"))
                .andDo(print())
                .andExpect(status().isForbidden())
                .andExpect(view().name("error"))
                .andExpect(model().attributeExists("error"));

        ErrorResponse response = getModelAttribute(result, "error", ErrorResponse.class);

        assertThat(response).extracting(ErrorResponse::status, ErrorResponse::message)
                .containsExactly(FORBIDDEN.value(), FORBIDDEN_ERROR_MESSAGE);
    }
}

 

Slice Test에서는 응답 데이터 값을 검증하지 않고 View Path, Model 이름, 객체의 필드가 존재하는지만 검증하고

통합 테스트에서 데이터베이스에서 가져온 데이터가 일치하는 확인 했고, 인증/인가는 @WithMockUser를 사용할 수 있지만 프로젝트의 맞게 애너테이션을 생성해서 @WithMockMember를 사용해서 확인했습니다.

E2E 테스트

E2E 테스트는 사용자 관점에서 시나리오 테스트를 작성한다고 합니다. 실제 애플리케이션을 End to End로 처음부터 끝까지 테스트하는 것입니다. E2E 테스트는 통합 테스트의 하위 범위에 속하는데, 애플리케이션이 구동이 되는 만큼 정확히 동작하는지 확인할 수 있어 신뢰도를 보장할 수 있습니다. 다만 그만큼 속도가 오래 걸리는 단점이 존재합니다.

 

E2E 테스트를 작성하기 위해 Rest Assured 라이브러리를 사용해 봤는데 Rest Assured는 Rest API를 단순히 테스트하는 Java DSL(Domain-specific language)입니다. given-when-then 패턴으로 가독성이 좋고 사용법이 매우 단순해서 사용해 봤습니다.

// rest-assured(java dsl)
testImplementation 'io.rest-assured:rest-assured'
testImplementation 'io.rest-assured:json-path'
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class NoticeApiRestAssuredE2ETest {

    @LocalServerPort
    private int port;

    @Autowired
    private NoticeRepository noticeRepository;

    @Autowired
    private MemberRepository memberRepository;

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
    }

    @AfterEach
    void tearDown() {
        noticeRepository.deleteAllInBatch();
        memberRepository.deleteAllInBatch();
    }

    @DisplayName("e2e 공지사항 전체 데이터를 조회한다.")
    @Test
    void findAll() {
        // Arrange
        ...

        Notice notice1 = createNotice("title 1", "content 1", member);
        Notice notice2 = createNotice("title 2", "content 2", member);
        Notice notice3 = Notice.builder()
            .title("title 3")
            .content("content 3")
            .member(member)
            .isDeleted(true)
            .build();

        noticeRepository.saveAll(List.of(notice1, notice2, notice3));

        // Act & Assert
       RestAssured.given()
               .when()
               .get("/api/notices")
               .then()
               .statusCode(HttpStatus.OK.value())
               .body("$", hasSize(2))
               .body("[0].title", equalTo("title 2"))
               .body("[0].content", equalTo("content 2"));

        List<Notice> notices = noticeRepository.findAllByIsDeletedFalseOrderByCreatedAtDescIdDesc();
        assertThat(notices).hasSize(2)
                .extracting(Notice::getTitle, Notice::getContent)
                .containsExactly(
                        tuple(notice2.getTitle(), notice2.getContent()),
                        tuple(notice1.getTitle(), notice1.getContent()));
    }
	...
}

 

 

 

🔗 Reference