7장 아키텍처 요소 테스트하기 (육각형 아키텍처에서의 테스트 전략)
테스트 피라미드
테스트 피라미드에 따르면 비용이 많이 드는 테스트는 지양하고 비용이 적게 드는 테스트를 많이 만들어야 한다. 테스트의 기본 전제는 만드는 비용이 적고, 유지보수가 쉽고, 빨리 실행되고, 안정적인 작은 크기의 테스트들에 대해 높은 커버리지를 유지해야 한다.
단위 테스트는 피라미드의 토대에 해당하며 일반적으로 하나의 클래스를 인스턴스화하고 해당 클래스의 인터페이스를 통해 기능들을 테스트한다. 만약 테스트 중인 클래스가 다른 클래스에 의존한다면 의존되는 클래스들은 인스턴스화하지 않고 테스트하는 동안 필요한 작업들을 흉내 내는 mock 으로 대체한다.
통합 테스트는 연결된 여러 유닛을 인스턴스화하고 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 기대한대로 잘 동작하는지 검증한다.
시스템 테스트는 애플리케이션을 구성하는 모든 객체 네트워크를 가동시켜 특정 유스케이스가 전 계층에서 잘 동작하는지 검증한다. 시스템 테스트 위에는 애플리케이션의 UI를 포함하는 end-to-end 테스트층이 있을 수 있다.
단위 테스트로 도메인 엔티티 테스트하기
육각형 아키텍처의 중심인 도메인 엔티티 테스트 Account의 상태는 과거 특정 시점의 계좌 잔고 (baselineBalance)와 그 이후의 입출금 내역으로 구성되어 있다. withdraw() 메서드가 기대한 대로 동작하는지 검증한다.
public class AccountTest {
@Test
void withdrawalSucceeds() {
AccountId accountId = new AccountId(1L);
Account account = defaultAccount()
.withAccountId(accountId)
.withBaselineBalance(Money.of(555L))
.withActivityWindow(new ActivityWindow(
defaultActivity()
.withTargetAccount(accountId)
.withMoney(Money.of(999L)).build(),
defaultActivity()
.withTargetAccount(accountId)
.withMoney(Money.of(1L)).build()))
.build();
boolean success = account.withdraw(Money.of(555L), new AccountId(99L));
assertThat(success).isTrue();
assertThat(account.getActivityWindow().getActivities()).hasSize(3);
assertThat(account.calculateBalance()).isEqualTo(Money.of(1000L));
}
}
특정 상태의 Account를 인스턴스화하고, withdraw() 메서드를 호출해서 출금이 성공했는지 체크, Account 객체의 상태에 대해 기대되는 부수효과들이 잘 일어났는지 확인하는 단순한 단위 테스트다.
단위테스트가 도메인 엔티티에 녹아 있는 비즈니스 규칙을 검증하기에 가장 적절한 방법이다. 도메인 엔티티의 행동은 다른 클래스에 거의 의존하지 않기 때문에 다른 종류의 테스트는 필요하지 않다.
단위 테스트로 유스케이스 테스트하기
육각형 아키텍처에서 계층의 바깥쪽으로 나가서, 다음으로 테스트할 아키텍처의 요소는 유스케이스다.
출금 계좌의 잔고가 다른 트랜잭션에 의해 변경되지 않도록 lock을 걸고, 출금 계좌에서 돈이 출금되고 나면 똑같이 입금 계좌에 락을 걸고 돈을 입금시킨다. 그러고 나서 두 계좌에서 모두 락을 해제한다.
public class SendMoneyServiceTest {
private final LoadAccountPort loadAccountPort =
Mockito.mock(LoadAccountPort.class);
private final AccountLock accountLock =
Mockito.mock(AccountLock.class);
private final UpdateAccountStatePort updateAccountStatePort =
Mockito.mock(UpdateAccountStatePort.class);
private final SendMoneyService sendMoneyService =
new SendMoneyService(loadAccountPort, accountLock, updateAccountStatePort, moneyTransferProperties());
@Test
void givenWithdrawalFails_thenOnlySourceAccountIsLockedAndReleased() {
AccountId sourceAccountId = new AccountId(41L);
Account sourceAccount = givenAnAccountWithId(sourceAccountId);
AccountId targetAccountId = new AccountId(42L);
Account targetAccount = givenAnAccountWithId(targetAccountId);
givenWithdrawalWillFail(sourceAccount);
givenDepositWillSucceed(targetAccount);
SendMoneyCommand command = new SendMoneyCommand(
sourceAccountId,
targetAccountId,
Money.of(300L));
boolean success = sendMoneyService.sendMoney(command);
assertThat(success).isFalse();
then(accountLock).should().lockAccount(eq(sourceAccountId));
then(accountLock).should().releaseAccount(eq(sourceAccountId));
then(accountLock).should(times(0)).lockAccount(eq(targetAccountId));
}
@Test
void transactionSucceeds() {
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);
boolean success = sendMoneyService.sendMoney(command);
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);
}
private void thenAccountsHaveBeenUpdated(AccountId... accountIds){
ArgumentCaptor<Account> accountCaptor = ArgumentCaptor.forClass(Account.class);
then(updateAccountStatePort).should(times(accountIds.length))
.updateActivities(accountCaptor.capture());
List<AccountId> updatedAccountIds = accountCaptor.getAllValues()
.stream()
.map(Account::getId)
.map(Optional::get)
.collect(Collectors.toList());
for(AccountId accountId : accountIds){
assertThat(updatedAccountIds).contains(accountId);
}
}
private void givenDepositWillSucceed(Account account) {
given(account.deposit(any(Money.class), any(AccountId.class)))
.willReturn(true);
}
private void givenWithdrawalWillFail(Account account) {
given(account.withdraw(any(Money.class), any(AccountId.class)))
.willReturn(false);
}
private void givenWithdrawalWillSucceed(Account account) {
given(account.withdraw(any(Money.class), any(AccountId.class)))
.willReturn(true);
}
private Account givenTargetAccount(){
return givenAnAccountWithId(new AccountId(42L));
}
private Account givenSourceAccount(){
return givenAnAccountWithId(new AccountId(41L));
}
private Account givenAnAccountWithId(AccountId id) {
Account account = Mockito.mock(Account.class);
given(account.getId())
.willReturn(Optional.of(id));
given(loadAccountPort.loadAccount(eq(account.getId().get()), any(LocalDateTime.class)))
.willReturn(account);
return account;
}
private MoneyTransferProperties moneyTransferProperties(){
return new MoneyTransferProperties(Money.of(Long.MAX_VALUE));
}
}
행동-주도 개발(behavior driven development) 에서 일반적으로 사용되는 방식대로 given/when/then 섹션으로 나눠 테스트한다.
Account 인스턴스를 각각 생성하고 SendMoneyCommand 인스턴스도 만들어, 유스케이스의 입력으로 사용하고, 유스케이스를 실행한다. 마지막으로 트랜잭션이 성공했는지 체크하고, 계좌에 락을 걸고 해제하는 책임을 가진 AccountLock에 대해 특정 메서드가 호출됐는지 검증한다.
테스트 중인 유스케이스 서비스는 상태가 없기(stateless) 때문에 'then' 섹션에서 특정 상태를 검증할 수 없다. 대신 테스트는 서비스가 의존 대상의 특정 메서드와 상호작용했는지 여부를 검증한다. 테스트가 코드의 행동 변경뿐만 아니라 코드의 구조 변경에도 취약해진다는 의미가 된다. 자연스럽게 코드가 리팩토링되면 테스트도 변경될 확률이 높아진다.
테스트에서 어떤 상호작용을 검증하고 싶은지 신중하게 생각해야하며, 위 코드처럼 모든 동작을 검증하는게 아니라 중요한 핵심만 골라서 테스트하는 것이 좋다. 모든 동작을 검증하려고 하면 클래스가 조금이라도 바뀔 때마다 테스트를 변경해야 한다. 이 테스트는 단위 테스트긴 하지만 의존성의 상호작용을 테스트하고 있기 때문에 통합 테스트에 가깝다. 목으로 작업하고 있고 실제 의존성을 관리해야 하는 것은 아니기 때문에 완전한 통합 테스트에 비해 만들고 유지보수 하기 쉽다.
통합 테스트로 웹 어댑터 테스트하기
육각형 아키텍처 에서 한 계층 더 바깥으로 나가 웹 어댑터를 테스트해보자.
웹 어댑터는 JSON 문자열 등의 형태로 HTTP를 통해 입력 받고, 입력에 대한 유효성 검증, 유스케이스에서 사용할 수 있는 포맷으로 매핑하고, 유스케이스에 전달한다. 그러고 나서 다시 유스케이스이 결과를 JSON으로 매핑하고 HTTP 응답을 통해 클라이언트로 반환한다.
@WebMvcTest(controllers = SendMoneyController.class)
public 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))));
}
}
위 예제는 스프링에서 웹 컨트롤러를 테스트하는 표준적인 통합 테스트 방법이다. MockMvc 객체를 이용해 모킹했기 때문에 실제로 HTTP 프로토콜을 통해 테스트한 것은 아니다. 프레임워크가 잘 해줄것이기 때문이다.
입력을 JSON에서 SendMoneyCommand 객체로 매핑하는 전 과정은 다룬다. 또한 유스케이스가 실제로 호출됐는지도, HTTP 응답이 기대한 상태를 반환했는지도 검증했다.
이 테스트가 통합 테스트인 이유는 이 테스트에서는 하나의 웹 컨트롤러 클래스만 테스트한 것처럼 보이지만, 보이지 않는 곳에서 더 많은 일들이 벌어지고 있다. @WebMvcTest 에너테이션을 사용해서 스프링이 특정 요청 경로, 자바와 JSON 간의 매핑, HTTP 입력 검증 등에 필요ㅕ한 전체 객체 네트워크를 인스턴스화 했다. 그리고 테스트에서는 웹 컨트롤러가 네트워크의 일부로서 잘 동작하는지 검증한다.
웹 컨트롤러가 스프링 프레임워크에 강하게 묶여 있기 떄문에 격리된 상태로 테스트하기 보다는 프레임워크와 통합된 상태로 테스트하는 것이 합리적이다.
통합 테스트로 영속성 어댑터 테스트하기
웹 어댑터 테스트와 비슷한 맥락으로 영속성 어댑터의 테스트에는 단위 테스트보다는 통합 테스트를 적용하는것이 합리적이다. 어댑터으 ㅣ로직만 검증하고 싶은 게 아니라 데이터베이스 매핑까지 검증하기 때문이다.
@DataJpaTest
@Import({AccountPersistenceAdapter.class, AccountMapper.class})
public 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);
}
}
이 테스트에서는 데이터베이스를 모킹하지 않았다는게 중요하다. 영속성 어댑터는 모킹보다는 실제 데이터베이스를 대상으로 진행해야 한다. 스프링에서는 기본적으로 인-메모리 데이터베이스를 테스트에서 사용해서 아무 설정 없이 곧바로 테스트할 수 있으므로 아주 실용적이지만 프로덕션 환경에서는 인메모리 데이터베이스를 사용하지 않는 경우가 많기 때문에 인메모리 데이터베이스에서 테스트가 완벽하게 통과했더라도 실제 데이터베이스에서는 문제가 생길 가능성이 높다.
Testcontainers 같은 라이브러리는 필요한 데이터베이스를 도커 컨테인너에 띄울 수 있기 때문에 유용하다. 실제 데이터베이스를 대상으로 테스트를 실행하면 두 개의 다른 데이터베이스 시스템을 신경 쓸 필요가 없다는 장점도 생긴다.
시스템 테스트로 주요 경로 테스트하기
피라미드의 최상단에 있는 시스템 테스트는 전체 애플리케이션을 띄우고 API를 통해 요청을 보내고, 모든 계층이 잘 동작하는지 검증한다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public 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);
}
}
이 테스트에서는 웹 어댑터처럼 MockMvc를 이용해서 요청을 보내는 것이 아니라 TestRestTemplate를 이용해서 요청을 보낸다. 프로덕션 환경에 조금 더 가깝게 만들기 위해 실제 HTTP 통신을 하는 것이다.
실제 HTTP 통신을 하는 것처럼 실제 출력 어댑터도 이용하는데, 영속성 어댑터 뿐이다. 다른 시스템 연동 애플리케이션의 경우 다른 출력 어댑터도 있을 수 있지만 언제나 서드파티 시스템을 실행해서 테스트할 수 있는 것은 아니기 때문에 결국 모킹을 해야할 때도 있다. 육각형 아키텍처는 이러한 경우 몇 개의 출력 포트 인터페이스만 모킹하면 되기 때문에 아주 쉽게 이 문제를 해결할 수 있다.
단위 테스트, 통합테스트와 겹치는 코드가 많은데, 추가적인 장점도 있다. 시스템 테스트는 단위 테스트, 통합 테스트가 발견하는 버그와는 또 다른 종류의 버그를 발견해서 수정할 수 있게 하주며, 시스템 테스트는 여러 개의 유스케이스를 결합해 시나리오를 만들 때 더 좋은 시너지를 낸다. 시스템 테스트를 통해 중요한 시나리오들이 커버된다면 최신 변경사항들이 애플리케이션을 망가뜨리지 않았음을 가정하고, 배포될 준비가 됐다는 확신을 가질 수 있게 해준다.
얼마만큼의 테스트가 충분할까?
라인 커버리지는 테스트 성공을 측정하는 데 있어서는 잘못된 지표다. 코드의 중요한 부분이 전혀 커버되지 않을 수 있기 때문에 100%를 제외한 어떤 목표도 완전히 무의미하며, 100%라고 해도 버그가 잘 잡혔는지 확신할 수 없다.
책의 저자는 얼마나 마음 편하게 소프트웨어를 배포할 수 있느냐를 테스트의 성공 기준으로 삼고 테스트 결과를 신뢰한다면 그것으로 됐다고 정의한다.
- 육각형 아키텍처에서 사용하는 전략이다
- 도메인 엔티티를 구현할 때는 단위테스트로 커버한다.
- 유스케이스를 구현할 때는 단위 테스트로 커버한다.
- 어댑터를 구현할 때는 통합 테스트로 커버한다.
- 사용자가 취할 수 있는 중요 애플리케이션 경로(시나리오)는 시스템 테스트로 커버한다.
새로운 필드를 추가할 때마다 테스트를 고치는 데 한 시간을 써야 한다면 뭔가 잘못된 것이다..
유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 될까?
육각형 아키텍처는 도메인 로직과 바깥으로 향한 어댑터를 분리한다. 핵심 도메인 로직은 단위 테스트로, 어댑터는 통합 테스트로 처리하는 명확한 테스트 전략을 정의한다.
입출력 포트는 테스트에서 뚜렷한 모킹 지점이 되어주고, 모킹하는 것이 너무 버거워지거나 코드의 특정 부분을 커버하기 위해 어떤 종류의 테스트를 써야 할지 모르겠다면 이는 아키텍처의 문제에 대해 경고 신호 이기 때문에 고민이 필요한 시점이된다.
'etc' 카테고리의 다른 글
[react] openlayers - react - vworld (3) | 2024.10.22 |
---|---|
[Network] react sprignboot CORS (9) | 2024.10.09 |
[만들면서 배우는 클린 아키텍처] 8장, 9장, 10장 정리 (0) | 2024.09.14 |
[만들면서 배우는 클린 아키텍처] 5장, 6장 정리 (0) | 2024.09.14 |
[만들면서 배우는 클린 아키텍처] 1장, 2장, 3장, 4장 정리 (0) | 2024.09.14 |