Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

Kuma's Curious Paradise

테스트 코드1 - Member 관련 코드를 하나씩 살펴보며 이해하기 본문

스프링

테스트 코드1 - Member 관련 코드를 하나씩 살펴보며 이해하기

쿠마냥 2024. 5. 28. 21:50

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 {
            // .... //
        }
    }
}