육각형 아키텍처의 각 요소 테스트 전략과 유형
맥락에 따라 테스트 피라미드에 포함되는 계층은 달라질 수 있다.
도메인 엔티티에 녹아 있는 비즈니스 규칙 검증
도메인 엔티티의 행동은 다른 클래스에 거의 의존하지 않기 때문에 다른 종류의 테스트는 필요하지 않다.
Mockito
라이브러리로 given..
메서드의 mock 객체 생성
then()
: mock 객체에 대해 특정 메서드가 호출되었는지 검증할 수 있는 메서드
특정 상태를 검증하는 것이 아니라, 모킹된 객체의 특정 메서드와 상호작용했는가를 검증
단점: 테스트가 코드의 행동 변경뿐만 아니라 구조 변경에도 취약해진다. = 테스트 변경 확률 ↑
@WebMvcTest(controllers = {SendMoneyController.class})
class SendMoneyControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SendMoneyUseCase sendMoneyUseCase;
@Test
void sendMoney() throws Exception {
String sendMoneyUrl = "/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}";
long sourceAccountId = 1L;
long targetAccountId = 2L;
long amount = 500;
ResultActions resultActions = mockMvc.perform(
post(sendMoneyUrl, sourceAccountId, targetAccountId, amount)
.contentType(MediaType.APPLICATION_JSON)
);
resultActions.andExpect(status().isOk());
then(sendMoneyUseCase).should()
.sendMoney(eq(new SendMoneyCommand(
new AccountId(sourceAccountId),
new AccountId(targetAccountId),
Money.of(amount)
)));
}
}
@WebMvcTest
: MockMvc
에 관한 설정을 자동으로 수행, 특정 컨트롤러 클래스와 관련 설정들을 스캔한다.MockMvc
: 서버 입장에서 구현한 API를 통해 비즈니스 로직이 문제없이 수행되는지 테스트. Servlet Container를 생성하지 않는다.@MockBean
: 사용할 서비스 인터페이스를 모킹resultActions.andExpert()
: HTTP 응답이 기대한 상태를 반환했는가then()
SendMoneyCommand
: 유스케이스에 구문적으로 ㅠ효한 입력인가컨트롤러 클래스만 테스트하는 것처럼 보이지만, @WebMvcTest
가 스프링이 특정 요청 경로, 자바↔ JSON 매핑, HTTP 입력 검증 등 필요한 모든 네트워크를 인스턴스화한다. 웹 컨트롤러는 이 네트워크의 일부로서 동작하는 것이다.
웹 컨트롤러는 스프링 프레임워크와 강하게 결합되어있기 때문에 단위 테스트보다는 프레임워크와 통합된 상태로 테스트하는 것이 합리적이다.
프레임워크를 구성하는 요소들이 프로덕션 환경에서 정상적으로 작동할지 확신할 수 없다.
웹 어댑터를 통합 테스트하는 이유과 마찬가지로 영속성 어댑터도 통합 테스트를 적용한다.
어댑터의 로직만이 아니라 데이터베이스 매핑도 검증
@DataJpaTest
@Import({AccountPersistenceAdapter.class, AccountMapper.class})
class AccountPersistenceAdapterTest {
@Autowired
private AccountPersistenceAdapter accountPersistenceAdapter;
@Autowired
private ActivityRepository activityRepository;
@Test
@Sql("AccountPersistenceAdapterTest.sql")
void loadAccount() {
Account account = accountPersistenceAdapter.loadAccount(new AccountId(1L), LocalDateTime.of(2018, 8, 10, 0, 0));
assertAll(
() -> assertThat(account.getActivityWindow().getActivities()).hasSize(2),
() -> assertThat(account.calculateBalance()).isEqualTo(Money.of(500))
);
}
}
@DataJpaTest
: 데이터베이스 접근에 필요한 객체 네트워크(spring data repository 포함)를 인스턴스화해야함을 스프링에게 알려준다.@Import
: 특정 객체가 이 네트워크에 추가됨을 명확히 표현@Sql("AccountPersistenceAdapterTest.sql")
: sql 스크립트로 데이터베이스를 특정 상태로 만든다.⇒ 실제 데이터베이스를 대상으로 진행해야 한다.
두 개의 다른 데이터베이스 시스템을 신경 쓸 필요도 없어진다.
전체 애플리케이션 띄우고, API로 요청 보내고, 모든 계층이 잘 작동하는지 검증
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class SendMoneySystemTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private LoadAccountPort loadAccountPort;
private String url;
@BeforeEach
void setUp() {
url = "/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}";
}
@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 ResponseEntity whenSendMoney(AccountId sourceAccountId, AccountId targetAccountId, Money money) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Void> requestEntity = new HttpEntity<>(null, headers);
return restTemplate.exchange(
url,
HttpMethod.POST,
requestEntity,
Object.class,
sourceAccountId,
targetAccountId,
money.amount()
);
}
}
@SpringBootTest
: Spring이 애플리케이션을 구성하는 모든 객체 네트워크를 띄우게 한다.TestRestTemplate
: MockMvc
를 사용하지 않음. Servlet Container를 생성해서 프로덕션 환경에 조금 더 가깝게 만들기 위해 실제 HTTP 통신을 한다.LoadAccountPort
: 실제 출력 어댑터 이용sourceAccount()
, sourceAccountId()
, ..line coverage는 잘못된 지표
얼마나 마음 편하게 소프트웨어를 배포할 수 있느냐?
= 그만큼 테스트를 신뢰한다는 뜻
테스트가 잡지 못한 버그를 기록하고, 테스트를 추가하고, 배포함을 반복하면서 개선된다.
육각형 아키텍처는 애플리케이션 코어(도메인 로직)와 바깥으로 향한 어댑터를 깔끔하게 분리한다.
⇒ 핵심 도메인 로직 → 단위 테스트, 어댑터 → 통합 테스트 / 테스트 전략 분리 가능
7-5 영속성 어댑터 통합 테스트 / @DataJpaTest
를 사용한 영속성 통합 테스트 - 실제 데이터베이스를 대상으로 진행해야 한다.
@DataJpaTest
Spring Data JPA를 테스트하고자 한다면
@DataJpaTest
기능을 사용해볼 수 있습니다. 이 어노테이션과 함께 테스트를 수행하면 기본적으로 in-memory embedded database를 생성하고@Entity
클래스를 스캔합니다. 일반적인 다른 컴포넌트들은 스캔하지 않습니다.~~
만약 테스트에 in-memory embedded database를 사용하지 않고 real database를 사용하고자 하는 경우,
@AutoConfigureTestDatabase
어노테이션을 사용하면 손쉽게 설정할 수 있습니다.
예제에서는 인메모리 데이터베이스를 사용했다. 실제 프로젝트에서는 추가 설정을 해야한다.
7-6 시스템 테스트 예제 구현시 → SendMoneyService
에서 사용하는 인터페이스 AccountLock
(lockAccount(), releaseAccount() 메서드를 가짐)의 구현체 NoOpAccountLock
의 위치가 application/service에 위치하고 있다. adapter/out/persistence에 위치해야하지 않을까?
평소에 시간에 쫓겨 잘 안하던 테스팅... 이번에 적용해보려고 찾아보려다 여기까지 왔네