[TDD] TDD 개발 방식 적용기 # 1

최동근·2023년 1월 11일
0

TDD

목록 보기
3/4
post-thumbnail

안녕하세요 오늘은 TDD(Test Driven Development) 적용기 #1 포스팅을 준비했습니다 🙆🏻
TDD 개념이 부족하다면 [TDD] TDD 개발방식이란? 을 참고해 주세요 🧑🏼‍💻
또한 해당 포스팅은 망나니 개발자님의 [Spring] TDD로 멤버쉽 등록 API 구현 예제 를 따라하면서 정리한 글입니다 🙏

📚 기능 요구 사항

  • 멤버쉽 등록하기, 멤버쉽 조회, 멤버쉽 삭제, 포인트 적립 API 를 구현합니다.
  • 사용자 식별값은 문자열 형태이며 "M-USER-ID" 라는 HTTP Header로 전달되며, 이 값을 통해 식별하고 적립합니다.
  • TDD , 단위테스트를 기반으로 진행합니다.

📚 상세 기술 구현 사항[API 명세서]

1) 멤버쉽 등록 API

  • 기능 : 멤버쉽을 등록합니다.
  • Request : 사용자 식별값, 멤버쉽 이름, 초기 포인트
  • Response : 멤버쉽 ID, 멤버쉽 이름

2) 멤버쉽 전체 조회 API

  • 기능 : 멤버쉽을 조회합니다.
  • Request : 사용자 식별값
  • Response : { 멤버쉽 ID, 멤버쉽 이름, 포인트, 가입 일시 }의 멤버쉽 리스트

3) 멤버쉽 상세 조회 API

  • 기능 : 나의 멤버쉽 하나를 상세 조회합니다.
  • Request : 사용자 식별값, 멤버쉽 ID
  • Response : 멤버쉽 ID, 멤버쉽 이름, 포린트 가입 일시

4) 멤버쉽 포인트 적립 API

  • 기능 : 나의 멤버쉽 포인트를 결제 금액의 1% 적립
  • Request : 사용자 식별값, 멤버쉽 ID, 사용 금액
  • Response : x

1. 멤버쉽 등록 API 구현

멤버쉽 등록 API 은 Repository 계층 테스팅을 필요로합니다.

[Repository 계층 개발]

MemberShip 엔티티를 MemberShipRepository에 저장하는 테스트 코드를 작성해보겠습니다.

[실패 테스트 코드 ]

public class MembershipRepositoryTest {

	@Autowired
	private MembershipRepository membershipRepository;
    
    @Test
    public void MembershipRepositoryNull이아님() {
        assertThat(membershipRepository).isNotNull();
    }
	
}

가장 먼저 Repository Test 코드를 작성합니다.
아직 MemberShipRepository 존재하지 않기 때문에 해당 테스트는 컴파일 오류가 납니다 ⛔️
MembershipRepository 를 추가하겠습니다.

@Repository
public interface MemberShipRepository extends JpaRepository<MemberShip, Long> {
}

스프링 Data Jpa 를 사용할 것이기 때문에 JpaRepository를 상속하도록 설계합니다.
Repository 계층을 완성했다면 Entity 계층도 작성합니다.

public class MemberShip {

}

이제 테스트 코드를 돌려보겠습니다.
어떤 결과가 나올까요? 실패입니다.
그 이유는 MemberShipRepository가 null 이기 때문입니다.
따라서 MemberShipRepositoryTest 위에 @DataJpaTest 를 붙여주고, Entity 클래스를 완성시킵니다 🧑🏼‍💻
실패 코드를 수정하여 성공 케이스를 바꾼 코드는 밑쪽에 나와있습니다 👇

[성공 테스트 코드 ]

🔻 MemberShip Entity 클래스
@Slf4j
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table
@Entity

