hola 개발

[ Test ] 제대로 익히는 Spring Boot 테스트 방법 #2. 각 계층 단위 테스트 또는 슬라이스 테스트 본문

프레임워크/스프링

[ Test ] 제대로 익히는 Spring Boot 테스트 방법 #2. 각 계층 단위 테스트 또는 슬라이스 테스트

hola. 2025. 7. 10. 09:55

# 예제 코드들은 글 하단에 있습니다.

# 테스트에 대해 처음 접하는 분들은 아래 글을 읽고 오시는 것을 추천합니다.

https://do-it-zero.tistory.com/57

 

[ Test ] 제대로 익히는 Spring 테스트 방법 #1. 기본

## 스프링 애플리케이션 테스트 코드는 글 마지막에 있습니다 ##- 왜 써야하는가? 개발을 하면서 어떤 기술이나 방법론을 배울 때는 왜 써야하는지? 에 대한 질문이 중요한 것 같습니다. 왜냐하면

do-it-zero.tistory.com

 

[ 들어가며 ]

지난 글에서 테스트 유형, 테스트 프레임워크 등에 대한 글을 썼습니다.

이번 글에서는 Spring 애플리케이션의 각 계층인 Controller, Service, Repository 차례대로 어떻게 테스트를 진행할지에 대한 내용입니다.

[ 명명 규칙 ]

테스트 메서드 이름은 그 의도를 명확하게 전달해야 합니다. 

왜냐하면 다른 사람들이 테스트를 이해하는 데 도움을 주며, 추후 다시 방문할 때 유용하기 때문입니다.

회사마다 명명 규칙은 다르겠지만, 이번 글에서 명명 규칙은 다음과 같이 하겠습니다. 

 

should[Action]When[Condition]

 

예를 들어, 회원가입 시 회원 id를 반환하는 테스트를 진행한다면

-> shouldReturnUserIdWhenJoinSuccess() 라고 테스트 이름을 짓도록 하겠습니다.

 

[ 각 계층 테스트 ]

1.Controller 단위 테스트 -> 슬라이스 테스트 

단위 테스트는 다른 프레임워크를 사용하지 않고 순수 자바로 로직을 검증하는 것입니다.

다음은 Controller를 순수 자바로 로직을 검증하는 코드입니다.

@Test
void 컨트롤러_단위_테스트() {
    MemberService mockService = mock(MemberService.class);
    when(mockService.join(any())).thenReturn(1L);
    
    MemberController controller = new MemberController(mockService);
    
    ResponseEntity<?> response = controller.join(new MemberRequest("test"));
    
    assertEquals(HttpStatus.CREATED, response.getStatusCode());
}

 

위와 같이 다른 프레임워크 없이 Controller 단위 테스트를 진행할 수 있습니다.

하지만 위와 같은 테스트는 Spring의 DispatcherServlet의 흐름을 거치지 않는다는 문제가 있습니다. 

Controller라는 특성 상 DispatcherServlet에 탑재되어 동작하기 때문에  Spring의 실제 동작 흐름에 맞춰서 테스틀 할 수 있어야 합니다. 또한 테스트 특성 상 HTTP 요청을 만들어야 하기도 합니다.

 

이를 도와주는 도구들을 알아보고 해당 도구를 활용한 테스트 코드를 작성해보겠습니다..

 

- @WebMvcTest

@WebMvcTest는 Spring MVC 구성 요소 중에서 컨트롤러 계층만 테스트할 수 있도록 설정된 슬라이스 테스트입니다.

즉, 전체 애플리케이션 컨텍스트를 띄우는 것이 아니라, 웹 계층(Spring MVC)에 관련된 Bean들만 로드합니다.

대상 @Controller, @ControllerAdvice 등
목적 웹 요청/응답 흐름 테스트
로딩 Bean Spring MVC 관련 Bean만 (예: Controller, @JsonComponent, @ControllerAdvice 등)
제외 대상 @Service, @Repository, @Component 등은 자동으로 로드되지 않음
Mock 필요한 Bean은 @MockBean을 이용해 주입

 

- MockMvc

MockMvc는 Spring에서 제공하는 웹 계층 테스트 도구로, 실제로 서버를 실행하지 않고도 컨트롤러에 HTTP 요청을 보내고 응답을 검증할 수 있게 해줍니다.

목적 서버 없이 Controller 테스트
테스트 대상 DispatcherServlet → Filter → Controller → Response 까지의 흐름
실제 요청 X 네트워크 없이 내부적으로 요청 시뮬레이션
Http 요청 get(), post(), put(), delete() 등 사용 가능
응답 검증 status(), jsonPath(), content() 등으로 가능

 

-@MockitoBean

Spring Test 컨텍스트에 가짜 Bean을 등록해줍니다.

