우와한 개발자

[Spring] Spring Boot 테스트 코드 작성하기 – JUnit5, Mockito, MockMvc 구현

by 우와한개발자

1. 테스트 코드를 작성하는 이유

  • 기능을 개발한 뒤 동작 여부를 확인하려면 보통 서버를 켜고 Postman으로 직접 요청을 보냄.
  • 그런데 이 방식은 매번 서버를 켜야 하고, 코드를 수정했을 때 기존 기능이 깨졌는지 일일이 확인하기 어려움.
  • 테스트 코드를 작성하면 서버를 켜지 않아도 자동으로 검증할 수 있고, 테스트 자체가 코드의 동작을 설명하는 문서 역할도 함.

 

2. 테스트 종류

테스트 종류
    ├── 단위 테스트  (@ExtendWith(MockitoExtension.class))
    │       └── 하나의 클래스만 독립 테스트, Spring 없이 실행 → 매우 빠름
    │           ex) DashboardServiceTest
    │
    ├── 슬라이스 테스트  (@WebMvcTest)
    │       └── Controller 레이어만 Spring 로드 → 빠름
    │           ex) AdminControllerTest, MemberViewControllerTest
    │
    └── 통합 테스트  (@SpringBootTest)
            └── 전체 Spring 컨텍스트 로드, 실제 DB 연결 → 느림
                ex) DashboardCachePerformanceTest

 

3. 의존성

  • spring-boot-starter-test 하나만 추가하면 JUnit 5, Mockito, AssertJ, MockMvc가 모두 포함됨
  • <scope>test</scope>로 선언하면 테스트 실행 시에만 사용되고, 실제 빌드 결과물(jar)에는 포함되지 않음.
<!-- JUnit5 + Mockito + AssertJ + MockMvc 전부 포함 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

<!-- Spring Security를 사용하는 경우 추가 -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>
라이브러리 역할
JUnit 5 테스트 실행 및 관리 (뼈대)
Mockito 가짜 객체(Mock) 생성
AssertJ 결과값 검증
MockMvc HTTP 요청/응답 테스트
spring-security-test @WithMockUser 등 Security 테스트 지원

 

4. JUnit 5 : 테스트의 뼈대

  • 테스트를 실행하고 관리하는 프레임워크. 어떤 메서드가 테스트인지 선언하고, 실행 순서를 제어하며, 결과를 보여주는 역할.
어노테이션 실행 시점 설명
@Test - 이 메서드가 테스트임을 선언
@BeforeEach 각 테스트 전 테스트마다 공통 초기화
@AfterEach 각 테스트 후 테스트마다 공통 정리
@BeforeAll 전체 시작 전 1번 클래스 전체에서 한 번만 실행
@DisplayName - 테스트 이름을 한글로 표시
@Disabled - 해당 테스트를 임시 비활성화

 

5. AssertJ : 결과 검증 담당

  • 테스트 결과가 기대한 값과 일치하는지 검증하는 라이브러리.
  • JUnit에도 assertEquals 같은 검증 메서드가 있지만 AssertJ는 영어 문장처럼 읽혀서 가독성이 훨씬 좋음.
// JUnit 방식 — 순서가 헷갈리고 읽기 어려움
assertEquals(5, result);

// AssertJ 방식 — 자연스러운 영어 문장처럼 읽힘
assertThat(result).isEqualTo(5);
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

// 값 비교
assertThat(result).isEqualTo(5);
assertThat(name).isEqualTo("홍길동");

// null 여부
assertThat(result).isNotNull();

// 리스트 검증
assertThat(list).hasSize(3);
assertThat(list).containsExactly("2024-01", "2024-02");
assertThat(list).isEmpty();

// 예외 발생 검증
assertThatThrownBy(() -> service.getById(-1L))
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessageContaining("존재하지 않는");

 

6. Mockito : 가짜 객체(Mock) 생성 담당

  • DashboardService를 테스트하려면 DashboardRepository가 필요하고, DashboardRepository는 DB가 있어야 동작함.
  • 테스트할 때마다 DB에 연결하면 느리고 테스트 데이터가 DB에 쌓이는 등 여러 문제가 생김.
  • 이 문제를 해결하기 위해 Repository를 가짜(Mock)로 교체.
실제 구조:  DashboardService → DashboardRepository → Oracle DB
테스트 구조: DashboardService → Mock Repository (DB 없음, 가짜 반환값)
@ExtendWith(MockitoExtension.class)  // Mockito 활성화
class DashboardServiceTest {

    @Mock          // 가짜 Repository 생성 (DB 연결 없음)
    DashboardRepository dashboardRepository;

    @InjectMocks   // 가짜 Repository를 Service에 자동 주입
    DashboardService dashboardService;