public class MemberShip extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private Long id;

    @Column(nullable = false)
    private String userId;

    @Column(nullable = false,length = 20)
    private String memberShipName;

    @Column(nullable = false)
    @ColumnDefault("0")
    private Long point;

}
🔻 MemberShipRepository 인터페이스
@Repository
public interface MemberShipRepository extends JpaRepository<MemberShip, Long> {
}
🔻 MemberShipRepositoryTest 클래스
@DataJpaTest
class MemberShipRepositoryTest {

    @Autowired
    private MemberShipRepository memberShipRepository;

    @Test
    @DisplayName("MemberRepository Test")
    void MemberRepositoryTest(){

        // given
        MemberShip memberShip = MemberShip.builder()
                .userId("userId")
                .memberShipType(MemberShipType.KAKAO)
                .point(1000L)
                .build();

        // when
        MemberShip result = memberShipRepository.save(memberShip); // MemberShip 엔티티 저장

        // then
        assertThat(result.getId()).isNotNull();
        assertThat(result.getUserId()).isEqualTo("userId");
        assertThat(result.getMemberShipType()).isEqualTo(MemberShipType.KAKAO);
        assertThat(result.getPoint()).isEqualTo(1000L);

        assertThat(memberShipRepository).isNotNull();

     }

}
  • @ DataJpaTest : JPA Repository 관련 빈들을 등록하여 단위 테스트를 용이하게 합니다.
    • @ ExtendWith(SpringExtension.class), @Transactional 등 다양한 어노테이션을 포함합니다.

개발자는 초기 실패 코드로 부터 성공 테스트 코드까지 완성했습니다 💪
하지만, 개발자는 Test 코드에서 MemberShipType 중복검사가 필요할 것 같다는 생각을 합니다.
즉 사용자가 이미 해당 MemberShipType 을 등록했다면 등록되지 못하게 막는 기능이 추가되어야 합니다 🧑🏼‍💻

🔻 MemberShipRepositoryTest 클래스
@DataJpaTest
class MemberShipRepositoryTest {

    @Autowired
    private MemberShipRepository memberShipRepository;

    @Test
    @DisplayName("MemberRepository Test")
    void MemberRepositoryTest(){

        // given
        MemberShip memberShip = MemberShip.builder()
                .userId("userId")
                .memberShipType(MemberShipType.KAKAO)
                .point(1000L)
                .build();


        // when
        memberShipRepository.save(memberShip); // MemberShip 엔티티 저장
        MemberShip findResult = memberShipRepository.findByUserIdAndMemberShipType("userId",MemberShipType.KAKAO);

        // then
        assertThat(findResult.getId()).isNotNull();
        assertThat(findResult.getUserId()).isEqualTo("userId");
        assertThat(findResult.getMemberShipType()).isEqualTo(MemberShipType.KAKAO);
        assertThat(findResult.getPoint()).isEqualTo(1000L);

        assertThat(memberShipRepository).isNotNull();

     }

}

이렇게 중복 검사 로직까지 모두 완성하면 기본적인 Repository 계층은 완성했습니다 🙆🏻

마지막 수정된 코드 까지 여러 번 테스트를 실행하고 수정하는 작업을 반복하였습니다.
이러한 작업 흐름이 TDD의 흐름입니다.

[Service 계층 개발]

이번에는 MemberShip 을 등록하는 Service 계층을 개발하려고 합니다.
앞에 과정과 동일하게 Service 계층에 관한 Test 클래스부터 작성하겠습니다.
Service 계층은 DB 처리를 하는 Respository 계층을 Mocking 하기 위해 MockitoExtension에서 실행되도록 합니다 🔥
Mockito 를 알고싶다면 Mockito 를 뿌셔봅시다!을 참고해주세요.

이제 멤버쉽 등록에 대한 테스트 코드를 작성해야 합니다.
TDD 에 따라서 실패 하는 테스트 코드부터 작성하겠습니다.

[ 실패 테스트 코드 ]

UserId와 MemberShipType 으로 이미 멤버쉽이 존재하여 실패하는 테스트 코드부터 작성합시다 👨‍💻
즉 findByUserIdAndMemberShipType 을 호출했을 때 결과가 null 이 아니여야 하는 것을 의미합니다.
또한 이런 경우 Exception을 던지도록 구현하겠습니다.