기존 빈을 **대체(override)**하여 테스트에서 원하는 동작을 지정할 수 있게 합니다.

주로 @WebMvcTest, @SpringBootTest 등에서 의존성을 주입해야 하지만 실제 빈을 사용하고 싶지 않을 때 사용합니다.

복잡한 의존성을 가진 클래스에서도 일부만 목 처리하여 부분 통합 테스트 가능하게 합니다.

예를 들어 컨트롤러 테스트 시, 서비스 레이어를 MockBean으로 대체해 독립적인 테스트를 하거나, 실제 DB 연결 없이도 레포지토리 계층 MockBean으로 대체해 테스트를 가능하게 합니다.

이 때 MockBean으로 대체된 경우 그 결과값을 명시해줘야 합니다.(아래의 예시에 memberService의 결과값을 명시한 부분 참고)

 

이러한 도구들을 사용하여  MemberController의 join 메서드를 슬라이스 테스트 예시 입니다.

@WebMvcTest(MemberController.class) // Spring Mvc Test Context에 등록
class MemberControllerTest {
    /** MockMvc 사용이유
     * 실제 서버를 띄우지 않고도 HTTP 요청과 응답을 시뮬레이션할 수 있게 해주기 때문
     * @WebMvcTest(MemberController.class) 으로 등록된 MemberController에 HTTP 요청 시물레이션
     * */
    @Autowired
    private MockMvc mockMvc;

    /** @MockitoBean 사용이유
     * MemberService 타입의 mock을 만들어서 MemberController에 주입
     * 이렇게 하는 이유는 단위 테스트는 외부 의존성을 제거하고 로직을 검증해야 하기 때문
     * */
    @MockitoBean
    private MemberService memberService;

    /** Jackson의 JSON 직렬화 하는 이유
     *  컨트롤러에서 @RequestBody에 해당하는 요청본문을 역질렬화 하기 때문
     */
    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("MemberController join 테스트")
    void shouldReturnUserIdWhenJoinSuccess() throws Exception {
        // given
        MemberDto memberDto = new MemberDto("test");
        Member member = memberDto.toEntity();
        String json = objectMapper.writeValueAsString(memberDto);

        // mock 으로 구현된 memberService의 join시의 결과를 명시해줌
        Mockito.when(memberService.join(Mockito.any(MemberDto.class)))
                .thenReturn(member.getId());

        mockMvc.perform(post("/members")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(json)
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(member.getId()));
    }
}

2. Service 단위 테스트

현재 예제 코드의 Service는 Controller 와 달리 HTTP 환경이 필요하지 않습니다.

 

join 로직에 memberRepository가 사용됩니다.

따라서 join 단위 테스트시에는 memberRepository는 mock 객체로 만들고 save시 어떤 타입을 리턴할지 명시해주면 됩니다.

public String join(MemberDto memberDto) {
    validateDuplicateMember(memberDto);
    return memberRepository.save(memberDto.toEntity()).getId();
}

private void validateDuplicateMember(MemberDto memberDto) {
    memberRepository.findByName(memberDto.getName())
            .ifPresent(m -> {
                throw new IllegalStateException("이미 존재하는 회원입니다.");
            });
}

-@Mock

테스트 대상 클래스가 Repository, Service, HttpClient, 외부 API 등을 의존할 때 이 의존 객체들을 실제로 만들지 않고, @Mock으로 대체할 때 사용됩니다.

정의 가짜 객체(Mock)를 만들어주는 어노테이션
동작 Mockito가 memberRepository의 동작을 흉내냄
효과 의존성 격리, 단위 테스트에 집중
주의 @ExtendWith(MockitoExtension.class)와 함께 사용해야 동작

 

-@InjectMocks

@Mock 객체들을 주입해서 테스트 대상 객체 생성해줍니다. 즉, 테스트 할 대상 클래스에 사용되는 어노테이션입니다.

지금 테스트에서는 memberServie가 대상 객체이고 memberRepository가 의존 객체입니다.

 

-@ExtendWith(MockitoExtension.class)

JUnit 5에서 Mockito 기능을 활성화해주는 어노테이션입니다.
테스트 클래스에 붙이면 Mockito 관련 어노테이션(@Mock, @InjectMocks, 등)이 자동으로 동작하게 됩니다.

 

해당 도구들을 활용하여 memberService의 join 단위 테스트입니다.

join시 기존 회원이 존재하는지 확인하는 기능이 있으므로 , 해당 기능이 작동하는지에 대한 테스트는 따로 진행하였습니다.

@ExtendWith(MockitoExtension.class)
class MemberServiceTest {

    @Mock
    private MemberRepositoryJpa memberRepository;

    @InjectMocks
    private MemberService memberService;

