티스토리 뷰

Spring

[Spring] TDD로 멤버십 등록 API 구현 예제 - (3/5)

망나니개발자 2021. 8. 22. 10:00
반응형

이번에는 TDD로 멤버십 등록 API를 구현해보도록 하겠습니다. 첫 API를 개발하는 것이므로 다른 로직에서 사용되는 공통 로직들도 많이 있어서 작업이 적지는 않을 것 같은데, 열심히 첫 API를 개발해보도록 하겠습니다.

 

 

1. 멤버십 등록 API 구현


[ 요구사항 확인 ]

  • 나의 멤버십 등록 API
    • 기능: 나의 멤버십을 등록합니다.
    • 요청: 사용자 식별값, 멤버십 이름, 포인트
    • 응답: 멤버십 ID, 멤버십 이름

 

[ Repository 계층 개발 ]

앞선 포스팅에서 살펴봤던 것처럼 Repository의 메소드 중에서 멤버십을 DB에 추가하는 테스트 코드를 먼저 작성해보고자 한다. MembershipRepository에 대한 테스트 클래스를 작성하면 다음과 같다.

public class MembershipRepositoryTest {

	@Autowired
	private MembershipRepository membershipRepository;
	
}

 

 

Repository 계층은 통합 테스트로 작성할 것이므로, @Repository 계층이 Spring 컨테이너의 빈으로 등록될 것이다. 실제 프로덕트 코드라면 생성자 주입을 사용해야겠지만, 테스트 코드니까 편하게 필드 주입인 @Autowired를 사용하도록 하자.

(Spring에서는 생성자 주입을 강력하게 권장하고 있습니다. 생성자 주입을 사용해야 하는 이유에 대해서는 여기를 참고해주세요!)

 

위의 테스트 클래스에서 아직 MembershipRepository는 존재하지 않으므로, 빨간 줄을 띄우며 클래스를 찾을 수 없다는 에러가 보일 것이다.

 

 

 

TDD로 개발을 하면 컴파일 에러를 자주 만나게 된다. 처음에는 익숙하지 않을 수 있지만 점차 익숙해질 것이다.

Repository 계층은 통합 테스트이므로 먼저 MemberRepository라는 빈이 잘 띄워지는지부터 테스트를 해보자.

public class MembershipRepositoryTest {

    @Autowired
    private MembershipRepository membershipRepository;

    @Test
    public void MembershipRepository가Null이아님() {
        assertThat(membershipRepository).isNotNull();
    }
}

 

 

이제 우리는 컴파일 에러를 해결하기 위해 MemberRepository를 추가해주어야 한다. 그러면 다음과 같은 MemberRepository를 추가할 수 있다.

public interface MembershipRepository extends JpaRepository<Membership, Long> {

}

 

 

그러면 또 컴파일 에러를 해결하기 위해 Membership이라는 entity가 추가로 필요하다.

public class Membership {

}

 

 

그러면 이제 컴파일 에러가 해결되었고, 테스트 코드를 실행할 수 있다. 사실 TDD로 개발하면 이러한 클래스들은 테스트 클래스의 Inner 클래스로 만들고 프로덕션 코드로 옮기는게 정석이지만, 이러한 사실을 알고만 넘어가도록 하자.

테스트 코드를 실행하면 MemberRepository가 Null이므로 테스트에 실패하고 빨간막대를 보게 된다.

이를 해결하기 위해 다음과 같은 어노테이션들을 테스트 클래스에 추가하였다. 

@DataJpaTest
public class MembershipRepositoryTest {

    @Autowired
    private MembershipRepository membershipRepository;

    @Test
    public void MembershipRepository가Null이아님() {
        assertThat(membershipRepository).isNotNull();
    }
}

 

각각의 어노테이션들은 다음의 역할을 한다.

  • @DataJpaTest: JPA Repository들에 대한 빈들을 등록하여 단위 테스트의 작성을 용이하게 함

 

Repository 타입의 빈을 등록하기 위해서는 @Repository 어노테이션을 붙여주어야 한다. 하지만 JpaRepository 하위에 @Repository가 이미 붙어있으므로 @Repository를 붙여주지 않아도 된다.

또한 테스트를 위해서는 테스트 컨텍스트를 잡아주어야 할텐데, @DataJpaTest는 내부적으로 @ExtendWith( SpringExtension.class) 어노테이션을 가지고 있어서, 이 어노테이션만 붙여주면 된다.

마찬가지로 @DataJpaTest에는 @Transactional 어노테이션이 있어서, 테스트의 롤백 등을 위해 별도로 트랜잭션 어노테이션을 추가하지 않아도 된다.

... 생략
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(DataJpaTypeExcludeFilter.class)
@Transactional
@AutoConfigureCache
@AutoConfigureDataJpa
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
@ImportAutoConfiguration
public @interface DataJpaTest {

}

 

 

그리고 테스트를 실행하면 이번에도 실패하고, 다음과 같은 에러가 발생하게 된다.

org.springframework.beans.factory.BeanCreationException: 
Error creating bean with name 'membershipRepository' defined in com.mang.atdd.membership.app.membership.repository.MembershipRepository 
defined in @EnableJpaRepositories declared on JpaRepositoriesRegistrar.EnableJpaRepositoriesConfiguration: Invocation of init method failed; 
nested exception is java.lang.IllegalArgumentException: Not a managed type: class com.mang.atdd.membership.app.membership.entity.Membership

 

 

내용을 분석해보면 Membership이 JPA에 의해 관리되는 클래스가 아니라는 것이고, Memebership을 엔티티로써 관리하기 위해 다음의 코드들을 Memebership 클래스에 추가해주어야 한다. JPA에서 관리되는 엔티티를 위해서는 기본 생성자가 필요하다. 그러므로 @NoArgsConstructor 어노테이션도 추가해주도록 하자

@Entity
@Table
@NoArgsConstructor
public class Membership {

    private Long id;

}

 

 

그리고 또 테스트를 실행하면 다시 다음과 같은 에러가 발생하게 된다.