🔻 MemberShipServiceTest 클래스
@ExtendWith(MockitoExtension.class)
class MemberShipServiceTest {


    private final String userId = "userId";
    private final MemberShipType memberShipType = MemberShipType.KAKAO;
    private final Long point = 1000L;
    
    
    @InjectMocks
    private MemberShipService memberShipService; // 의존성 주입을 받는 대상(테스트 대상)
    
    @Mock
    private MemberShipRepository memberShipRepository; // 의존성있는 클래스

    @Test
    @DisplayName("MemberShip ServiceTest")
    void 멤버쉽등록실패_이미존재함() {

        // given
        // null 아닌 값을 반환
        doReturn(MemberShip.builder().build()).when(memberShipRepository)
        				.findByUserIdAndMemberShipType(userId, memberShipType);

        // when
        final MembershipException result = assertThrows(MembershipException.class, 
        			() -> memberShipService.addMembership(userId, memberShipType, point));

        // then
        assertThat(result.getErrorResult()).isEqualTo(MembershipErrorResult.DUPLICATED_MEMBERSHIP_REGISTER);
    }

}

해당 테스트 코드는 컴파일 에러를 발생시킵니다 😥
아직 Exception 부분을 구현하지 않았으며, MemberService 클래스에는 addMembership() 메소드가
아직 구현되지 않았기 때문입니다.

🔻 MemberShipException 필드에 들어갈 MemberShipErrorResult

@Getter
@RequiredArgsConstructor
public enum MemberShipErrorResult {
    
    DUPLICATED_MEMBERSHIP_REGISTER(HttpStatus.BAD_REQUEST, "Duplicated MemberShip Register Request");
    
    private final HttpStatus httpStatus;
    
    private final String message;
}
🔻 MemberShipException 클래스

@Getter
@RequiredArgsConstructor
public class MemberShipException extends RuntimeException{

    private final MemberShipErrorResult result;
}
🔻 MemberShipService 클래스 addMemberShip() 구현

@RequiredArgsConstructor
@Service

public class MemberShipService {
    public MemberShip addMembership(String userId, MemberShipType memberShipType, Long point) {

        return null;
    }
}

MemberShipException에 대해 간단히 짚고 넘어가겠습니다.
MemberShipException은 RuntimeException을 상속하고 있습니다.
RuntimeException 은 Unchecked Exception 인데, 예외처리를 강제하지 않는 예외 종류입니다.
여기서는 예외 처리를 개발자가 강제할 필요가 없으며, 트랜잭션 내에서 Unchecked Exception 만이 자동으로 롤백되기 때문에 RuntimeException을 상속받습니다.

위에 있는 코드까지 모두 구현하면 MemberShipServiceTest 클래스에서 발생했던 컴파일 에러는 모두 사라집니다 🙆🏻
하지만 MemberShipServiceTest을 실행하면 MemberShipService 에서 예외가 throw 되지 않아 실패하게 됩니다.
따라서 MemberShipService 를 완성해줍니다.

🔻 수정한 MemberShipService
@Slf4j
@RequiredArgsConstructor
@Service

public class MemberShipService {
    
    private final MemberShipRepository memberShipRepository;
    public MemberShip addMembership(String userId, MemberShipType memberShipType, Long point) {


        MemberShip member = memberShipRepository.findByUserIdAndMemberShipType(userId, memberShipType);

        if (member != null) {

            throw new MemberShipException(MemberShipErrorResult.DUPLICATED_MEMBERSHIP_REGISTER);

        } // 중복 체크
        
    }
        
}

MemberShipService 까지 모두 완성하면 멤버쉽등록을 실패하는 경우 테스트를 완성 할 수 있습니다 🧑🏼‍💻

