하나의 클래스를 인스턴스화하고 해당 클래스의 인터페이스를 통해 기능 테스트, 의존되는 클래스는 mock으로 대체
연결된 여러 유닛을 인스턴스화, 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 잘 동작하는지 검증
애플리케이션을 구성하는 모든 객체 네트워크를 가동시켜 특정 유스케이스가 전 계층에서 잘 동작하는지 검증
assertThat(success).isTrue();
assertThat(account.getActivityWindow().getActivities()).hasSize(3);
assertThat(account.calculateBalance()).isEqualTo(Money.of(1000L));
도메인 엔티티는 withdraw()와 같은 메서드로 상태를 변경하고, 부수효과들이 잘 일어났는지 확인하는 단순한 단위 테스트로 이루어진다. 따라서 테스트 중 간단한 편에 속한다.
@Test
void transactionSucceeds() {
//given
Account sourceAccount = givenSourceAccount();
Account targetAccount = givenTargetAccount();
givenWithdrawalWillSucceed(sourceAccount);
givenDepositWillSucceed(targetAccount);
Money money = Money.of(500L);
SendMoneyCommand command = new SendMoneyCommand(
sourceAccount.getId().get(),
targetAccount.getId().get(),
money);
//when
boolean success = sendMoneyService.sendMoney(command);
//then
assertThat(success).isTrue();
AccountId sourceAccountId = sourceAccount.getId().get();
AccountId targetAccountId = targetAccount.getId().get();
then(accountLock).should().lockAccount(eq(sourceAccountId));
then(sourceAccount).should().withdraw(eq(money), eq(targetAccountId));
then(accountLock).should().releaseAccount(eq(sourceAccountId));
then(accountLock).should().lockAccount(eq(targetAccountId));
then(targetAccount).should().deposit(eq(money), eq(sourceAccountId));
then(accountLock).should().releaseAccount(eq(targetAccountId));
thenAccountsHaveBeenUpdated(sourceAccountId, targetAccountId);
}
간략하게 로직을 설명하자면, sendMoney는 출금 계좌의 잔고가 다른 트랜잭션에 의해 변경되지 않도록 lock을 걸고, 출금이 된다면 입금 계좌에 lock을 걸고 돈을 입금시킨다. 그리고 나서 두 계좌 모두 락을 해제한다.
위의 코드는 가독성을 높이기 위해 BDD에서 사용되는 given, when, then 섹션으로 나눠 작성하였다
의존 대상의 특정 메서드와 상호작용 여부를 검증할 때, 모든 동작을 검증하는 것보다 중요한 핵심만 골라서 테스트하는 것이 유지보수 측면에서 좋다.
도메인 -> 서비스 -> 어댑터
웹 어댑터는 JSON 등으로 http를 통해 입력, 입력에 대한 유효성 검증, 유스케이스 포맷 매핑, 유스케이스로 전달 하는 과정을 거친다. 그 후, 반대 과정을 거쳐 클라이언트에게 반환하였다.
@WebMvcTest(controllers = SendMoneyController.class)
class SendMoneyControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SendMoneyUseCase sendMoneyUseCase;
@Test
void testSendMoney() throws Exception {
mockMvc.perform(post("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
41L, 42L, 500)
.header("Content-Type", "application/json"))
.andExpect(status().isOk());
then(sendMoneyUseCase).should()
.sendMoney(eq(new SendMoneyCommand(
new AccountId(41L),
new AccountId(42L),
Money.of(500L))));
}
}
isOk() 메서드로 http 응답 상태가 200임을 통해 SendMoneyCommand(service로 넘기는 dto)로 잘 매핑되었는지, 모킹한 유스케이스가 잘 호출되는지 검증한다.
@WebMvcTest
애너테이션은 스프링이 특정 요청 경로, 자바와 json 간의 매핑, http 입력 검증 등에 필요한 전체 객체 네트워크를 인스턴스화하도록 만들고, 테스트에서 웹 컨트롤러가 이 네트워크의 일부로서 잘 동작하는지 검증하기에 통합 테스트라고 본다.
@DataJpaTest //Spring Data Repository를 포함한 DB접근에 필요한 객체 네트워크를 인스턴스화
@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));
}
@DataJpaTest
애노테이션으로 Spring Data Repository를 포함한 DB접근에 필요한 객체 네트워크를 인스턴스화한다.
@Import
애노테이션으로 특정 객체가 이 네트워크에 포함되었다를 명시한다.
이 테스트는 DB를 모킹하지 않고, 실제 DB에 접근하고 있다. 이러한 테스트를 진행한다면, 프로덕션 환경에서는 인메모리 DB를 사용하지 않는 경우가 많고 인메모리 DB에서 통과하더라도 실제 DB에선 문제가 발생할 수도 있기에 TestContainers와 같은 라이브러리를 사용하는 것을 권장한다.
시스템 테스트는 전체 애플리케이션을 띄우고 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 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());
}
}
@SpringBootTest
애너테이션으로 스프링이 구성하는 모든 객체 네트워크를 띄우게 하고, MockMvc가 아닌 TestRestTemplate을 통해 실제 HTTP 통신을 한다.
라인 커버리지는 테스트 성공을 측정하는 데 좋은 지표인 것만은 아니다. 따라서 위와 같이 해당 레이어를 구현할 때, 테스트를 정의하는 것도 하나의 방법이다.
헥사고날 아키텍처는 도메인 로직과 바깥으로 향한 어댑터를 깔끔하게 분리한다. 덕분에 핵심 도메인 로직은 단위, 어댑터는 통합으로 명확한 테스트 전략을 정의할 수 있다.
입출력 포트(UseCase, Command 등)는 테스트에서 뚜렷한 모킹 지점이 된다. 각 포트에 대해 모킹할지, 실제 구현을 이용할지 선택할 수 있다.