이번 장에서는 육각형 아키텍처에서의 테스트 전략에 대해 이야기한다. 아키텍처의 각 요소들을 테스트 할 수 있는 테스트 유형에 대해 논의한다.
-
그림은 테스트의 종류와 어떤 종류의 테스트를 목표로 해야 하는지 결정하는 데 도움을 준다
-
기본 전제
- 만드는 비용이 적어야 한다.
- 유지보수 하기 쉬워야 한다.
- 빨리 실행돼야 한다.
- 안정적인 작은 크기의 테스트들에 대해 높은 커버리지를 유지해야 한다. ⇒ 하나의 단위(클래스)가 재대로 동작하는 지 확인할 수 있는 단위 테스트들이다.
- 피라미드가 위로 올라갈 수록 테스트 비용이 더 비싸지고 실행이 더 느려지며, 설정 에러로 인해 깨지기 더 쉬워진다. ⇒ 테스트가 비싸질 수록 테스트 커버리지의 목표는 낮게 잡아야 한다. ⇒ 기능보다 테스트를 만드는데 시간을 더 쓰게되기 때문이다
-
단위 테스트
- 피라미드의 토대에 해당한다.
- 하나의 클래스를 인스턴스화 하고 해당 클래스의 인터페이스를 통해 기능들을 테스트한다. ** 다른 클레스에 의존하는 경우 의존되는 클래스를 인스턴스화 하지 않고 mock으로 대체한다.
-
통합 테스트
- 연결된 여러 유닛을 인스턴스화 한다 ⇒ 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 잘 작동 하는지 검증 ⇒ 두 계층 간의 경계를 걸쳐서 테스트 할 수 있다 ⇒ 객체 네트워크가 완전하지 않거나 어떤 시점에는 mock을 대상으로 수행한다
-
시스템 테스트
- 어플리케이션을 구성하는 모든 객체 네트워크를 가동시켜 특정 유스케이스가 전 계층에서 잘 동작하는지 검증한다.
- 4장에서 본 Account entitiy 를 테스트한다
class AccountTest{
@Test
void withdrawalSucceeds(){
//step1. Account 인스턴스화
//step2. withdraw() 메서드 호출 => 출금 성공 검증
//step3. Account 객체의 상태에 기대되는 부수효과 잘 일어났는지 확인
}
}
- 특징
- 만들고 이해하기 쉽다. 아주 빠르고 간단하게 실행된다.
- 도메인 엔티티에 녹아 있는 비즈니스 규칙을 검증하기에 적절한 방법이다.
- 도메인 엔티티의 행동은 다른 클래스에 거의 의존하지 않음 ⇒ 도메인 엔티티의 검증에 있어 다른 종류의 테스트는 필요없다
- 4장에서 본 SendMoneyService 의 테스트를 살펴본다.
class SendMoneyServiceTest{
@Test
void trasactionSucceeds(){
/*
출금계좌 lock (다른 트랜젝션에 의해 변경 X)
필요한 만큼 출금
입금계좌 lock , 출금된 만큼 입금
두 계좌의 lock을 해제
이후 결과 검증
*/
# Mockito 라이브러리를 이용해 given...() 메서드의 mock 객체를 생성
# then() : mock 객체에 대해 특정 메서드가 호출됐는지 검증하는 메서드
/*
given/when/then 섹션으로 나눠서 테스트한다
*/
# given
입출금 Account 인스턴스를 각각 생성후
given...()로 시작되는 메서드의 인자로 넣는다
(givenWithdrawalWillSucceed - 출금 ,givenDepositWillSucceed - 입금)
SendMoneyCommand 인스턴스 생성--> 유스케이스의 입력으로 사용
# when
SendMoneyCommand 인스턴스를 인자로 받아 유스케이스의 sendMoney() 호출
# then
then() 메서드를 사용하여...
트랜잭션의 성공 여부 검증
이후 Account 에 걸린 lock을 걸고 해제하는 메서드 작동 검증
}
}
- 유스케이스 서비스는 상태가 없기 때문에 then 섹션에서 특정 상태를 검증할 수 없다
⇒ 상호작용 여부만 검증한다.
⇒ 이는 테스트가 행동 , 코드 구조변경에 취약해짐을 의미한다 (코드의 변경이 테스트의 변경으로 이어진다)
- 테스트에서 어떤 상호작용을 검증하고 싶은지 신중하게 생각해야 한다. ⇒ 모든 동작을 검증하기 보다는, 핵심 기능만 골라 집중해서 테스트 하는 것이 좋다
- 웹 어댑터는 JSON 문자열 등의 형태로 HTTP 를 통해 입력 받고 , 입력에 대한 유효성을 검증한다.
- 이후 유스케이스에서 사용할 수 있는 포맷으로 매핑하고 , 유스케이스에 전달한다.
- 유스케이스의 결과를 JSON으로 매핑하고 HTTP응답을 통해 클라이언트에 반환 한다.
-
웹 어댑터의 테스트는 위의 모든 단계들이 기대한 대로 동작하는지 검증해야 한다.
class SendMoneyControllerTest { @Autowired private MockMvc mockMvc; @MockBean private SendMoneyUseCase sendMoneyUseCase; @Test void sendMoney() throws Exception { long sourceAccountId = 1L; long targetAccountId = 2L; long amount = 500; ResultActions resultActions = mockMvc.perform( post("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}" ,sourceAccountId, targetAccountId, amount) .header("Content-Type","application/json")) .andExpect(statuc().isOK()) ); then(sendMoneyUseCase).should() .sendMoney(eq(new SendMoneyCommand( new AccountId(sourceAccountId), new AccountId(targetAccountId), Money.of(amount) ))); } }
-
mockMvc
Mocking 한 HTTP 요청을 웹 컨트롤러에 보낸다. 요청 바디는 JSON 문자열의 형태로 입력객체를 포함한다 ⇒ 실제 HTTP 프로토콜을 사용하는 것은 아니지만 ,실제 프로토콜에 대한 부분은 프레임워크가 적절히 수행하고 있다고 믿는다.
-
isOk()
⇒ HTTP 응답의 상태가 200임을 검증하고 , 모킹한 유스케이스의 호출을 검증한다.
-
then( …
-
⇒ 유스케이스의 입력 유효성과 , 실제 호출을 검증한다.
컨트롤러 클래스만 테스트하는 것처럼 보이지만, @WebMvcTest
가 스프링이 특정 요청 경로, 자바↔ JSON 매핑, HTTP 입력 검증 등 필요한 모든 네트워크를 인스턴스화한다. 웹 컨트롤러는 이 네트워크의 일부로서 동작하는 것이다.
웹 컨트롤러는 스프링 프레임워크와 강하게 결합되어있기 때문에 단위 테스트보다는 프레임워크와 통합된 상태로 테스트하는 것이 합리적이다.
위와 비슷한 이유로 단순 로직 뿐 아니라 데이터베이스 매핑도 검증해야 하기 때문에 통합테스트를 적용 하는 것이 합리적이다.
@DataJpaTest
@Import({AccountPersistenceAdapter.class, AccountMapper.class})
class AccountPersistenceAdapterTest {
@Autowired
private AccountPersistenceAdapter adapterUnderTest;
@Autowired
private ActivityRepository activityRepository;
@Test
@Sql("AccountPersistenceAdapterTest.sql")
void loadsAccount() {
Account account = adapterUnderTest.loadAccount(new AccountId(1L),
LocalDateTime.of(2018, 8, 10, 0, 0));
assertThat(account.getActivityWindow().getActivities()).hasSize(2);
assertThat(account.calculateBalance()).isEqualTo(Money.of(500));
}
@Test
void updatesActivities() {
Account account = defaultAccount()
.withBaselineBalance(Money.of(555L))
.withActivityWindow(new ActivityWindow(
defaultActivity()
.withId(null)
.withMoney(Money.of(1L)).build()))
.build();
adapterUnderTest.updateActivities(account);
assertThat(activityRepository.count()).isEqualTo(1);
ActivityJpaEntity savedActivity = activityRepository.findAll().get(0);
assertThat(savedActivity.getAmount()).isEqualTo(1L);
}
}
-
@DataJpaTest : 스프링 데이터 레포지토리를 포함해 , 데이터베이스 접근에 필요한 객체 네트워크를 인스턴스화 해야 함을 스프링에 알려준다.
-
@Import : 특정 객체가 이 네트워크에 추가됐다는 것을 명확하게 표현할 수 있다.
⇒ 이 객체들은 테스트 상에서 어댑터가 도메인 객체를 데이터 베이스 객체로 매핑하는 등의 작업에 필요함
-
loadAccount()
⇒ 어댑터 API 로 계좌를 가저온 후 SQL 스크립트에서 설정한 상태값과 일치하는지 검증한다
-
updateActivities()
⇒ 새로운 활동을 가진 Account 인스턴스를 어댑터로 전달 , 이후 잘 저장됐는지 확인한다
이유는?
- 데이터 베이스를 모킹했더라도 높은 커버리지(많은 코드라인을 테스트)를 보여줬을 것이다. 하지만 , 여전히 실제 데이터베이스와 연동했을 때 SQL 구문의 오류, 데이터베이스 테이블과 자바 객체 간의 매핑 에러 등으로 문제가 생길 확률이 높아진다.
- 스프링은 인메모리 데이터베이스를 테스트에서 사용한다 ⇒ 아무것도 설정할 필요가 없어 실용적임 하지만 , 실제 데이터 베이스에서 문제가 생길 가능성이 높다. ex) 데이터베이스 마다 고유한 SQL 문법으로 인한 문제
피라미드 최상단에 있는 시스템 테스트는 전체 어플리케이션을 띄우고 API를 통해 요청을 보내고 , 모든 계층이 조화롭게 잘 동작하는지 검증한다.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class SendMoneySystemTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private LoadAccountPort loadAccountPort;
@Test
@Sql("SendMoneySystemTest.sql")
void sendMoney() {
Money initialSourceBalance = sourceAccount().calculateBalance();
Money initialTargetBalance = targetAccount().calculateBalance();
ResponseEntity response = whenSendMoney(
sourceAccountId(),
targetAccountId(),
transferredAmount());
then(response.getStatusCode())
.isEqualTo(HttpStatus.OK);
then(sourceAccount().calculateBalance())
.isEqualTo(initialSourceBalance.minus(transferredAmount()));
then(targetAccount().calculateBalance())
.isEqualTo(initialTargetBalance.plus(transferredAmount()));
}
private Account sourceAccount() {
return loadAccount(sourceAccountId());
}
private Account targetAccount() {
return loadAccount(targetAccountId());
}
private Account loadAccount(AccountId accountId) {
return loadAccountPort.loadAccount(
accountId,
LocalDateTime.now());
}
private ResponseEntity whenSendMoney(
AccountId sourceAccountId,
AccountId targetAccountId,
Money amount) {
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Type", "application/json");
HttpEntity<Void> request = new HttpEntity<>(null, headers);
return restTemplate.exchange(
"/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
HttpMethod.POST,
request,
Object.class,
sourceAccountId.getValue(),
targetAccountId.getValue(),
amount.getAmount());
}
private Money transferredAmount() {
return Money.of(500L);
}
private Money balanceOf(AccountId accountId) {
Account account = loadAccountPort.loadAccount(accountId, LocalDateTime.now());
return account.calculateBalance();
}
private AccountId sourceAccountId() {
return new AccountId(1L);
}
private AccountId targetAccountId() {
return new AccountId(2L);
}
}
- @SpringBootTest : 스프링이 어플리케이션을 구성하는 모든 객체 네트워크를 띄우게 한다.
- @test method : 요청을 생성하여 어플리케이션에 보내고 , 응답 상태와 계좌의 새로운 잔고를 검증한다.
- 웹 어댑터 테스트처럼 MockMve를 이용하는 것이 아니라 TestRestTemplate을 이용해 요청을 보낸다 . ⇒ 프로덕션 환경에 조금 더 가깝게 실제 HTTP 통신을 하는 것이다.
- 다른 출력 어댑터 들도 필요할 수 있다 ⇒ 육각형 아키텍처 에서는 몇 개의 출력 포트 인터페이스만 모킹하면 된다
- 단위 통합 테스트보다 ,실제 사용자를 잘 흉내 내기 때문에 사용자 관점에서 어플리케이션을 검증할 수 있다.
- 단위, 통합 테스트에서 알아차리지 못했을 게층 간 매핑 버그 등 또 다른 종류의 버그를 발견하여 수정 할 수 있게 해준다
- 라인 커버리지 : 얼마만큼의 실제 코드를 테스트가 커버하고 있는지 ⇒ 테스트의 성공을 축정하는 데 있어서는 잘못된 지표다.
- 높은 커버리지 보다는 얼마나 마음 편하게 배포할 수 있느냐를 테스트의 성공 지표로 삼는 것이 낫다. ⇒ 자주 배포할 수록 테스트를 더 신뢰할 수 있다.
- 잦은 배포 ⇒ 테스트가 잡지 못한 버그에 대해 생각 , 답변 기록 , 해당 케이스를 커버하는 테스트 추가 위 같은 상황이 반복 되면 , 남겨둔 기록으로 시간이 지날수록 상황이 개선되고 있음을 증명해 줄 것이다.
- 도메인 엔티티를 구현할 때 ⇒ 단위 테스트로 커버
- 유스케이스를 구현할 때 ⇒ 단위 테스트로 커버
- 어댑터를 구현할 때 ⇒ 통합 테스트로 커버
- 사용자가 취할 수 있는 중요 어플리케이션 경로 ⇒ 시스텝 테스트로 커버
- 육각형 아키텍처는 도메인 로직과 바깥으로 향한 어댑터를 깔끔하게 분리한다.
[명확한 테스트 전략을 정의할 수 있다]
- 핵심 도메인 로직 ⇒ 단위 테스트
- 어댑터 ⇒ 통합 테스트
- 입/출력 포트는 테스트에서 아주 뚜렷한 모킹 지점이 된다.
- 포트 인터페이스가 적은 메서드를 제공한다 ⇒ 모킹하는 것이 아주 쉬워진다.