    @Test
    void KPI_조회_성공() {
        // given: 가짜 Repository가 반환할 값 미리 설정
        BikeKpi fakeKpi = new BikeKpi();
        fakeKpi.setTotalUseCnt(1000L);

        when(dashboardRepository.selectLatestKpi())  // "이 메서드가 호출되면"
            .thenReturn(fakeKpi);                    // "이 값을 반환해"

        // when
        BikeKpiSummaryResponseDto result = dashboardService.getKpi();

        // then
        assertThat(result.getTotalUseCnt()).isEqualTo(1000L);
    }
}
// 반환값 설정
when(mock.메서드()).thenReturn(반환값);

// 예외 발생 설정
when(mock.메서드()).thenThrow(new RuntimeException("오류"));

// 이 메서드가 실제로 호출됐는지 검증
verify(mock).메서드();
verify(mock, times(2)).메서드();  // 2번 호출됐는지
verify(mock, never()).메서드();   // 한 번도 호출 안 됐는지

 

7. MockMvc : Controller HTTP 테스트 담당

  • 실제 서버를 켜지 않고도 HTTP 요청을 테스트할 수 있게 해주는 도구.
  • GET /api/v1/dashboard/kpi를 호출했을 때 200이 오는지, 응답 JSON이 올바른지 등을 검증할 수 있음.
  • @WebMvcTest는 Controller 레이어만 Spring에 로드하고 Service는 Mock으로 대체하기 때문에 @SpringBootTest보다 빠르게 실행됨.

 

8. 테스트 구현

1) @Mock vs @MockBean 차이

  • Spring을 띄우지 않는 단위 테스트에서는 @Mock 사용
  • Spring 컨텍스트를 사용하는 슬라이스/통합 테스트에서는 @MockBean 사용
어노테이션 사용 위치 설명
@Mock @ExtendWith(MockitoExtension.class) 순수 Mockito 환경에서 가짜 객체 생성
@MockBean @WebMvcTest, @SpringBootTest Spring 컨텍스트에 가짜 빈으로 등록

 

2) AdminControllerTest : 슬라이스 테스트 (권한 검증)

  • Controller가 올바른 뷰를 반환하는지, 권한이 없을 때 403이 오는지 검증.
  • @WithMockUser로 특정 Role을 가진 가짜 사용자를 주입해서 권한 분기를 테스트 가능.
@WebMvcTest(AdminController.class)
@Import({ SecurityConfig.class, AdminControllerTest.TestConfig.class })
public class AdminControllerTest {

    @TestConfiguration
    @EnableMethodSecurity
    static class TestConfig { }

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private IAdminService adminService;

    @MockitoBean
    private MemberAuthService memberAuthService;

    @MockitoBean
    private JwtTokenProvider jwtTokenProvider;

    @MockitoBean
    private JwtCookieUtils jwtCookieUtils;

    @Test
    @WithMockUser(username = "adminUser", roles = { "ADMIN" })
    @DisplayName("관리자 마이페이지 접근 테스트 - 권한이 있는 경우")
    void adminMyPageAccessSuccess() throws Exception {
        // given
        Member mockMember = Member.builder()
                .userId(1L)
                .loginId("adminUser")
                .userName("관리자")
                .build();

        AdminDashboardDto mockDashboardData = new AdminDashboardDto();
        mockDashboardData.setAdminName("관리자");
        mockDashboardData.setPendingCount(5);
        // ...

        given(memberAuthService.getMember("adminUser")).willReturn(mockMember);
        given(adminService.getDashboardData(1L, 0, 10)).willReturn(mockDashboardData);

        // when & then
        mockMvc.perform(get("/admin/mypage"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(view().name("admin/mypage"))
                .andExpect(model().attribute("adminName", "관리자"))
                .andExpect(model().attribute("pendingCount", 5))
                .andExpect(model().attribute("activeMenu", "mypage"));
    }

    @Test
    @WithMockUser(username = "normalUser", roles = { "USER" })
    @DisplayName("관리자 마이페이지 접근 테스트 - 권한이 없는 경우(403)")
    void adminMyPageAccessDenied() throws Exception {
        mockMvc.perform(get("/admin/mypage"))
                .andDo(print())
                .andExpect(status().isForbidden());  // 403 확인
    }
}

 

 

3) MemberViewControllerTest — 슬라이스 테스트 (리디렉션 검증)

  • 로그인/비로그인 상태에 따라 올바른 페이지로 이동하는지 검증
@WebMvcTest(MemberViewController.class)
@Import(SecurityConfig.class)
class MemberViewControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockitoBean
    MemberAuthService memberAuthService;

    @MockitoBean
    IUploadFileService uploadFileService;

    @Test
    @DisplayName("GET /login - 비로그인 사용자 → 로그인 페이지 반환")
    void loginPage_비로그인_로그인페이지() throws Exception {
        mockMvc.perform(get("/login"))
                .andExpect(status().isOk())
                .andExpect(view().name("member/login"));
    }

