프로젝트를 진행하면서 테스트코드를 짜는일은 불가피한 일이다. TDD, DDD 등 다양한 디자인 패턴이 있지만 어찌됐건 결국 테스트 코드를 작성하는 일은 개발자라면(?) 해야할 과업이다.
다음은 테스트 피라미드라고 한다.
(출처:https://research.aimultiple.com/integration-testing-vs-unit-testing/)
통합테스트를 넘어 UI테스트로 갈수록 느려지고, 더 많은 모듈의 통합이 이루어진다. 반대로 내려갈수록 의존성 결합이 느슨해지고, 가볍고 빠른 테스트를 수행할 수 있다.
Domain - Repository - Service - Controller 유사한 기능들을 같은 계층으로 묶어서 관리하는 구조 레이어드 아키텍처
, 의존성 역전이나 추상화 없이 바로 구현체를 사용하는 구조이기에 데이터를 생성하고 저장하는 DB 주도 설계로 인해 불가피하게 통합테스트로 진행을 해왔었다. (지금까지는)
레이어드 계층 순서로 개발을 하게 되는 절차지향적 사고를 가지게 되고, 낮은 Testability와 SOLID 원칙을 어기게 되는건 자명했다.
다음은 기존 아키텍처 구조에서 SOLID 원칙을 지켜 만든 개선된 아키텍처 구조이다. 기존에 구현체였던 Repository를 class가 아닌 interface로 바꾸어 위치시킨다. 영속성 계층에서는 인터페이스의 구현체를 둔다. JpaRepository가 영속성 엔티티를 관리하여 DB와 소통을한다. 레이어드 아키텍처는 Service계층이 Repository계층에 강하게 의존을 했었는데 인터페이스를 둠으로써 의존 관계를 약하게 만들 수 있다. 이는 H2, Mysql 등 RDB를 사용하다 Nosql로 바꾸더라도 코드의 사용성에는 문제가 없다.(OOP)
Service는 Fake나 Mock을 Repository 구현체로 두어 Service를 고립상태로 테스트할 수 있게 한다.(=UnitTest) 또한 의존성 역전 원칙(DIP)
를 지킬 수 있다. Service에 의존하던 Controller가 Service 인터페이스를 두어 의존관계가 역전되게 만든다. 의존성 역전이 고립된 테스트를 수행할 수 있게 하는 핵심이다. 이는 단순 레이어 계층뿐만 아니라 외부 연동(외부 Api, MailSender 등)에도 적용시킬 수 있다.
진행중인 프로젝트를 개선된 아키텍처로 리팩토링하였다. SpringBootTest로 해도 되지만 우리가 원하는 최상의 개발 환경이 아닌, DB가 없는 열악한 환경, 또는 협업을 하는 입장에서 개발자A는 Repository, 개발자B는 Service를 개발 중이라면 결국 테스트를 진행할 수 없다. (과장되게 말하면 MSA와 같은 흐름을 가지고 있다고 해도 되지 않을까?) 하나의 서비스가 중단이 되어도 다른 서비스는 계속 실행할 수 있는, (컨텍스트 스위칭같다.)
@Service
@RequiredArgsConstructor
@Builder
public class PayServiceImpl extends PayService {
private final ApiService apiService;
private final PayRepository payRepository;
private final OrderRepository orderRepository;
private final CardRepository cardRepository;
private final UuidHolder uuidHolder;
}
Service는 아직 interface로 추상화를 시키지 못한 상태다. Repository는 모두 추상화를 완료한 상태로, private final
의 모든 변수들은 다 interface이다. 이를 상속하고 있는 구현체들의 메서드를 사용하여 PayService를 의존성 역전 시킨다.
public CardPayment getByPaymentKey(String paymentKey) {
return payRepository.findByPaymentKeyAndPaymentStatus(paymentKey, DONE);
}
@Transactional
public CardPayment pay(CardPaymentDto.Request request) throws IOException, InterruptedException {
Response response = apiService.callApi(request);
CardPayment approvalPayment = CardPayment.approvalPayment(CardPayment.fromModel(request), response.paymentKey(), response.approvedAt());
approvalPayment = payRepository.save(approvalPayment);
return approvalPayment;
}
Interface Repository (추상화)
public interface PayRepository {
CardPayment save(CardPayment cardPayment);
CardPayment findById(long id);
List<CardPayment> findAll();
CardPayment findByOrderIdAnAndPaymentStatus(String orderUuid, PaymentStatus status);
RepositoryImpl (구현체)
@Repository
@RequiredArgsConstructor
public class PayRepositoryImpl implements PayRepository {
private final JpaPayRepository jpaPayRepository;
@Override
public CardPayment save(CardPayment cardPayment) {
return jpaPayRepository.save(cardPayment);
}
@Override
public CardPayment findById(long id) {
return jpaPayRepository.findById(id)
.orElseThrow((NOT_FOUND_PAY_DETAILS::paymentDetailsException));
}
@Override
public List<CardPayment> findAll() {
return jpaPayRepository.findAll();
}
@Override
public CardPayment findByOrderIdAnAndPaymentStatus(String orderUuid, PaymentStatus status) {
return jpaPayRepository.findByOrderIdAnAndPaymentStatus(orderUuid, PaymentStatus.DONE)
.orElseThrow(NOT_FOUND_PAY_DETAILS::paymentDetailsException);
}
JpaRepository를 의존하면서 PayRepository의 추상화 메서드를 상속받아 구현하였다.
FakeRepository
추상화를 하고, 의존성 역전을 시킨 이유는 계층별 고립을 위해서였다.
이렇게 가짜 객체를 만듦으로써 테스트는 수월해진다.
public class FakePayRepository implements PayRepository {
private final AtomicLong atomicGeneratedId = new AtomicLong(0);
private final List<CardPayment> data = new ArrayList<>();
@Override
public CardPayment findById(long id) {
return data.stream().filter(element -> element.getId().equals(id)).findAny()
.orElseThrow(NOT_FOUND_PAY_DETAILS::paymentDetailsException);
}
@Override
public CardPayment findByOrderIdAnAndPaymentStatus(String orderUuid, PaymentStatus status) {
return data.stream().filter(element -> element.getOrderUuid().equals(orderUuid)
&& element.getPaymentStatus().equals(DONE)).findAny()
.orElseThrow(NOT_FOUND_PAY_DETAILS::paymentDetailsException);
}
Unit Test
@Test
void save로_결제_내역을_저장한다() {
//given
String paymentKey = "5zJ4xY7m0kODnyRpQWGrN2xqGlNvLrKwv1M9ENjbeoPaZdL6";
CardPayment approvalPayment = CardPayment.approvalPayment(cardPayment, paymentKey, LocalDateTime.now());
//when
CardPayment save = payRepository.save(approvalPayment);
//then
assertThat(save.getId()).isEqualTo(1L);
}
톰캣 서버를 켜고 스프링부트를 컴파일 하는데 한세월 걸렸던 코드가 순수 java코드로 작성함으로써 테스트 실행시간은 대폭 감소하였다. 또한 실제 DB 쿼리를 jpa를 통해 타지 않고, 직접 구현하여 테스트 하여 가볍고 빠르다.
객체지향언어인 java로 코딩을 하면서 절차지향으로 나도 모르게 흘러가고 있지 않았나 생각이 든다. 지속적으로 아키텍처를 고민하며 고쳐나가고 있는 중이지만, 개발을 하는 그 순간에도 클래스가 책임을 지지 않고 위임하고 의존성이 결합되어 중형 테스트로 설계하는 그 순간 순간들을 잘 인지하여 개선된 코드로 만들기 위해 노력해야겠다.
큰 도움이 되었습니다, 감사합니다.