org.springframework.beans.factory.BeanCreationException: 
Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: 
Invocation of init method failed; nested exception is org.hibernate.AnnotationException: 
No identifier specified for entity: com.mang.atdd.membership.app.membership.entity.Membership

 

 

내용을 분석해보면 Membership에 식별자(ID)가 없다는 에러이다. 이 문제를 해결하기 위해 Long타입의 id가 AutoIncrement 되도록 다음과 같은 어노테이션을 id에 추가해주도록 하자.

@Entity
@Table
@NoArgsConstructor
public class Membership {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

}

 

 

그리고 테스트를 실행하면 드디어 첫 초록막대를 볼 수 있다.

 

첫 테스트를 성공시키기까지 여러 번 테스트를 실행하고, 수정하는 작업을 반복하였다. 이러한 작업 흐름이 TDD의 흐름이며, 이러한 흐름을 유지한 채로, 멤버십을 등록하는 Repository 개발을 이어가보도록 하자.

 

 

MembershipRepository가 Null인지 검사하는 테스트는 이제 불필요하므로 제거하자. (리팩토링의 단계를 진행한 것이다.)

그리고 다음과 같은 멤버십 등록 테스트를 작성하도록 하자. 테스트의 결과 검증은 응답인 멤버십 ID와 멤버십 이름이 Null이 아님으로 판단하도록 하자.

@Test
public void 멤버십등록() {
    // given
    final Membership membership = Membership.builder()
            .userId("userId")
            .membershipName("네이버")
            .point(10000)
            .build();

    // when
    final Membership result = membershipRepository.save(membership);

    // then
    assertThat(result.getId()).isNotNull();
    assertThat(result.getUserId()).isEqualTo("userId");
    assertThat(result.getMembershipName()).isEqualTo("네이버");
    assertThat(result.getPoint()).isEqualTo(10000);
}

 

 

역시 테스트 코드에서 컴파일 에러들이 발생하고 있고, 이를 해결하기 위해 다음과 같은 프로덕션 코드들을 추가할 수 있다.

@Entity
@Table
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Membership {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

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

    @Column(nullable = false)
    private String userId;

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

    @CreationTimestamp
    @Column(nullable = false, length = 20, updatable = false)
    private LocalDateTime createdAt;

    @UpdateTimestamp
    @Column(length = 20)
    private LocalDateTime updatedAt;
}

 

 

Entity 객체에서 @Column을 붙여주지 않은 지역 변수도 컬럼에 추가된다. 하지만 userId외에는 모두 속성이 있어서, userId도 일관성을 위해 붙여주었다.

 

테스트를 실행하면 다음과 같이 테스트를 성공하고 초록 막대를 보게 된다.

 

테스트가 성공하고 초록 막대를 보았으니 이제는 리팩토링의 단계를 진행해야 한다. 요구사항으로는 네이버, 카카오, 라인 3개의 멤버십을 관리할 수 있어야 하는데, 이 부분을 Enum으로 관리하면 더욱 좋을 것이다. 그러므로 멤버십 이름을 멤버십 타입으로 관리하도록 하자.

여기서 다시 우리는 TDD 기반으로 개발하고 있음을 잊어서는 안된다. 테스트 코드에 String으로 되어 있는 membershipName을 Enum 기반의 MemebershipType으로 먼저 변경하고, 프로덕트 코드를 변경하도록 하자.

 

기존의 테스트 코드를 수정하면 다음과 같다.

@Test
public void 멤버십등록() {
    // given
    final Membership membership = Membership.builder()
            .userId("userId")
            .membershipType(MembershipType.NAVER)
            .point(10000)
            .build();

    // when
    final Membership result = membershipRepository.save(membership);

    // then
    assertThat(result.getId()).isNotNull();
    assertThat(result.getUserId()).isEqualTo("userId");
    assertThat(result.getMembershipType()).isEqualTo(MembershipType.NAVER);
    assertThat(result.getPoint()).isEqualTo(10000);
}

 

 

이제는 컴파일 에러가 나는 상황에 조금 적응이 되었을 것이다.

컴파일 에러를 수정하기 위해 MemebershipType이라는 Enum 클래스를 만들고 Membership 엔티티를 수정하도록 하자.

@Getter
@RequiredArgsConstructor
public enum MembershipType {

    NAVER("네이버"),
    LINE("라인"),
    KAKAO("카카오"),
    ;

    private final String companyName;
}

 

 

@Entity
@Table
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Membership {

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

    @Enumerated(EnumType.STRING)
    private MembershipType membershipType;

    @Column(nullable = false)
    private String userId;

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

    @CreationTimestamp
    @Column(nullable = false, length = 20, updatable = false)
    private LocalDateTime createdAt;

    @UpdateTimestamp
    @Column(length = 20)
    private LocalDateTime updatedAt;
}

 

 

여기서 약간의 팁이지만, Enum에서는 , ; 문법이 허용된다. 이렇게 Enum 클래스를 작성하면 불필요하게 세미콜론을 관리할 필요가 없으므로 새로운 Type을 추가하기에 편리하다. (실제로 적용하면 꽤나 많이 편리하다.)

하지만 그것보다 주목해야 하는 것은 MembershipType 같은 Enum 변수에 @Enumerated(EnumType.STRING) 어노테이션을 추가해주었다는 것이다. 이것은 DB에 저장될 때 enum의 코드값(NAVER or KAKAO 등)으로 저장을 하도록 하는데, 이 값이 없으면 Enum의 ORDINAL(1, 2, 3 과 같은 Integer 값)로 저장이 된다. 이 부분이 중요한 이유는 현재 LINE은 두 번째에 있어 ORDINAL 값이 2인데, LINE앞에 다른 SAMSUNG과 같은 값이 추가되면 ORDINAL 순서가 흐뜨러져 데이터의 정합성에 문제가 생기기 때문이다.