[성공 테스트 코드 ]

   🔻 MemberServiceTest 멤버쉽등록성공() 메소드
    @Test
    @DisplayName("MemberShipFail_ServiceTest")
    void 멤버쉽등록성공() {

        // given
        // stubbing
        given(memberShipRepository.findByUserIdAndMemberShipType(userId, memberShipType))
                .willReturn(null);

        given(memberShipRepository.save(any())).willReturn(memberShip());
        
        // when
        final MemberShip memberShip = memberShipService.addMembership(userId, memberShipType, point);

        // then
        assertThat(memberShip.getId()).isNotNull();
        assertThat(memberShip.getMemberShipType()).isEqualTo(memberShipType);

        // verify
        verify(memberShipRepository, times(1))
                .findByUserIdAndMemberShipType(userId, memberShipType);

        verify(memberShipRepository, times(1)).save(any(MemberShip.class));

    }
    
    private MemberShip memberShip() {

        return MemberShip.builder()
                .id(-1L)
                .userId(userId)
                .point(point)
                .memberShipType(MemberShipType.KAKAO)
                .build();
    }
🔻 수정한 MemberService
@Slf4j
@RequiredArgsConstructor
@Service

public class MemberShipService {

   private final MemberShipRepository memberShipRepository;
   public MemberShip addMembership(String userId, MemberShipType memberShipType, Long point) {


       MemberShip member = memberShipRepository
               .findByUserIdAndMemberShipType(userId, memberShipType);

       if (member != null) {

           throw new MemberShipException(MemberShipErrorResult.DUPLICATED_MEMBERSHIP_REGISTER);

       } // 중복 체크

       return memberShipRepository.save(MemberShip.builder()
               .userId(userId)
               .memberShipType(memberShipType)
               .point(point)
               .build()
       );
   }
}

두 코드는 멤버쉽 등록 성공 테스트 코드입니다.
TDD 에서 중요한 것은 앞에서 진행했던 [멤버쉽 등록 실패 테스트 코드]를 먼저 작성하고 [성공하는 테스트코드]를 작성해야
한다는 점입니다.
여기서 끝났다고 착각할 수 있지만 마지막 단계인 리팩토링 단계가 남았습니다

[ 리팩토링 ]

위에서 확인한 코드에서는 MemberShipService 에서 반환하는 데이터 타입이 MemberShip 입니다.
Spring MVC 패턴에서 엔티티를 Presentation Layer 로 반환하지 않습니다.
그 대신 Dto 라는 클래스에 노출가능한 필요한 정보만 담아서 Presentation Layer로 보냅니다.
따라서 리팩토링과정에서 DTO 형태로 반환화도록 하겠습니다 🙆🏻

저희는 현재 TDD 개발 방식으로 개발을 하고 있음으로 프러덕션 코드(MemberShipService)가 아닌 테스트 코드부터 수정하겠습니다.

 🔻 리팩토링한 MemberShipServiceTest
 @Test
 @DisplayName("MemberShipFail_ServiceTest")
 void 멤버쉽등록성공() {

     // given
     // stubbing
     given(memberShipRepository.findByUserIdAndMemberShipType(userId, memberShipType))
             .willReturn(null);

     MemberShip member = memberShip();

     given(memberShipRepository.save(any()))
             .willReturn(MemberShipResponse.fromEntity(member));
     // MemberShipResponse 클래스는 MemberShip 엔티티의 응답 Dto 로서 역할을 합니다.
     // 아직 프로덕션 코드(MemberShipService) 수정이 되기 전이기 때문에 컴파일 에러가 뜹니다.


     // when
     final MemberShipResponse memberShipResponse = memberShipService.addMembership(userId, memberShipType, point);
     // 아직 프로덕션 코드(MemberShipService) 수정이 되기 전이기 때문에 컴파일 에러가 뜹니다.


     // then
     assertThat(memberShipResponse.getId()).isNotNull();
     assertThat(memberShipResponse.getMemberShipType()).isEqualTo(member.getMemberShipType());
     assertThat(memberShipResponse.getUserId()).isEqualTo(member.getUserId());
     // 아직 프로덕션 코드(MemberShipService) 수정이 되기 전이기 때문에 컴파일 에러가 뜹니다.


     // verify
     verify(memberShipRepository, times(1))
             .findByUserIdAndMemberShipType(userId, memberShipType);

     verify(memberShipRepository, times(1))
             .save(any(MemberShip.class));

     verify(memberShipRepository, atLeast(1))
             .findByUserIdAndMemberShipType(userId, memberShipType);

 }

 private MemberShip memberShip() {

     return MemberShip.builder()
             .id(-1L)
             .userId(userId)
             .point(point)
             .memberShipType(MemberShipType.KAKAO)
             .build();
 }
 