    @Test
    @WithMockUser
    @DisplayName("GET /login - 로그인 사용자 → 대시보드로 리디렉션")
    void loginPage_로그인_대시보드리디렉션() throws Exception {
        mockMvc.perform(get("/login"))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/dashboard"));
    }

    @Test
    @WithMockUser(username = "testUser")
    @DisplayName("GET /mypage - 로그인 사용자 → 마이페이지 반환")
    void myPage_로그인_마이페이지() throws Exception {
        Member fakeMember = Member.builder()
                .userId(1L)
                .loginId("testUser")
                .userName("홍길동")
                .email("test@test.com")
                .build();

        when(memberAuthService.getMember("testUser")).thenReturn(fakeMember);
        when(uploadFileService.countFilesByUploaderId(1L)).thenReturn(0);
        when(uploadFileService.getFilesByUploaderIdPaged(1L, 0, 5)).thenReturn(List.of());

        mockMvc.perform(get("/mypage"))
                .andExpect(status().isOk())
                .andExpect(view().name("mypage/user-mypage"))
                .andExpect(model().attribute("userName", "홍길동"))
                .andExpect(model().attribute("userEmail", "test@test.com"));
    }

    @Test
    @DisplayName("GET /mypage - 비로그인 사용자 → 로그인 페이지로 리디렉션")
    void myPage_비로그인_로그인리디렉션() throws Exception {
        mockMvc.perform(get("/mypage"))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrlPattern("**/login"));
    }
}

4) DashboardCachePerformanceTest : 통합 테스트 (캐시 성능 검증)

  • @SpringBootTest로 전체 Spring 컨텍스트를 로드해서 실제 DB 조회와 캐시 반환 시간을 비교.
@SpringBootTest
class DashboardCachePerformanceTest {

    @Autowired
    private DashboardService dashboardService;

    @Autowired
    private CacheManager cacheManager;

    @BeforeEach
    void clearCache() {
        // 테스트 간 캐시 초기화 (이전 테스트 결과가 영향을 주지 않도록)
        cacheManager.getCacheNames().forEach(name -> {
            var cache = cacheManager.getCache(name);
            if (cache != null) cache.clear();
        });
    }

    @Test
    @DisplayName("[캐시 성능 비교] KPI 요약")
    void compareKpiPerformance() {
        comparePerformance("KPI 요약", () -> dashboardService.getKpi());
    }

    private void comparePerformance(String label, Runnable callable) {
        long s1 = System.nanoTime();
        callable.run();                           // 1차 호출 : DB 조회 (Cache Miss)
        long noCacheNs = System.nanoTime() - s1;

        long s2 = System.nanoTime();
        callable.run();                           // 2차 호출 : 캐시 반환 (Cache Hit)
        long cachedNs = System.nanoTime() - s2;

        double ratio = cachedNs > 0 ? (double) noCacheNs / cachedNs : 0;
        System.out.printf(
            "[%s] 1차(DB): %.3f ms | 2차(Cache): %.3f ms | 속도향상: %.2f배%n",
            label,
            noCacheNs / 1_000_000.0,
            cachedNs / 1_000_000.0,
            ratio
        );
    }
}

 

9. Given-When-Then 패턴

  • 모든 테스트는 Given-When-Then 3단계로 작성하는 것이 표준
  • 코드만 봐도 무엇을 테스트하는지 바로 파악할 수 있어서 팀 협업 시 유용.
Given (준비): 테스트에 필요한 데이터와 Mock 설정
When  (실행): 실제로 테스트할 메서드 호출
Then  (검증): 결과가 기대한 값과 일치하는지 확인
@Test
@DisplayName("월별 통계 조회 시 month, useCount 리스트를 반환한다")
void getMonthlySummary_성공() {
    // given
    List<BikeMonthlySummary> fakeList = List.of(
        createSummary("2024-01", 5000L),
        createSummary("2024-02", 6000L)
    );
    when(dashboardRepository.selectMonthlySummaryList()).thenReturn(fakeList);

    // when
    BikeMonthlySummaryResponseDto result = dashboardService.getMonthlySummary();

    // then
    assertThat(result.getMonthList()).hasSize(2);
    assertThat(result.getMonthList()).containsExactly("2024-01", "2024-02");
    assertThat(result.getUseCountList()).containsExactly(5000L, 6000L);
}

 

10. CI에서 테스트 자동화

  • DB 연결이 필요한 통합 테스트는 CI 환경에서 실행 불가능하기 때문에, GitHub Actions에서는 해당 테스트를 제외하고 실행.
  • !클래스명으로 특정 테스트를 제외하고, 나머지 단위/슬라이스 테스트만 CI에서 자동으로 실행되도록 구성.
# .github/workflows/maven.yml
- name: Build and Test with Maven
  run: ./mvnw test -Dtest="!MyddareungiApplicationTests,!DashboardCachePerformanceTest"

 

 

블로그의 정보

우와한개발자 님의 블로그

우와한개발자

활동하기