이렇게 테스트 코드와 프로덕션 코드를 수정하고 실행하면 다음과 같이 테스트를 성공하고 초록 막대를 보게 된다. 그러면 이제 멤버십을 등록하는 Repository 테스트를 작성한 것이다.

 

하지만 아직 하나 더 추가해야 하는 Repository 테스트가 있다. 그것은 바로 어떤 사용자가 이미 멤버십 타입을 등록했으면 중복 검사를 하여 등록되지 않도록 하는 것이다.

해당 로직을 구현하기 위해서는 사용자의 아이디와 멤버십 테스트로 멤버십을 조회할 수 있어야 한다. 이에 대한 테스트 코드를 다음과 같이 작성할 수 있다.

@Test
public void 멤버십이존재하는지테스트() {
    // given
    final Membership membership = Membership.builder()
            .userId("userId")
            .membershipType(MembershipType.NAVER)
            .point(10000)
            .build();

    // when
    membershipRepository.save(membership);
    final Membership findResult = membershipRepository.findByUserIdAndMembershipType("userId", MembershipType.NAVER);

    // then
    assertThat(findResult).isNotNull();
    assertThat(findResult.getId()).isNotNull();
    assertThat(findResult.getUserId()).isEqualTo("userId");
    assertThat(findResult.getMembershipType()).isEqualTo(MembershipType.NAVER);
    assertThat(findResult.getPoint()).isEqualTo(10000);
}

 

 

그러면 MembershipRepository의 findByUserIdAndMembershipType이 존재하지 않아 컴파일 에러가 발생할 것이다. 그리고 이를 해결하기 위해 MembershipRepository에 다음과 같이 메소드를 추가해주면 된다.

public interface MembershipRepository extends JpaRepository<Membership, Long> {

    Membership findByUserIdAndMembershipType(final String userId, final MembershipType membershipType);

}

 

 

그리고 테스트를 실행하면 다음과 같이 초록 막대를 볼 수 있다.

 

그러면 멤버십을 등록하기 위한 API에 필요한 Repository의 로직들은 구현이 완료되었다. 다음 단계로 Service 계층을 개발해보도록 하자.

 

 

 

[ Service 계층 개발 ]

이번에는 멤버십을 등록하는 서비스 계층을 개발해야 한다. 앞서 레포지토리 계층을 개발했던 것 처럼 이번에도 다음과 같이 서비스 계층에 대한 테스트 클래스부터 작성해야 한다. 서비스 계층은 데이터베이스에 데이터를 처리하는 레포지토리 계층을 모킹하기 위해 MockitoExtension에서 실행되도록 하자.

(Mockito를 이용한 단위테스트에 대한 공부가 부족하면 여기를 참고해주세요!)

@ExtendWith(MockitoExtension.class)
public class MembershipServiceTest {
    
}

 

 

그리고 이제 멤버십 등록에 대한 테스트 코드를 작성해야 하는데, 사용자Id와 멤버십타입으로 이미 멤버십이 존재하여 실패하는 테스트 코드부터 작성하도록 하자. 즉, membershipRepository의 findByUserIdAndMembershipType를 호출했을 때 결과가 null이 아니여야 하는 것이다. 그리고 이에 대한 결과로 Exception을 던지도록 구현하고자 할 때, 이에 대한 테스트 코드를 작성하면 다음과 같다.

@ExtendWith(MockitoExtension.class)
public class MembershipServiceTest {

    private final String userId = "userId";
    private final MembershipType membershipType = MembershipType.NAVER;
    private final Integer point = 10000;

