Kuma's Curious Paradise
테스트 코드1 - Member 관련 코드를 하나씩 살펴보며 이해하기 본문
Member 회원가입 관련 테스트 코드를 작성하였다. 해당 코드를 다시 살펴보며 테스트 코드를 이해해 보자.
public class MemberTest {
@Nested
class create {
@Test
void 생성성공_주어진_필드로_멤버_생성() {
// given
String email = "test@naver.com";
String password = "asdf1234!";
String name = "이름";
Address address = Mockito.mock(Address.class);
// when
Member member = Member.builder()
.email(email)
.password(password)
.name(name)
.address(address)
.build();
// then
Assertions.assertAll(
() -> assertThat(member.getId()).isNull(),
() -> assertThat(member.getEmail()).isEqualTo(email),
() -> assertThat(member.getPassword()).isEqualTo(password),
() -> assertThat(member.getName()).isEqualTo(name),
() -> assertThat(member.getAddress()).isEqualTo(address)
);
}
@Test
void 예외발생_멤버_필드가_Null일_때() {
// given
String email = "test@naver.com";
String password = "asdf1234!";
String name = "이름";
Address address = Mockito.mock(Address.class);
// when & then
Assertions.assertAll(
() -> assertThrowsNullPointerException(null, password, name, address),
() -> assertThrowsNullPointerException(email, null, name, address),
() -> assertThrowsNullPointerException(email, password, null, address),
() -> assertThrowsNullPointerException(email, password, name, null)
);
}
private void assertThrowsNullPointerException(String email, String password, String name, Address address) {
Assertions.assertThrows(IllegalArgumentException.class, () -> Member.builder()
.email(email)
.password(password)
.name(name)
.address(address)
.build());
}
}
}
1. @Nested
- 테스트를 그룹화하는 방법. create라는 내부 클래스 안에 관련 테스트 2개를 집어넣어서 관리한다.
2. given, when, then 패턴
- BDD(Behavior-Driven Development)에서 유래된 방식.
- given 이러한 것들이 주어졌을 때, 초기 상태 설정
when 이것이 발생하면, 테스트할 메서드를 호출하거나 이벤트를 트리거
then 이렇게 된다, 실행된 값 확인 - 이 외에 BDD와 아주 유사한 AAA(Arrange-Act-Assert) 패턴도 있지만, given when then이 가독성이 좋아 더 많이 사용되는 듯.
[주의할 점] - given이 너무 길다면, 테스트 클래스 내 별도의 private 메서드 or 팩토리 클래스를 만드는 것이 좋다. → 아직은 필요하지 않을 것 같아서 만들지 않았는데, 필요하다면 도입할 예정.
- when이 두 줄 이라면, 캡슐화가 제대로 안 되어 있을 가능성이 높다. → 이럴 때는 구현 코드를 수정하는 것이 필요하다.
3. Mochito.mock()
- mock object 모의 객체를 생성할 수 있게 한다.
- Address 객체를 builder로 끙차끙차 만드는 대신 mock(Address.class)로 손쉽게 해결.
4. Assertions.assertAll() / .assertNull() / .assertEquals() / .assertThrows
- assertAll()는 여러 개의 assertion을 한 번에 실행하는데, 만약 아래와 같은 코드들이 있다면...
// 하나씩 하나씩 실행하기
Assertions.assertNull(member.getId());
Assertions.assertEquals(member.getEmail(), email);
Assertions.assertEquals(member.getPassword(), password);
Assertions.assertEquals(member.getName(), name);
Assertions.assertEquals(member.getAddress(), address);
// assertAll()로 묶어서 실행하기
Assertions.assertAll(
() -> Assertions.assertNull(member.getId()),
() -> Assertions.assertEquals(member.getEmail(), email),
() -> Assertions.assertEquals(member.getPassword(), password),
() -> Assertions.assertEquals(member.getName(), name),
() -> Assertions.assertEquals(member.getAddress(), address)
);
- 위의 경우, 만약 첫 번째 검증에서 실패하면 나머지 검증은 실행되지 않는다. 이렇게 되면 어떤 검증이 실패했는지 알 수 없게 된다.
- 하지만, 아래처럼 assertAll()로 묶어서 익명함수들이 하나씩하나씩 실행되게 만들면, 하나의 함수가 실패해도 다음 함수가 실행된다. 이후 무엇이 실패했는지 한 번에 결과를 볼 수 있다.
5. 예외 검증에서 when과 then이 붙어 있는 이유
: 예외 발생 검증의 경우, 예외를 유발하는 동작과 그 결과를 동시에 검증해야 하기 때문. 쉽게 말해, 예외를 유발하는 코드를 실행하고 그 결과로 예외가 유발되기 때문에 붙어 있을 수밖에.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ImportTestcontainers(InitializeTestContainers.class)
public class MemberRepositoryTest {
@Autowired
MemberRepository memberRepository;
@Test
void 성공_멤버_저장() {
//given
Member member = Member.builder()
.email("test@naver.com")
.password("asdf1234!")
.name("이름")
.address(Mockito.mock(Address.class))
.build();
// when
Member savedMember = memberRepository.save(member);
// then
Assertions.assertAll(
() -> assertThat(savedMember).isNotNull(),
() -> assertThat(savedMember.getId()).isEqualTo(member.getId()),
() -> assertThat(savedMember.getEmail()).isEqualTo(member.getEmail()),
() -> assertThat(savedMember.getPassword()).isEqualTo(member.getPassword()),
() -> assertThat(savedMember.getName()).isEqualTo(member.getName()),
() -> assertThat(savedMember.getAddress()).isEqualTo(member.getAddress())
);
}
6. @DataJpaTest
- 레포지토리 테스트할 때 사용하는 어노테이션. 엔티티가 잘 매핑되었는지, 레포지토리의 CRUD가 잘 수행되는지 확인함.
7. @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
- DataJpaTest는 기본 설정이 인메모리 db를 사용하는데, 이를 사용하지 않고 실제 db 설정을 유지하도록 함. 우리는 MySQL 컨테이너를 사용하니까, 요것을 유지하기 위해서 설정.
8. @ImportTestcontainers(InitializeTestContainers.class)
- TestContainer 관련 글을 보면 나와 있지만, Interface로 InitializeTestContainers라는 클래스를 만들고 거기에 MySQL 컨테이너를 설정해 두었음. 이걸 주입해서 테스트하겠다는 뜻.
- 팀원의 TestContainer 글!
https://good4y.tistory.com/7
[SpringBoot] TestContainer 설정 방법
개요Testcontainers는 데이터베이스, 메시지 브로커, 웹 브라우저와 같은 Docker 컨테이너에서 실행될 수 있는 모든 것에 대한 일회용 경량 인스턴스를 제공하기 위한 오픈 소스 프레임워크이다.도입
good4y.tistory.com
@ExtendWith(MockitoExtension.class)
public class MemberServiceTest {
@InjectMocks
private MemberService memberService;
@Mock
private MemberRepository memberRepository;
@Nested
class saveMember {
@Test
void 성공반환_멤버_저장() {
// given
MemberSignupRequest request = new MemberSignupRequest(
"test@naver.com", "asdf1234!", "이름", mock(Address.class)
);
when(memberRepository.save(any(Member.class))).thenReturn(mock(Member.class));
// when
memberService.saveMember(request);
// then
verify(memberRepository).save(any(Member.class));
}
@Test
void 예외반환_중복된_멤버() {
// given
MemberSignupRequest request = new MemberSignupRequest(
"test@naver.com", "asdf1234!", "이름", mock(Address.class)
);
// when
when(memberRepository.existsByEmail(request.email())).thenReturn(true);
// then
assertThrows(BaseException.class, () -> memberService.saveMember(request));
}
}
}
9. @ExtendWith(MockitoExtension.class)
- Mochito 확장 기능 활성화. mock 객체의 생성과 주입, 초기화를 관리한다. @Mock, @InjectMocks, @Spy 사용 가능.
10. @InjectMocks
- memberService 객체 생성, 이 객체에 @Mock 표시가 달린 모든 의존성(여기서는 memberRepository)을 주입하게 함.
- @Mock : 해당 클래스 mock 객체 생성 지시.
11. when( ).thenReturn( ) 으로 memberRepository.save 메서드가 할 일을 지시하고 verify()로 호출이 되었는지 확인한다.
12. 왜 when().thenReturn()을 사용하는 걸까? memberRepository mock 객체를 주입받았으면 memberRepository.save(any(Member.class))하면 안 될까?
- Mochito를 사용해서 만든 목 객체는 메서드 호출 시 기본적으로 null을 반환한다. 그냥 무늬만 있는 객체기 때문!
- 따라서 memberRepository.save(any(Member.class))은 null이다.
- 원하는 값을 반환하려면 when().thenReturn()을 통해 명확하게 지정해 주어야 한다.
- 또한 이렇게 했을 때 무엇이 반환될지 명확하게 알 수 있다. → when(memberRepository.save(any(Member.class))).thenReturn(mock(Member.class));
13. 위에서 예외검증 시 when과 then이 붙어 있다고 했는데, 여기서는 왜 떨어져 있을까?
// when
when(memberRepository.existsByEmail(request.email())).thenReturn(true);
// then
assertThrows(BaseException.class, () -> memberService.saveMember(request));
- when에서 ‘true’를 반환하게 하여 중복 이메일이 존재하는 상황을 만든다 후, saveMember()를 호출해야 하기 때문.
@WebMvcTest(MemberControllerTest.class)
@Import(CustomSecurityConfig.class)
@SuppressWarnings("NonAsciiCharacters")
public class MemberControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private MemberService memberService;
@Nested
class signup {
@Test
void 성공반환_멤버_회원가입_요청() throws Exception {
// given
MemberSignupRequest request = new MemberSignupRequest("test@naver.com", "asdf1234!", "이름", Mockito.mock(Address.class));
doNothing().when(memberService).saveMember(request);
// when
ResultActions result = mockMvc.perform(post("/members/signup")
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding("UTF-8")
.content(new ObjectMapper().writeValueAsString(request)));
// then
result.andExpect(status().isOk())
.andExpect(content().json("{\"status\":200, \"message\":\"성공\"}"));
}
}
}
14. @AutoConfigureMockMvc : MVC 기반 애플리케이션을 mock 환경에서 테스트할 수 있게 한다. 이것이 어떻게 가능하냐면은…
- 실제 HTTP 요청을 보내지 않아도 컨트롤러 메서드를 호출할 수 있다.
- 응답도 verify가 가능하다.
- 컨트롤러와 관련 있는 모든 빈들을 로드해서 통합 테스트를 실시할 수 있다.
이를 사용하려면… 테스트 클래스에 @AutoConfigureMockMvc 어노테이션을 추가하고, MockMvc 객체를 **@Autowired**로 주입받는다. -> 문제는 이것이 @WebMvcTest와 겹친다는 것! -> 따라서 삭제하였다.
@WebMvcTest(MyController.class)
public class MyControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void 성공반환_멤버_회원가입_요청(){
//...
}
}
15. @WebMvcTest(MemberControllerTest.class)
- 웹 계층(Web Layer) 테스트를 위한 어노테이션.
- @SpringBootTest는 모든 빈들을 로드한다. 따라서 테스트 구동 시간이 오래 걸리고 테스트 단위가 크기 때문에 디버깅이 어려울 수 있다.
- 컨트롤러만 가볍게 테스트하고 싶을 때는 @WebMvcTest로 컨트롤러 관련된 최소한의 빈들만 로드해서 테스트할 수 있다. → 요청과 응답이 잘 오가는지, 기대한 응답이 오는지 확인.
16. 왜 security는 @Mock 하지 않고 @Import하는 걸까?
- 보안 설정을 mock으로 주입하면 실제 보안 로직이 적용되지 않아, 실제 어떻게 작동하는지 테스트할 수 없기 때문이다.
- 이 어노테이션을 사용하면 @WithMockUser를 통해서 인증된 사용자를 mock할 수 있다.
17. controller는 단위 테스트와 웹 계층 테스트로 나눌 수 있는데, 실제로 동작이 잘 되는지 확인하기 위해서 웹 계층 테스트는 무조건 필요하다고 생각한다. 그렇다면 나머지 단위 테스트까지 해야 할까?
- 장점 : 단위 테스트까지 작성하면 당연히 코드가 잘 작동하는지 사전에 확인할 수 있고, 따라서 예측 가능성이 향상될 것.
- 단점 : 컨트롤러는 단순히 요청을 받아서 서비스 로직을 돌리는 역할을 하는데, 이걸 반드시 단위 테스트까지 작성할 필요가 있을지 잘 모르겠다. 시간과 비용이 넉넉하지 않다면 컨트롤러 단위 테스트는 넘어가는 것이 좋을지도…?
- 결론: 시간과 비용을 고려하여 웹 계층 테스트만 실시하는 것으로 결정하였다.
18. @InjectMocks와 @Mock을 주로 단위 테스트에서 사용하고, @MockBean을 스프링 통합 테스트에서 사용하는 이유
- @InjectMock와 @Mock은 스프링 컨텍스트를 로드하지 않고도 클래스 의존성을 모킹하여 테스트할 수 있다. 쉽게 말해, 빈으로 인스턴스화하여 주입하는 과정 없이, 가짜 객체를 만들어서 테스트 진행이 가능하다.
- 따라서 테스트 실행 속도가 빠르며
- 클래스를 독립적으로 테스트하는 단위 테스트에 적합하다.
- @MockBean은 '빈을 모킹'한다. 따라서 스프링 컨텍스트를 로드하고 스프링의 DI 매커니즘까지 모두 소화한다.
- 따라서 @WebMvcTest처럼 실제 웹 계층을 테스트해야 할 때는 @MockBean을 사용한다.
- 더불어, @WebMvcTest는 컨트롤러 관련 빈들을 모두 불러오는데, 이때 컨트롤러에 딸린 다른 빈들(서비스, 레포지토리 빈 등) 주입이 필요하여 @MockBean을 사용하지 않으면 에러가 난다.
19. @WithMockUser와 @WithUserDetails 차이
- @WithMockUser 이름과 role 디폴트 값이 “user”, “USER”인 가짜 유저를 생성한다. 아래처럼 지정도 가능!.
@Nested
class signup {
@Test
@WithMockUser(username = "testUser", roles = {"USER"})
void 성공반환_멤버_회원가입_요청() throws Exception {
// ... //
}
}
- @WithUserDetails 는 실제로 UserDetailsService를 사용하며, db에 저장되어 있는 특정 사용자를 대상으로 테스트를 하고 싶을 때 사용한다.
public class MemberControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private MemberService memberService;
@MockBean
private UserDetailsService userDetailsService;
@Nested
class signup {
@Test
@WithUserDetails("testUser")
void 성공반환_멤버_회원가입_요청() throws Exception {
// .... //
}
}
}
'스프링' 카테고리의 다른 글
테스트 코드3 - Service와 Manager 계층이 분리되어 있을 때의 테스트 (0) | 2024.07.02 |
---|---|
테스트 코드2 - 시큐리티 필터 테스트 만들기 (0) | 2024.06.02 |
JPA : N+1 문제 개념과 해결방법 (0) | 2024.05.29 |
JUnit과 AssertJ의 차이점 (0) | 2024.05.25 |
JPA의 값 타입(기본값 타입 / 임베디드 타입 / 컬렉션 값 타입)이란? (0) | 2024.05.21 |