🎯 목표 : Spring 의 단위 테스트 작성의 필요성 이해와 테스트 코드 작성
📒 단위 테스트와 통합 테스트
📌 단위 테스트
- 단위테스트는 하나의 모듈을 기준으로 독립적으로 진행되는 가장 작은 단위의 테스트다.
- 하나의 모듈이란 각 계층에서의 하나의 기능 또는 메소드로 이해할 수 있다.
- 하나의 기능이 올바르게 동작하는지를 독립적으로 테스트하는 것이다.
📌 단위 테스트의 필요성
- 일반적으로 테스트 코드를 작성한다고 하면 거의 단위 테스트를 의미한다.
- 통합 테스트는 실제 여러 컴포넌트들 간의 상호작용을 테스트 하기 때문에 모든 컴포넌트들이 구동된 상태에서 테스트를 하게 되므로, 캐시나 데이터베이스 등 다른 컴포넌트들과 실제 연결을 해야하고 어플리케이션을 구성하는 컴포넌트들이 많아 질수록 테스트를 위한 시간이 커진다.
- 하지만, 단위 테스트는 테스트하고자 하는 부분만 독립적으로 테스트를 하기 때문에 해당 단위를 유지 보수 또는 리팩토링 하더라도 빠르게 문제 여부를 확인 할 수 있다.
📌 단위 테스트의 한계
- 일반적으로 어플리케이션은 하나의 기능을 처리하기 위해 다른 객체들과 데이터를 주고 받는 복잡한 통신이 일어난다.
- 단위 테스트는 해당 기능에 대한 독립적인 테스트기 때문에 다른 객체와 데이터를 주고 받는 경우에 문제가 발생한다.
- 그래서, 이 문제를 해결하기 위해 테스트하고자 하는 기능과 연관된 모듈에서 가짜 데이터, 정해진 반환값이 필요하다.
- 즉 단위 테스트에서는, 테스트 하고자 하는 기능과 연관된 다른 모듈은 연결이 단절 되어야 비로소 독립적인 단위 테스트가 가능해 진다.
📌 단위 테스트의 특징
- 좋은 테스트 코드란, 계속해서 변하는 요구사항에 맞춰 변경된 코드는 버그의 가능성을 항상 내포하고 있으며, 이를 테스트 코드로 검증함으로써 해결할 수 있어야 한다.
- 실제 코드가 변경되면 테스트 코드도 변경이 필요할 수 있으며, 테스트 코드 역시 가독성 있게 작성하여 일관된 규칙과 일관된 목적으로 테스트 코드를 작성 해야한다.
- FIRST 규칙
- Fast : 테스트는 빠르게 동작하고 자주 가동 해야한다.
- Independent : 각각의 테스트는 독립적어이야 하며, 서로에 대한 의존성은 없어야 한다.
- Repeatable : 어느 환경에서도 반복이 가능해야 한다.
- Self-Validating : 테스트는 성공 또는 실패 값으로 결과를 내어 자체적으로 검증 되어야 한다.
- Timely : 테스트는 테스트 하려는 실제 코드를 구현하기 직전에 구현 해야한다.
📌 통합 테스트
- 모듈을 통합하는 과정에서 모듈 간 호환성을 확인하기 위한 테스트다.
- 다른 객체들과 데이터를 주고받으며 복잡한 기능이 수행 될때, 연관된 객체들과 올바르게 동작하는지 검증하고자 하는 테스트다.
- 독립적인 기능보다 전체적인 연관 기능과 웹 페이지로 부터 API를 호출하여 올바르게 동작하는지 확인한다.
📒 단위 테스트 작성
📌 테스트 코드 작성 공통 준수 사항
- 보통 테스트를 위한 라이브러리로 JUnit과 AssertJ 조합을 사용하여 테스트를 한다.
- Given/When/Then 패턴
- Given : 어떠한 데이터가 주어질 때.
- When : 어떠한 기능을 실행하면.
- Then : 어떠한 결과를 기대한다.
@Test
@DisplayName("Test")
void test() {
}
📌 Mockito를 사용한 단위 테스트
- 모키토는, 개발자가 동작을 직접적으로 제어할 수 있는 가짜 객체를 지원하는 테스트 프레임웍이다.
- Spring 어플리케이션은 여러 객체들 간의 의존성이 생기는데 이러한 의존성을 모키토를 이용하여 단절 시킴으로 단위 테스트를 쉽게 작성하는 것을 도와준다.
- 앞으로 작성할 예제 테스트 에서는 모키토와 JUnit5의 조합으로 테스트 코드를 작성할 것 이다.
@RestController
@RequestMapping("/members")
@Validated
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
private final MemberMapper mapper;
@PostMapping
public ResponseEntity postMember(@Valid @RequestBody MemberDto.Post requestBody) {
Member member = mapper.memberPostToMember(requestBody);
member.setStamp(new Stamp());
Member createdMember = memberService.createMember(member);
MemberDto.response response = mapper.memberToMemberResponse(createdMember);
return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.CREATED);
}
}
@Transactional
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
public Member createMember(Member member) {
Member savedMember = memberRepository.save(member);
verifyMemberByEmail(member.getEmail());
return savedMember;
}
private void verifyMemberByEmail(String email) {
Optional<Member> findMember = memberRepository.findByEmail(email);
if (findMember.isPresent())
throw new ServiceLogicException(ErrorCode.MEMBER_EXISTS);
}
}
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
}
- 위 코드의 컨트롤러와 서비스, 레포지토리 테스트 한다고 가정한다.
📌 컨트롤러 계층 단위 테스트
- 컨트롤러의 단위 테스트를 하기 위해서 Mockito를 이용하여 다른 계층과 의존관계를 단절 시켜 주어야한다.
- 컨트롤러가 의존하고 있는 객체는
MemberService
와 MemberMapper
객체다.
- 컨트롤러를 테스트 하기 위해서는 HTTP 호출이 필요하다. 스프링 부트는 컨트롤러 테스트를 위한 @WebMvcTest 어노테이션을 제공한다.
- 이를 이용하면 MockMvc 객체가 자동으로 생성될 뿐만 아니라 테스트에 필요한 요소들을 빈으로 등록해 스프링 컨텍스트 환경을 구성 해 준다.
@WebMvcTest(MemberController.class)
@MockBean(JpaMetamodelMappingContext.class)
public class MemberControllerRestDocsTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private MemberService memberService;
@MockBean
private MemberMapper mapper;
@Autowired
private Gson gson;
@Test
@DisplayName("POST 회원 등록 컨트롤러 로직 확인")
public void postMemberTest() throws Exception {
MemberDto.Post post = new MemberDto.Post("hgd@gmail.com", "홍길동", "010-1234-5678");
String content = gson.toJson(post);
MemberDto.response responseDto =
new MemberDto.response(1L,
"hgd@gmail.com",
"홍길동",
"010-1234-5678",
Member.MemberStatus.MEMBER_ACTIVE,
new Stamp());
given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class))).willReturn(new Member());
given(memberService.createMember(Mockito.any(Member.class))).willReturn(new Member());
given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(responseDto);
ResultActions actions =
mockMvc.perform(
post("/members")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(content)
);
actions
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.email").value(post.getEmail()))
.andExpect(jsonPath("$.data.name").value(post.getName()))
.andExpect(jsonPath("$.data.phone").value(post.getPhone()));
}
}
- 컨트롤러는
MemberService
와 MemberMapper
객체에 의존하고 있기 때문에 @MockBean
어노테이션을 사용하여 가짜 객체를 생성해준다.
@MockBean
private MemberService memberService;
@MockBean
private MemberMapper mapper;
- 컨트롤러의 로직에서 의존하고 있는 객체의 사용되는 메소드들의 반환값을 지정해 준다.
given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class))).willReturn(new Member());
given(memberService.createMember(Mockito.any(Member.class))).willReturn(new Member());
given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(responseDto);
- given(의존관계에 있는 객체의 메소드(파라미터)).willReturn(반환 값)
- 어떠한 파라미터가 의존관계에 있는 객체의 메소드에 삽입되더라도 항상 반환값은 지정한대로 같은 값만 반환되어 나온다 라는 설정을 해 줌으로, 컨트롤러가 의존하고 있는 객체에 대한 의존성을 테스트 코드에서 단절 시켜주었다.
- 해당 컨트롤러는 Json 데이터 형태로 요청받아 Json 데이터 형태로 반환한다.
- 객체를 Json 데이터로 변환 해 주기 위해
Gson
라이브러리를 사용하였다.
- When 단계에서 mockMvc에 데이터와 함께 POST 요청을 보내야 하는데, 요청정보는
MockMvc
의 perform()
메소드로 작성 가능하다.
- 요청 정보에는
MockMvcRequestBuilders
가 사용되며 메소드 종류 요청 및 응답 데이터 타입, 바디의 데이터 등을 설정할 수 있다.
post()
,accept()
,contentType()
,content()
메소드 모두 MockMvcRequestBuilders
클래스의 메소드다.
- When 단계에서 요청에 따른 응답 데이터는
ResultActions
인터페이스 타입으로 반환되며 이를 이용하여, 응답 데이터에 대한 검증을 할 수 있다.
- Then 단계의
status().isCreated()
는 컨트롤러의 단위 테스트 대상 메소드가 응답으로 HttpStatus.CREATED
를 반환하기 때문에 응답 코드를 검증하기 위한 메소드다.
- Then 단계에서 응답 데이터 타입은 Json이다,
jsonPath()
를 활용하여 실제 테스트에서 응답하는 데이터와 기대하는 응답 데이터 값을 검증 할 수 있다.
📌 서비스 계층 단위 테스트
- 예제 서비스 코드에서
MemberService
는 MemberRepository
에 의존 하고 있다.
- 서비스 계층은 HTTP 호출과 상관 없으며 단순한 로직 검증만 하면 된다.
- Repository에 저장하는 로직은 단순하기 때문에 입력된 회원 정보가 중복 될시 예외가 발생하는 경우를 테스트 하였다.
@ExtendWith(MockitoExtension.class)
class MemberServiceTest {
@Mock
private MemberRepository memberRepository;
@InjectMocks
private MemberService memberService;
@Test
@DisplayName("중복 회원 생성시 예외 발생 - 이메일 조회")
void createMemberException() {
Long memberId = 1L;
Member testMember = createTestMember(memberId);
when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(testMember));
Throwable throwable = catchThrowable(() -> memberService.createMember(
Member.builder()
.email("PreventNull")
.build()));
assertThat(throwable)
.isInstanceOf(ServiceLogicException.class)
.hasMessageContaining(ErrorCode.MEMBER_EXISTS.getMessage());
}
}
- JUnit5와 Mockito를 연동하기 위해서는
@ExtendWith(MockitoExtension.class)
를 사용해야한다.
- 의존성 주입을 위해
MemberRepository
는 @Mock
을 이용하여 Mock 객체를 생성해 주고.
- 생성된 Mock 객체를
MemberService
에 @InjectMocks
어노테이션을 이용하여 주입해 준다.
when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(testMember));
given().willReturn()
과 활용 법은 동일하다.
memberRepository.findByEmail(anyString())
메소드를 호출할때 어떤 String 데이터를 파라미터로 지정하더라도,
- 항상
Optional.of(testMember)
를 반환하도록 설정하였다.
catchThrowable()
을 이용하여 실제 테스트할 메소드가 반환할 예외를 Throwable
타입으로 할당 해 둔다.
catchThrowable()
는 AssertJ 라이브러리의 메소드다.
assertThat(throwable)
.isInstanceOf(ServiceLogicException.class)
.hasMessageContaining(ErrorCode.MEMBER_EXISTS.getMessage());
- throwable의 객체를
isInstanceOf()
를 이용하여 ServiceLogicException.class
로 형 변환 되는지 확인 해 보면, 동일한 객체인지 검증되며,
- 해당 객체에
hasMessageContaining()
를 이용하여 기대하는 메세지를 포함하고 있는지 검증 할 수 있다.
📌 레포지토리 계층 단위 테스트
@DataJpaTest
어노테이션은, 스프링 부트에서 JPA 레포지토리를 쉽게 테스트 할수 있게 지원 한다.
- 해당 프로젝트에서는 H2 인메모리 DB를 구축해 놓았고,
@DataJpaTest
어노테이션은 기본적으로 H2를 기반으로 테스트하며, 테스트가 끝나면 트랜잭션 롤백을 한다.
- 실제 DB와 통신을 하지 않으면 테스트의 의미가 없으므로, 목킹은 하지 않는다.
@DataJpaTest
public class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Test
public void saveMemberTest() {
Member member = new Member();
member.setEmail("hgd@gmail.com");
member.setName("홍길동");
member.setPhone("010-1111-2222");
Member savedMember = memberRepository.save(member);
assertNotNull(savedMember);
assertTrue(member.getEmail().equals(savedMember.getEmail()));
assertTrue(member.getName().equals(savedMember.getName()));
assertTrue(member.getPhone().equals(savedMember.getPhone()));
}
}
- 실제로 DB에 Stub 데이터를 저장하고 저장되어 반환된 데이터가 기대값과 같은지 검증한다.
덕분에 잘 테스트했습니다 ! 감사해요