    @Test
    public void 멤버십등록실패_이미존재함() {
        // given
        doReturn(Membership.builder().build()).when(membershipRepository).findByUserIdAndMembershipType(userId, membershipType);

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

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

 

 

위와 같이 테스트 코드를 작성하면 많은 부분에서 컴파일 에러가 발생한다.

 

 

하나 하나씩 차근차근 구현을 시작해보도록 하자.

여기서 userId, membershipType, point 값들은 다른 테스트 메소드에서도 사용될 것 같으므로 클래스 변수로 빼두도록 하고, 테스트 대상인 클래스와 의존성이 있는 클래스를 추가해주도록 하자.

@ExtendWith(MockitoExtension.class)
public class MembershipServiceTest {

    @InjectMocks
    private MembershipService target;
    @Mock
    private MembershipRepository membershipRepository;
    
    ... 생략
}

 

 

MembershipService는 테스트 대상이므로 의존성이 주입되는 어노테이션인 @InjectMocks를 붙여주었고, MembershipRepository가 의존성이 있는 클래스이므로 가짜 객체 생성을 도와주는 @Mock를 붙여주었다.

 

컴파일 에러를 해결하기 위해 다음과 같은 클래스들을 작성하였다.

@Getter
@RequiredArgsConstructor
public enum MembershipErrorResult {

    DUPLICATED_MEMBERSHIP_REGISTER(HttpStatus.BAD_REQUEST, "Duplicated Membership Register Request"),
    ;

    private final HttpStatus httpStatus;
    private final String message;
}
@Getter
@RequiredArgsConstructor
public class MembershipException extends RuntimeException {

    private final MembershipErrorResult errorResult;

}
@Service
public class MembershipService {

    public Memberhsip addMembership(final String userId, final MembershipType membershipType, final Integer point) {
        return null;
    }
}

 

 

위와 같이 MembershipErrorResult를 작성한 이유는 추후에 MembershipException이 throw 되었을 때 RestControllerAdvice를 통해 MembershipErrorResult의 HttpStatus와 message를 반환하기 위함이다. (TDD를 하면 이러한 고민을 하게 되고, 이는 좋은 설계로 이어지게 된다.)

MembershipException이 RuntimeException(언체크 예외)로 된 이유는 예외 복구 가능성이 없으므로 예외 처리를 개발자가 강제할 필요가 없으며, 트랜잭션 내에서 언체크 예외 만이 자동으로 롤백되기 때문이되고 체크 예외는 롤백되지 않기 때문이다. (물론 옵션으로 체크 예외도 롤백되도록 변경할 수 있다.)

 

위와 같은 클래스들을 추가하였으면 컴파일 에러는 해결이 되었을 것이다. 그리고 테스트 클래스를 실행하면 다음과 같이 예외가 throw되지 않아서 테스트가 실패하게 된다. (테스트를 작성하고 프로덕션 코드를 작성하고 테스트를 빠르게 실행하는게 TDD의 흐름이다.)

Expected com.mang.atdd.membership.app.exception.MembershipException to be thrown, 
but nothing was thrown. org.opentest4j.AssertionFailedError: 
Expected com.mang.atdd.membership.app.exception.MembershipException to be thrown, but nothing was thrown.

 

 

위의 에러를 해결하기 위해 MembershipRepository에서 Membership을 조회하여 있으면 throw하도록 기존의 코드를 수정하도록 하자.

@Service
@RequiredArgsConstructor
public class MembershipService {

    private final MembershipRepository membershipRepository;

    public Membership addMembership(final String userId, final MembershipType membershipType, final Integer point) {
        final Membership result = membershipRepository.findByUserIdAndMembershipType(userId, membershipType);
        if (result != null) {
            throw new MembershipException(MembershipErrorResult.DUPLICATED_MEMBERSHIP_REGISTER);
        }

        return null;
    }
}

 

 

그러면 이제 중복되어 실패하는 멤버십 등록 테스트는 통과하게 된다. 실패 테스트에 대한 작성이 마무리 되었으니 멤버십 등록에 성공하는 테스트 코드를 추가로 작성하도록 하자. 멤버십 등록의 응답값으로는 멤버십 id와 멤버십 타입을 반환해주어야 하므로 두 값이 잘 반환되는지를 통해 검증하도록 하자.

이에 대한 테스트 코드를 작성하면 다음과 같다.

@Test
public void 멤버십등록성공() {
    // given
    doReturn(null).when(membershipRepository).findByUserIdAndMembershipType(userId, membershipType);
    doReturn(membership()).when(membershipRepository).save(any(Membership.class));
 
    // when
    final Membership result = target.addMembership(userId, membershipType, point);

    // then
    assertThat(result.getId()).isNotNull();
    assertThat(result.getMembershipType()).isEqualTo(MembershipType.NAVER);

    // 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.NAVER)
            .build();
}

 

 

이번에는 추가로 membershipRepository를 통해 메소드가 호출되었는지를 검증하는 verify 단계까지 추가로 작성해보았다.

그리고 테스트를 실행하시면 result가 null이므로 java.lang.NullPointerException이 발생한다. 해당 테스트를 통과하기 위해서는 MemberService를 다음과 같이 수정해야 한다.

@Service
@RequiredArgsConstructor
public class MembershipService {

    private final MembershipRepository membershipRepository;

    public Membership addMembership(final String userId, final MembershipType membershipType, final Integer point) {
        final Membership result = membershipRepository.findByUserIdAndMembershipType(userId, membershipType);
        if (result != null) {
            throw new MembershipException(MembershipErrorResult.DUPLICATED_MEMBERSHIP_REGISTER);
        }

        final Membership membership = Membership.builder()
                .userId(userId)
                .point(point)
                .membershipType(membershipType)
                .build();

        return membershipRepository.save(membership);
    }
}

 

 

위와 같이 addMembership 함수를 구현하면 테스트를 통과하고, 초록막대를 볼 수 있다.

 

끝났다고 착각할 수 있지만, 아직 끝나지 않았다. 왜냐하면 리팩토링의 단계가 진행되지 않았기 때문이다.

위와 같은 코드를 보니 MembershipService에서 컨트롤러로 반환하는 객체는 Membership 엔티티이다. 엔티티를 반환하는 것 보다는 DTO를 반환하는 것이 바람직하므로 DTO를 반환하도록 리팩토링하도록 하자.

(엔티티가 아닌 DTO로 반환해야 하는 이유에 대해서는 여기의 내용을 참고해주세요)

 

우리는 TDD로 개발하고 있으므로 프로덕션 코드(MembershipService)가 아닌 테스트 코드부터 수정하도록 하자.

@Test
public void 멤버십등록성공() {
    // given
    doReturn(null).when(membershipRepository).findByUserIdAndMembershipType(userId, membershipType);
    doReturn(membership()).when(membershipRepository).save(any(Membership.class));

    // when
    final MembershipResponse result = target.addMembership(userId, membershipType, point);

    // then
    assertThat(result.getId()).isNotNull();
    assertThat(result.getMembershipType()).isEqualTo(MembershipType.NAVER);

    // verify
    verify(membershipRepository, times(1)).findByUserIdAndMembershipType(userId, membershipType);
    verify(membershipRepository, times(1)).save(any(Membership.class));
}

 

 

그리고 컴파일 에러를 해결하기 위해 다음과 같은 MembershipResponse 클래스를 추가하고, MembershipService를 수정해야 한다. 우선 빨리 현재의 상황을 해결하기 위해 null로 반환하도록 하자.

@Getter
@Builder
@RequiredArgsConstructor
public class MembershipResponse {

    private final Long id;
    private final MembershipType membershipType;

}

@Service
@RequiredArgsConstructor
public class MembershipService {

    private final MembershipRepository membershipRepository;

    public MembershipResponse addMembership(final String userId, final MembershipType membershipType, final Integer point) {
        final Membership result = membershipRepository.findByUserIdAndMembershipType(userId, membershipType);
        if (result != null) {
            throw new MembershipException(MembershipErrorResult.DUPLICATED_MEMBERSHIP_REGISTER);
        }

        final Membership membership = Membership.builder()
                .userId(userId)
                .point(point)
                .membershipType(membershipType)
                .build();

        membershipRepository.save(membership);

        return null;
    }
}

 

 

그리고 테스트를 실행하면 앞서 봤던 NullPointerException이 발생한다. 테스트를 성공시켜 초록 막대를 보기 위해서는 다음과 같이 작성한 프로덕션 코드를 수정해야 한다.

@Service
@RequiredArgsConstructor
public class MembershipService {

    private final MembershipRepository membershipRepository;

    public MembershipResponse addMembership(final String userId, final MembershipType membershipType, final Integer point) {
        final Membership result = membershipRepository.findByUserIdAndMembershipType(userId, membershipType);
        if (result != null) {
            throw new MembershipException(MembershipErrorResult.DUPLICATED_MEMBERSHIP_REGISTER);
        }

        final Membership membership = Membership.builder()
                .userId(userId)
                .point(point)
                .membershipType(membershipType)
                .build();

        final Membership savedMembership = membershipRepository.save(membership);

        return MembershipResponse.builder()
                .id(savedMembership.getId())
                .membershipType(savedMembership.getMembershipType())
                .build();
    }
}

 

그러면 이제 서비스 계층에 대한 개발이 완료된 것이다.

 

이제 이를 이용하는 컨트롤러 계층을 개발할 차례이다. 컨트롤러를 개발할 때에도 테스트 코드를 작성하고, 프로덕션 코드를 작성하며 꾸준히 테스트를 실행하는 TDD의 단계를 숙지한 상태로 진행하도록 하자.

 

 

 

[ Controller 계층 개발 ]

컨트롤러 역시 다른 계층을 개발할 때와 마찬가지로 테스트 코드를 먼저 작성하도록 하자.

컨트롤러는 함수 호출이 아닌 API 호출을 통해 요청을 받고 응답을 처리해야 하며, 메세지 컨버팅 등과 같은 작업이 필요하다. 그러므로 MockMvc라는 클래스를 이용해야 하는데, 이에 대한 초기화를 하는 테스트부터 작성하도록 하자.

@ExtendWith(MockitoExtension.class)
public class MembershipControllerTest {

    private MembershipController target;
    
    private MockMvc mockMvc;
    private Gson gson;

    @Test
    public void mockMvc가Null이아님() throws Exception {
        assertThat(target).isNotNull();
        assertThat(mockMvc).isNotNull();
    }
}

 

 

위와 같이 테스트를 작성하고 실행하면 실패한다. 이를 해결하기 위해 다음과 같이 테스트를 수정해야 한다.

@ExtendWith(MockitoExtension.class)
public class MembershipControllerTest {

    @InjectMocks
    private MembershipController target;
    
    private MockMvc mockMvc;
    private Gson gson;

    @Test
    public void mockMvc가Null이아님() throws Exception {
        mockMvc = MockMvcBuilders.standaloneSetup(target)
                .build();
        assertThat(target).isNotNull();
        assertThat(mockMvc).isNotNull();
    }
}

 

 

그러면 테스트를 통과하고 초록막대를 보게 된다. 그리고 이제 리팩토링의 단계이다.

위의 테스트 코드를 보아하니 MockMvc는 다른 테스트에서도 사용이 될 것으로 보인다. 그러므로 각각의 테스트마다 독립적으로 해당 객체를 만들어주면 좋을 텐데, 이를 위해 각각의 테스트가 실행되기 전에 초기화를 도와주는 @BeforeEach 를 사용하도록 하자.

@ExtendWith(MockitoExtension.class)
public class MembershipControllerTest {

    @InjectMocks
    private MembershipController target;
    
    private MockMvc mockMvc;
    private Gson gson;

    @BeforeEach
    public void init() {
        mockMvc = MockMvcBuilders.standaloneSetup(target)
                .build();
    }
    
    @Test
    public void mockMvc가Null이아님() throws Exception {
        assertThat(target).isNotNull();
        assertThat(mockMvc).isNotNull();
    }
}

 

 

그러면 이제 mockMvc가 Null인지를 검사하는 테스트는 더 이상 필요가 없다. 그러므로 이 테스트는 제거하고 init 함수만 남긴 채로 API 개발에 들어가도록 하자. 참고로 컨트롤러 테스트를 위해 @WebMvcTest를 이용할수도 있다. 하지만 @WebMvcTest를 이용하면 테스트 속도가 느리므로 직접 MockMvc를 만들어주도록 하자.

 

 

 

API 호출 시에 발생하는 여러 개의 실패 케이스 중에서 사용자 식별값이 헤더에 없어서 실패하는 케이스부터 작성해보도록 하자.

@ExtendWith(MockitoExtension.class)
public class MembershipControllerTest {

    @InjectMocks
    private MembershipController target;
    
    private MockMvc mockMvc;
    private Gson gson;

    @BeforeEach
    public void init() {
        gson = new Gson();
        mockMvc = MockMvcBuilders.standaloneSetup(target)
                .build();
    }

    @Test
    public void 멤버십등록실패_사용자식별값이헤더에없음() throws Exception {
        // given
        final String url = "/api/v1/memberships";

        // when
        final ResultActions resultActions = mockMvc.perform(
                MockMvcRequestBuilders.post(url)
                        .content(gson.toJson(membershipRequest(10000, MembershipType.NAVER)))
                        .contentType(MediaType.APPLICATION_JSON)
        );

        // then
        resultActions.andExpect(status().isBadRequest());
    }

    private MembershipRequest membershipRequest(final Integer point, final MembershipType membershipType) {
        return MembershipRequest.builder()
                .point(point)
                .membershipType(membershipType)
                .build();
    }
}

 

 

Gson도 다른 API 호출마다 사용될 것 같으니 @BeforeEach에서 바로 넣어두도록 하자. (앞선 mockMvc를 초기화하는 작업처럼 중복될 것으로 충분히 예상가능하므로 리팩토링 단계를 건너뛰고 바로 @BeforeEach로 넣은 것이다.)

 

그리고 아직 테스트코드에는 컴파일 에러가 남아있다. 컴파일에러를 해결하기 위해 다음과 같이 클래스들을 추가해줄 수 있다.

@RestController
public class MembershipController {
    
}

@Getter
@Builder
@RequiredArgsConstructor
@NoArgsConstructor(force = true)
public class MembershipRequest {

    private final Integer point;
    private final MembershipType membershipType;

}

 

 

여기서 MembershipRequest 객체는 Json으로 보내진 메세지가 Spring의 메세지 컨버터를 통해 변환될 객체이다. Spring은 Json 메세지를 @RequestBody가 붙은 DTO로 변환할 때 기본 생성자를 만들고, 해당 데이터들에 값을 Reflection을 통해 값을 주입하여 변환을 한다. 그러므로 Setter가 필요 없으며 MembershipResponse와 달리 기본 생성자를 만들어주는 @NoArgsConstructor(force = true)를 추가해주었다.

 

그리고 테스트를 실행하면 아직 API를 개발하지 않았으므로 당연히 404 Not Found가 발생하게 된다. 멤버십 추가에 대한 프로덕션 코드를 다음과 같이 작성할 수 있다.

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class MembershipConstants {

    public final static String USER_ID_HEADER = "X-USER-ID";

}

@RestController
public class MembershipController {

    @PostMapping("/api/v1/memberships")
    public ResponseEntity<MembershipResponse> addMembership(
            @RequestHeader(USER_ID_HEADER) final String userId,
            @RequestBody final MembershipRequest membershipRequest) {

        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}

 

 

그리고 사용자 식별값이 헤더에 없어 실패하는 테스트 케이스는 통과를 하게 된다.

이번에는 사용자가 보낸 데이터가 포인트가 음수이거나 null인 경우 또는 멤버십 타입이 null인 경우에 실패하는 테스트 코드를 작성해보도록 하자. 이에 대한 테스트 코드를 추가하면 다음과 같다.

@Test
public void 멤버십등록실패_포인트가null() throws Exception {
    // given
    final String url = "/api/v1/memberships";

    // when
    final ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.post(url)
                    .header(USER_ID_HEADER, "12345")
                    .content(gson.toJson(membershipRequest(null, MembershipType.NAVER)))
                    .contentType(MediaType.APPLICATION_JSON)
    );

    // then
    resultActions.andExpect(status().isBadRequest());
}

@Test
public void 멤버십등록실패_포인트가음수() throws Exception {
    // given
    final String url = "/api/v1/memberships";

    // when
    final ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.post(url)
                    .header(USER_ID_HEADER, "12345")
                    .content(gson.toJson(membershipRequest(-1, MembershipType.NAVER)))
                    .contentType(MediaType.APPLICATION_JSON)
    );

    // then
    resultActions.andExpect(status().isBadRequest());
}

@Test
public void 멤버십등록실패_멤버십종류가Null() throws Exception {
    // given
    final String url = "/api/v1/memberships";

    // when
    final ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.post(url)
                    .header(USER_ID_HEADER, "12345")
                    .content(gson.toJson(membershipRequest(10000, null)))
                    .contentType(MediaType.APPLICATION_JSON)
    );