테스트 코드를 수정하면 아직 프로덕션 코드를 리팩토링하기 전이기 때문에 곳곳에 컴파일 에러가 뜹니다.
MemberShipResponse 클래스를 추가하고 MemberService 클래스도 수정하겠습니다.

🔻 MemberShipReponse 클래스
@Getter
@Setter
@Builder
@ToString
@RequiredArgsConstructor
public class MemberShipResponse {

    private final Long id;

    private final MemberShipType memberShipType;


    public static MemberShipResponse fromEntity(MemberShip memberShip) {

        return MemberShipResponse.builder()
                .id(memberShip.getId())
                .memberShipType(memberShip.getMemberShipType())
                .build();
    }

}
🔻 수정한 MemberShipService 

@Slf4j
@RequiredArgsConstructor
@Service

public class MemberShipService {

    private final MemberShipRepository memberShipRepository;
    public MemberShipResponse addMembership(String userId, MemberShipType memberShipType, Long point) {


        MemberShip member = memberShipRepository
                .findByUserIdAndMemberShipType(userId, memberShipType);

        if (member != null) {

            throw new MemberShipException(MemberShipErrorResult.DUPLICATED_MEMBERSHIP_REGISTER);

        } // 중복 체크

        return MemberShipResponse.fromEntity(memberShipRepository.save(MemberShip.builder()
                                                      .userId(userId)
                                                      .memberShipType(memberShipType)
                                                      .point(point)
                                                      .build())
        );
    }
}

이렇게 리랙토링 과정까지 마무리 하면 [멤버쉽 등록 API] 의 Service 계층까지 완성했습니다. 🙆🏻
자 이제 마지막 [ Controller 계층 ] 을 개발해보겠습니다.

[Controller 계층 개발]

Controller 계층은 함수 호출이 아닌 API 호출을 통해 요청을 받고 응답을 처리합니다.
따라서 요청 메세지를 Conveting 하는 작업이 필요합니다.
보통 Controller 계층을 테스트하기 위해 @MockMvc, @WebMvcTest 를 사용합니다.

  • @WebMvcTest

Controller 계층을 Test 할때 사용하는 어노테이션 입니다.
Application Context 를 완전하게 start 시키지 않고 Presentation Layer 를 테스트 하고 싶을 때
사용합니다. Controller 계층 관련된 컴포넌트만 스캔을 합니다.

@WebMvcTest 사용 예시

@WebMvcTest(xxxController.class)
class xxxControllerTest {
...
}

  • MockMvc

애플리케이션을 배포하지 않고도 , 서버의 MVC 동작을 테스트 할 수 있는 라이브러리입니다.
주로 Controller Layer 단위 테스트에 많이 사용됩니다.

@MockMvc 사용 예시

mockMvc.perform(get("/contents"))
        .andExpect(status().isOk())
        .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
        .andExpect(jsonPath("$.data").isArray())
        .andExpect(jsonPath("$.data[0].name").value("user1"))
        .andExpect(jsonPath("$.data[0].title").value("title1"))
        .andExpect(jsonPath("$.code").value(ErrorCode.OK.getCode()))
        .andExpect(jsonPath("$.message").value(ErrorCode.OK.getMessage()));
profile
비즈니스가치를추구하는개발자

0개의 댓글