    @Test
    @DisplayName("join시 name 중복 테스트")
    void validateDuplicateJoin(){
        // given
        MemberDto memberDto = new MemberDto("test");
        Member member = memberDto.toEntity();

        when(memberRepository.findByName(member.getName())).thenReturn(Optional.of(member));

        // when & then
        IllegalStateException exception = assertThrows(IllegalStateException.class, () -> memberService.join(memberDto));

        // 예외 메세지 검증
        assertEquals("이미 존재하는 회원입니다.",exception.getMessage());

        // save() 호출 되지 않았는지 검증
        Mockito.verify(memberRepository,Mockito.never()).save(any());
    }

    @Test
    @DisplayName("join 테스트")
    void join() {
        // given
        MemberDto memberDto = new MemberDto("test1");
        Member member = memberDto.toEntity();

        when(memberRepository.save(any(Member.class))).thenReturn(member);

        // when
        String savedId = memberService.join(memberDto);

        // then
        Assertions.assertNotNull(savedId);
    }
}

3. Repository 단위 테스트 -> 슬라이스 테스트

이 쯤에서 다시 한번 단위 테스트의 주요 목적을 생각해봐야 합니다. 

단위 테스트의 목적은 메서드나 클래스와 같은 단일 "단위"의 작업에 집중하고, 외부 시스템(데이터베이스 등) 과는 독립적으로 그 로직을 테스트 하는 것입니다.

그렇기에 외부 DB와 연동된 Repository를 정확하게 테스트 하기 위해서는 단위 테스트 보다는 슬라이스 테스트나 통합 테스트를 진행하는 것이 합리적이라고 생각합니다.

 

-@DataJpaTest

Spring Boot에서 JPA Repository만 슬라이스 테스트(slice test) 하도록 도와주는 어노테이션입니다.
실제 DB가 아니라 보통 H2 인메모리 DB로 테스트되며, JPA Repository의 동작(쿼리, 저장, 조회 등)을 검증할 때 사용합니다.

@DataJpaTest
class MemoryMemberRepositoryTest {
    @Autowired
    private MemberRepositoryJpa memberRepository;

    @Test
    void shouldReturnMemberWhenSaveSuccess() {
        // given
        MemberDto memberDto = new MemberDto("test");
        Member member = new Member(memberDto.getName());
        // when
        Member savedMember = memberRepository.save(member);

        // then
        assertEquals(member.getName(),savedMember.getName());
    }
}

[ 정리 ]

각 계층에 대한 테스트 방법을 정리하자면 다음과 같습니다.

계층 테스트 방법 목적  Spring Context
Controller @WebMvcTest + MockMvc HTTP 흐름 테스트 일부 (슬라이스)
Service Mockito로 단위 테스트 비즈니스 로직 검증 ❌ 필요 없음
Repository @DataJpaTest DB 연동 검증 ✅ 필요 (슬라이스)

 

[ 예제 코드 ]

java : 17

spring boot : 3.5.3

build tool : maven

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <dependency>
        <groupId>io.rest-assured</groupId>
        <artifactId>rest-assured</artifactId>
        <version>5.4.0</version>
        <scope>test</scope>
    </dependency>

    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
</dependencies>

 

- dto

public class MemberDto {
    private String name;

    public MemberDto(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Member toEntity() {
        return new Member(this.name);
    }
}

 

-entity

@Entity
@Table(name = "members")
public class Member {
    @Id
    private String id;

    @Column
    private String name;

    public Member() {
    }

    // 생성자, getter
    public Member(String name) {
        this.id = IdGenerator.generateId();
        this.name = name;
    }

    public String getId() { return id; }

    public String getName() { return name; }
}

 

-controller

@RestController
@RequestMapping("/members")
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @PostMapping
    public ResponseEntity<?> join(@RequestBody MemberDto memberDto) {
        String id = memberService.join(memberDto);
        return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("id", id));
    }

    @GetMapping("/{id}")
    public ResponseEntity<Member> get(@PathVariable String id) {
        return memberService.findOne(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

 

-service

@Service
public class MemberService {
    private final MemberRepositoryJpa memberRepository;

    public MemberService(MemberRepositoryJpa memberRepository) {
        this.memberRepository = memberRepository;
    }

    public String join(MemberDto memberDto) {
        validateDuplicateMember(memberDto);
        return memberRepository.save(memberDto.toEntity()).getId();
    }

    private void validateDuplicateMember(MemberDto memberDto) {
        memberRepository.findByName(memberDto.getName())
                .ifPresent(m -> {
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }

    public Optional<Member> findOne(String id) {
        return memberRepository.findById(id);
    }

    public List<Member> findAll() {
        return memberRepository.findAll();
    }
}

 

-repository

@Repository
public interface MemberRepositoryJpa extends JpaRepository<Member,String> {
    Optional<Member> findById(String id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
}