    // then
    resultActions.andExpect(status().isBadRequest());
}

 

 

원래라면 각각의 테스트가 통과된 후에 다음의 테스트를 작성해야 한다. 하지만 위의 3가지 테스트는 너무 유사하기 때문에 한번에 작성을 진행하였다.

그리고 테스트를 실행하면 실패했다고 나온다. 왜냐하면 해당 값들에 대한 유효성 검사가 진행되지 않았기 때문이다. 테스트를 통과하도록 유효성 검사를 진행해야 하는데, 이 경우에는 Javax의 Validation 기능인 @Valid을 이용하도록 하자.

(만약 Javax의 @Valid에 대해 잘 모른다면 여기를 참고해주세요)

Javax의 Validation을 통해 유효성 검증을 진행하도록 다음과 같이 프로덕션 코드를 수정할 수 있다.

@Getter
@Builder
@RequiredArgsConstructor
@NoArgsConstructor(force = true)
public class MembershipRequest {

    @NotNull
    @Min(0)
    private final Integer point;

    @NotNull
    private final MembershipType membershipType;

}

@RestController
public class MembershipController {

    @PostMapping("/api/v1/memberships")
    public ResponseEntity<MembershipResponse> addMembership(
            @RequestHeader(USER_ID_HEADER) final String userId,
            @RequestBody @Valid final MembershipRequest membershipRequest) {

        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}

 

 

그러면 3가지 테스트가 모두 성공을 하게 된다. 그 다음에 진행해야 하는 부분은 memberService에서 MembershipException을 Throw한 경우이다. 이를 해결하기 위해서는 @RestControllerAdvice를 이용해보고자 한다.

MembershipService에서 멤버십이 이미 등록되어 중복된 경우, MembershipException이 throw된 테스트 코드를 작성해보도록 하자.

@Mock
private MembershipService membershipService;

... 테스트 코드 생략

@Test
public void 멤버십등록실패_MemberService에서에러Throw() throws Exception {
    // given
    final String url = "/api/v1/memberships";
    doThrow(new MembershipException(MembershipErrorResult.DUPLICATED_MEMBERSHIP_REGISTER))
            .when(membershipService)
            .addMembership("12345", MembershipType.NAVER, 10000);

    // when
    final ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.post(url)
                    .header(USER_ID_HEADER, "12345")
                    .content(gson.toJson(membershipRequest(10000, MembershipType.NAVER)))
                    .contentType(MediaType.APPLICATION_JSON)
    );

    // then
    resultActions.andExpect(status().isBadRequest());
}

 

 

테스트는 당연히 실패하게 되고, 그 이유를 분석해보면 controller에서 memberService를 이용하지 않기 때문에 throw가 되지 않는 것이다. 그래서 Controller를 다음과 같이 수정해주어야 한다.

@RestController
@RequiredArgsConstructor
public class MembershipController {

    private final MembershipService membershipService;

    @PostMapping("/api/v1/memberships")
    public ResponseEntity<MembershipResponse> addMembership(
            @RequestHeader(USER_ID_HEADER) final String userId,
            @RequestBody @Valid final MembershipRequest membershipRequest) {

        membershipService.addMembership(userId, membershipRequest.getMembershipType(), membershipRequest.getPoint());

        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}

 

 

그리고 테스트를 실행하면 다음과 같은 에러 로그를 만나게 된다.

Request processing failed; nested exception is com.mang.atdd.membership.app.exception.MembershipException
org.springframework.web.util.NestedServletException: Request processing failed; 
nested exception is com.mang.atdd.membership.app.exception.MembershipException

 

 

이러한 에러가 발생하는 이유는 MembershipException 예외를 잡아 HttpStatus와 에러 메세지로 반환하도록 도와주는 @RestControllerAdvice 부분이 개발되었지 않았기 때문이다. MembershipException이 발생하면 특정한 형태로 에러를 반환하도록 도와주는@RestControllerAdvice를 다음과 같이 만들어 줄 수 있다.

@Getter
@RequiredArgsConstructor
public enum MembershipErrorResult {

    DUPLICATED_MEMBERSHIP_REGISTER(HttpStatus.BAD_REQUEST, "Duplicated Membership Register Request"),
    UNKNOWN_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "Unknown Exception"),
    ;

    private final HttpStatus httpStatus;
    private final String message;

}

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            final MethodArgumentNotValidException ex,
            final HttpHeaders headers,
            final HttpStatus status,
            final WebRequest request) {

        final List<String> errorList = ex.getBindingResult()
                .getAllErrors()
                .stream()
                .map(DefaultMessageSourceResolvable::getDefaultMessage)
                .collect(Collectors.toList());

        log.warn("Invalid DTO Parameter errors : {}", errorList);
        return this.makeErrorResponseEntity(errorList.toString());
    }

    private ResponseEntity<Object> makeErrorResponseEntity(final String errorDescription) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse(HttpStatus.BAD_REQUEST.toString(), errorDescription));
    }

    @ExceptionHandler({MembershipException.class})
    public ResponseEntity<ErrorResponse> handleRestApiException(final MembershipException exception) {
        log.warn("MembershipException occur: ", exception);
        return this.makeErrorResponseEntity(exception.getErrorResult());
    }

    @ExceptionHandler({Exception.class})
    public ResponseEntity<ErrorResponse> handleException(final Exception exception) {
        log.warn("Exception occur: ", exception);
        return this.makeErrorResponseEntity(MembershipErrorResult.UNKNOWN_EXCEPTION);
    }

    private ResponseEntity<ErrorResponse> makeErrorResponseEntity(final MembershipErrorResult errorResult) {
        return ResponseEntity.status(errorResult.getHttpStatus())
                .body(new ErrorResponse(errorResult.name(), errorResult.getMessage()));
    }

    @Getter
    @RequiredArgsConstructor
    static class ErrorResponse {
        private final String code;
        private final String message;
    }

}

 

 

위와 같이 @RestControllerAdvice를 구현하면, 모든 컨트롤러에서 해당 Exception이 발생하였을 때 전역적으로 이를 잡고 처리하게 된다. 위에서는 만약 우리가 작성한 MembershipException이 아니라 예상치 못한 Exception이 발생한 경우에도 처리를 해주도록 하였다.

그리고 오버라이딩된 handleMethodArgumentNotValid는 @Valid에서 예외가 발생할 경우에 처리하게 되는데, 발생한 에러들을 갖고 있는 MethodArgumentNotValidException에서 에러 메세지들을 얻어, 이를 메세지로 반환하도록 하고 있다.

만약 에러가 발생할 경우 다음과 같은 포맷으로 에러 메세지가 반환되는 것이다.

{
    "code": "MEMBERSHIP_NOT_FOUND",
    "message": "Membership Not found"
}

 

 

@WebMvcTest를 이용하면 GlobalExceptionHandler가 자동적으로 추가되었겠지만 우리는 현재 직접 MockMvc를 생성해주었다. 그러므로 MockMvc 생성 과정에도 GlobalExceptionHandler를 추가해주어야 하는데, @BeforeEach가 있는 Init을 다음과 같이 수정해주면 된다.

@ExtendWith(MockitoExtension.class)
public class MembershipControllerTest {

    @InjectMocks
    private MembershipController target;
    
    private MockMvc mockMvc;
    private Gson gson;

    @BeforeEach
    public void init() {
        gson = new Gson();
        mockMvc = MockMvcBuilders.standaloneSetup(target)
                .setControllerAdvice(new GlobalExceptionHandler())
                .build();
    }

}

 

 

그리고 마지막으로 멤버십 등록이 성공하는 테스트 케이스를 작성해야 한다. POST 요청에 대한 표준 응답은 201 Created이므로, 이를 참고하여 테스트 코드를 작성하면 다음과 같다.

@Test
public void 멤버십등록성공() throws Exception {
    // given
    final String url = "/api/v1/memberships";
    final MembershipResponse membershipResponse = MembershipResponse.builder()
            .id(-1L)
            .membershipType(MembershipType.NAVER).build();

    doReturn(membershipResponse).when(membershipService).addMembership("12345", MembershipType.NAVER, 10000);

    // when
    final ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.post(url)
                    .header(USER_ID_HEADER, "12345")
                    .content(gson.toJson(membershipRequest(10000, MembershipType.NAVER)))
                    .contentType(MediaType.APPLICATION_JSON)
    );

    // then
    resultActions.andExpect(status().isCreated());

    final MembershipResponse response = gson.fromJson(resultActions.andReturn()
            .getResponse()
            .getContentAsString(StandardCharsets.UTF_8), MembershipResponse.class);

    assertThat(response.getMembershipType()).isEqualTo(MembershipType.NAVER);
    assertThat(response.getId()).isNotNull();
}

 

 

그리고 테스트 코드를 실행하면 반환된 String을 변한한 MembershipResponse가 null이라 테스트가 실패하게 된다. 이를 해결하기 위해 MembershipController에서 MembershipResponse를 반환하도록 다음과 같이 구현할 수 있다.

@RestController
@RequiredArgsConstructor
public class MembershipController {

    private final MembershipService membershipService;

    @PostMapping("/api/v1/memberships")
    public ResponseEntity<MembershipResponse> addMembership(
            @RequestHeader(USER_ID_HEADER) final String userId,
            @RequestBody @Valid final MembershipRequest membershipRequest) {

        final MembershipResponse membershipResponse = membershipService.addMembership(userId, membershipRequest.getMembershipType(), membershipRequest.getPoint());

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(membershipResponse);
    }

}

 

 

그러면 이제 멤버십을 등록하는 API까지 정상적으로 개발이 완료된 것이다.

처음 개발을 진행하였기 때문에 다른 로직에서도 사용되는 코드들도 개발하느라 해야하는 작업들이 비교적 많았다. 공통 작업들은 다른 API를 개발할 때에 재사용될 것이고, 이후의 작업들에서는 개발해야 하는 양이 줄어들 것이다.

그런데 여기서 끝내지말고 테스트 코드를 리팩토링 해보도록 하자. TDD에서 리팩토링의 대상은 프로덕션 코드 뿐만 아니라 테스트 코드 역시 대상임을 인지해야 한다. TDD에서 테스트 코드는 프로덕션 코드 만큼이나 중요하다.

우리가 작성한 멤버십 등록이 실패하는 다음의 3가지 테스트는 코드가 상당히 유사하고 파라미터만 다르다. 즉, 중복인 것이다.

Junit5에서는 동일한 테스트 케이스에 대해 파라미터를 다르게 실행할 수 있는 기능을 @ParameterizedTest를 통해서 제공한다. 그러므로 @ParameterizedTest를 통해서 3가지 케이스를 1개의 테스트로 만들고 파라미터만 다르게 하여 중복을 제거하도록 하자.

먼저 @Test대신 @ParameterizedTest 어노테이션을 붙여주고 @MethodSource로 파라미터를 작성한 함수 이름을 적어주면 된다. 그리고 아래와 같이 파라미터를 넘겨주는 함수를 작성하고 테스트를 실행해보면 테스트가 통과함을 볼 수 있다.

@ParameterizedTest
@MethodSource("invalidMembershipAddParameter")
public void 멤버십등록실패_잘못된파라미터(final Integer point, final MembershipType membershipType) throws Exception {
    // given
    final String url = "/api/v1/memberships";

    // when
    final ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.post(url)
                    .header(USER_ID_HEADER, "12345")
                    .content(gson.toJson(membershipRequest(point, membershipType)))
                    .contentType(MediaType.APPLICATION_JSON)
    );

    // then
    resultActions.andExpect(status().isBadRequest());
}

private static Stream<Arguments> invalidMembershipAddParameter() {
    return Stream.of(
            Arguments.of(null, MembershipType.NAVER),
            Arguments.of(-1, MembershipType.NAVER),
            Arguments.of(10000, null)
    );
}

 

 

실행한 결과는 다음과 같이 1개의 테스트에 대해 여러 테스트 케이스가 존재하는 형태로 나오게 된다.

 

 

파라미터로 값을 넘겨주는 방법에는 MethodSource 외에도 다양한 방법들이 있으니 공식 문서를 참고하도록 하자.

 

 

 

모든 코드들에서 지역 변수와 파라미터 또는 멤버 변수까지 모두 final을 선언해주고 있다. final 키워드는 거의 모든 경우에 붙여주는 것이 좋은데, final을 사용해야 하는 이유는 여기의 내용을 통해 확인하도록 하자.

인텔리제이에서 변수를 자동완성 해주는 단축키를 자주 사용하는데, 자동으로 final을 붙여주는 설정을 해주면 상당히 편리하다.

맥북의 단축키와 관련해 숙지가 부족하다면 여기를 참고하여 개발할 때 사용하여 생산성을 높이도록 하자.

 

 

 

 

드디어 첫 API의 개발이 마무리 되었다. TDD의 작업 흐름도 배우고, 공통되는 코드도 개발하는 등의 이유로 쉽지 않았다. 다음 작업은 이보다 훨씬 간결해질 것이고, TDD에 조금씩 적응할 수 있을 것이다.

다음과 같이 진행되었던 TDD의 작업 흐름을 기억하고, 다음으로는 멤버십 상세 조회 및 목록 조회 API를 개발해보도록 하자.

 

  1. 실패하는 작은 단위 테스트를 작성한다. 처음에는 컴파일조차 되지 않을 수 있다.
  2. 빨리 테스트를 통과하기 위해 프로덕션 코드를 작성한다. 이를 위해 정답이 아닌 가짜 구현 등을 작성할 수도 있다.
  3. 그 다음의 테스트 코드를 작성한다. 실패 테스트가 없을 경우에만 성공 테스트를 작성한다.
  4. 새로운 테스트를 통과하기 위해 프로덕션 코드를 추가 또는 수정한다.
  5. 1~4단계를 반복하여 실패/성공의 모든 테스트 케이스를 작성한다.
  6. 개발된 코드들에 대해 모든 중복을 제거하며 리팩토링한다.

 

 

 

위에서 작성한 내용들은 클린코드나 이펙티브 자바, 테스트 주도 개발 By Example 등의 책과 실무 경험 등을 종합하여 개인적인 생각을 정리한 글입니다. 옳고 그른 얘기들 및 부족한 점 충분히 있을 수 있는데, 피드백이나 커멘트 등은 언제나 환영합니다:)

코드는 깃허브에 공개되어 있습니다! 코드를 확인하시려면 여기를 참고해주세요:)

 

 

관련 포스팅

  1. 단위 테스트와 TDD(테스트 주도 개발) 프로그래밍 방법 소개 - (1/5)
  2. TDD 연습문제 소개와 요구사항 분석 및 SpringBoot 프로젝트 설정 - (2/5)
  3. TDD로 멤버십 등록 API 구현 예제 - (3/5)
  4. TDD로 멤버십 상세/전체 조회 API 구현 예제 - (4/5)
  5. TDD로 멤버십 삭제 및 포인트 적립 API 구현 예제 - (5/5)

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2024/04   »
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
글 보관함