[3주차] 7장 : 아키텍처 요소 테스트하기

ing

육각형 아키텍처에서의 테스트 전략에 대해 이야기한다.

테스트 피라미드

  • 테스트가 목표해야 되는 기본 전제는 (1) 만드는 비용이 적고, (2) 유지보수하기 쉽고, (3) 빨리 실행되고, (4)안정적인 작은 크기의 테스트들에 대해 높은 커버리지를 유지해야 한다는 것이다.

  • 테스트는 하나의 ‘단위’ 일잔벅으로 하나의 클래스가 제대로 동작하는지 확인할 수 있는 단위 테스트들이다.

  • 테스트가 비싸질수록(여러 개의 단위와 단위를 넘는 경계, 아키텍체 경계, 시스템 경계를 결합하는 테스트) 테스트의 커버리지 목표를 낮게 잡아야 한다.

단위 테스트

  • 일반적으로 하나의 클래스를 인스턴스화하고 해당 클래스의 인터페이스를 통해 기능들을 테스트한다.

  • 만약 테스 중인 클래스가 다른 클래스에 의존한다면 의존되는 클래스들은 인스턴스화하지 않고 테스트하는 동안 필요한 작업들을 흉내 내는 목(mock)으로 대체한다.

통합 테스트

  • 연결된 여러 유닛을 인스턴스화하고 시작점이 되는 크래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 기대한대로 잘 동작하는지 검증한다.

시스템 테스트

  • 애플리케이션을 구선하는 모든 객체 네트워크를 가동시켜 특정 유스케이스가 전 계층에서 잘 동작하는지 검증한다.

단위 테스트로 도메인 엔티티 테스트하기

과거 특정 시점의 계좌 잔고(baseBalance)와 그 이후의 입출금 내역(activity)으로 구성돼 있는 Account에서 withdraw() 메서드를 검증해보자.

특정 상태의 Account를 인스턴스화하고 withdraw() 메서드를 호출해서 출금을 성공했는지 검증하고, Account 객체의 상태에 대해 기대되는 부수효과들이 잘 일어났는지 확인한다.

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));
    }
}

단위 테스트로 유스케이스 테스트하기

출금 계좌의 잔고가 다른 트랜잭션에 의해 변경되지 않도록 락(lock)을 걸고, 돈이 출금되고 나면 똑같이 입금 계좌에 락을 걸고 돈을 입금시킨다. 이후 두 계좌에서 모두 락을 해제하는 SendMoney 유스케이스를 테스트해보자.

테스트의 가독성을 높이기 위해 행동-주도 개발(behavior driven development)에서 사용되는 given/when/then 섹션으로 나눈다.

  • ‘given’섹션에서는 (1) 출금 및 Account의 인스턴스를 각각 생성하고 적절한 상태로 만들어, given...()으로 시작하는 메서드에 인자로 넣고, (2) SendMoneyCommand 인스턴스도 만들어 유스케이스의 입력으로 사용한다.

  • ‘when’섹션에서는 유스케이스를 실행하기 위해 sendMoney() 메서드를 호출한다.

  • ‘then’ 섹션에서는 트랜잭션이 성공적이었는지 확인하고, 출금 및 입금 Account, 그리고 계좌의 락을 걸고 해제하는 책임을 가진 AccountLock에 대해 특정 메서드가 호출되었는지 검증한다.

  • Mockito 라이브러리를 이용해 given...() 메서드의 목 객체를 생성한다. Mockito는 목 객체에 대해 특정 메서드가 호출됐는지 검증할 수 있는 then() 메서드도 제공한다.

테스트 중인 유스케이스는 상태가 없기(stateless) 때문에 ‘then’ 섹션에서 특정 상태를 검증할 수 없는 대신, 서비스가 모킹된 의존 대상의 특정 메서드와 상호작용했는지 여부를 검증한다.

  • 이는 테스트 코드의 행동 변경뿐만 아니라 코드의 구조 변경에도 취약하다는 의미이며, 코드가 리팩터링 되며 테스트도 변경될 확률이 높다.

  • 테스트에서 어떤 상호작용을 검증하고 싶은지 신중하게 생각해야 한다.

class SendMoneyServiceTest {

    // 필드 선언은 생략
    
    @Test
    void transactionSucceds() {
        
        Account sourceAccount = givenSourceAccount();
        Account targetAccount = gicenTargetAccount();
        
        givenWithdrawlWillSuccedd(sourceAccount);
        givenDepositWillSucceed(targetAccount);
        
        Money money = Money.of(500L);
        
        SendMoneyCommand command = new SendMoneyCommand(
            sourceAccount.getId(),
            targetAccount.getId(),
            money);
        
        boolean success = sendMoneyService.sendMoney(command);
        
        assertThat(success).isTrue();
        
        AccountId sourceAccountId = sourceAccount.getId();
        AccountId targetAccountId = targetAccount.getId();
        
        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);
    }
    
    // 헬퍼 메서드는 생략
}
more

이 테스트는 단위 테스트이긴 하지만 의존성의 상호작용을 테스트하고 있어 통합 테스트에 가깝지만, 목으로 작업하고 실제 의존성을 관리해야 하는 것은 아니기 때문에 완전한 통합 테스트에 비해 만들고 유지보수하기가 쉽다.

통합 테스트로 웹 어댑터 테스트하기

웹 어댑터는 JSON 문자열 등의 형태로 HTTP를 통해 입력을 받고, 입력에 대한 유효성 검증을 하고, 유스케이스에서 사용할 수 있는 포맷으로 매핑하고, 유스케이스에 전달한 후 , 다시 유스케이스의 결과를 JSON으로 매핑하고 HTTP 응답을 통해 클라이언트에 반환한다.

스프링 부트 프레임워크에서 웹 컨트롤러를 테스트하는 표준적인 통합 테스트 방법

  • testSendMoney() 메서드에서 입력 객체를 만들고 목 HTTP 요청을 웹 컨트롤러에 보낸다. 이때 요청 바디는 JSON 문자열의 형태로 입력 객체를 포함한다.

  • isOk() 메서드로 HTTP 응답 상태가 200임을 검증하고, 모킹한 유스케이스가 잘 호출 됐는지 검증한다.

  • MockMvc 객체를 이용해 모킹했기 때문에 실제로 HTTP 프로토콜을 통해 테스트한 것은 아니고, 프레임워크가 HTTP 프로토콜에 맞게 적절히 잘 변환한 것이다.

  • 입력은 JSON에서 SendMoneyCommand 객체로 매핑하는 전 과정을 다루고 있고, 만약 SendMoneyCommand 객체를 자체 검증 커맨드로 만들었다면 이 매핑이 유스케이스에 구문적으로 유효한 입력을 생성했는지도 확인한다.

  • 유스케이스가 실제로 호출됐는지도 검증했고, 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, 5000)
              .header("Content-Type", "application/json"))
              .andExpect(status().isOk());
      
        then(sendMoneyUseCase).should()
            .sendMoney(eq(new SendMoneyCommand(
                new AccountId(41L),
                new AccountId(42L),
                Money.of(500L))));
    }
}
integration test

이 테스트가 통합 테스트인 이유는, @WebMvcTest 애너테이션이 스프링이 특정 요청 경로, 자바와 JSON 간의 매핑, HTTP 입력 검증 등에 필요한 전체 객체 네트워크를 인스턴스화하도록 만들고, 웹 컨트롤러가 이네트워크의 일부로서 잘 동작하는지 검증하는 과정에서 웹 컨트롤러가 스프링 프레임워크에 강하게 묶여 있어 격리된 상태로 테스트하기 보다는 이 프레임워크와 통합된 상태로 테스트하기 떄문이다.

웹 컨트롤러를 평범한 단위 테스트로 테스트하면 모든 매핑, 유효성 검증, HTTP 항목에 대한 커버리지가 낮아지고, 프레임워크를 구성하는 요소들이 프로덕션 환경에서 정상적으로 작동할지 확신할 수 없게 된다.

통합 테스트로영속성 어댑터 테스트하기

단순히 어댑터의 로직만 검증하는 것이 아니라 데이터베이스 매핑도 검증하기 때문에 영속성 어댑터 또한 단위 테스트보다는 통합 테스트를 적용하는 것이 합리적이다.

AccountPersistenceAdapter에는 (1) Account 엔티티를 데이터베이스로부터 가져오는 메서드와 (2) 새로운 계좌 활동을 데이터베이스에 저장하는 메서드가 있다.

  • @DataJpaTest 애너테이션으로 스프링 데이터 리포지토리들을 포맣해서 데이터베이스 접근에 필요한 객체 네트워크를 인스턴스화해야 한다고 스프링에 알려준다.

  • @Import 애너테이션을 추가해서 특정 객체가 이 네트워크에 추가됐다는 것을 명호가하게 표현할 수 있다. 이 객체들은 테스트 상에서 어댑터가 도메인 객체를 데이터베이스 객체로 매핑하는 등의 작어벵 필요하다.

  • loadAccount() 메서드에 대한 테스트에서는 SQL 스크립트를 이용해 데이터베이스를 특정 상태로 만든 다음, 어댑터 API를 이용해 계좌를 가져온 후 SQL 스크립트에서 설정한 상태값을 가지고 있는지 검증한다.

  • updateActivities() 메서드는 새로운 계좌 활동을 가진 Account 객체를 만들어서 저장하기 위해 어댑터로 전달한 후 ActivityRepository의 API를 이용해 이 활동이 데이터베이스에 잘 저장됐는지 확인한다.

  • 데이터베이스를 모킹하지 않았다는 점이 중요하다. 테스트가 실제 데이터베이스에 접근한다.

  • 스프링에서는 기본적으로 인메모리(in-memory) 데이터베이스를 테스트에서 사용하지만 프로덕션 환경에서는 인메모리 데이터베이스를 사용하지 않는 경우가 많기때문에 실제 데이터베이스에서는 문제가 생길 가능성이 높다.

  • 이러한 이유로 영속성 어댑터 테스트는 실제 데이터베이스를 대상으로 진행해야 하며, Testcontainers 같은 라이브러리는 필요한 데이터베이스를 도커 컨테이너에 띄울 수 있어 유용하다.

@DataJpaTest
@Import({AccountPersistenceAdapter.class, AccountMapper.class})
class AccountPersitenceAdapterTest {

    @Autowired
    private AccountPersistenceAdapter adapterUnderTest;
    
    @Autowired
    private AcityvityRepository activityRepository;
    
    @Test
    @Sql("AccountPersistenceAdapterTest.sql")
    void loadsAccount() {
        Account account = adapter.loadAccount(
            new AccountId(1L),
            LocalDateTime.of(2018, 9, 10, 0, 0));
        
        assertThat(account.getActivityWindow().getActivities()).hasSize(2);
        assertThat(account.calculateBalance()).isEqualTo(Money.of(500));
    }
    
    @Test
    void updateActivities() {
        Account account = defaultAccount()
            .withBaselineBalance(Monet.of(555L))
            .withActivityWindow(new ActivityWindow(
                defaultActivity()
                    .withId(null)
                    .withMOney(Money.if(1L)).build()))
            .build();
        
        adapter.updateActivities(account);
        
        assertThat(activityRepository.count()).isEqualTo(1);
        
        ActityJpaEntity saveActivity = activityRepository.findAll().get(0);
        asserThat(savedActivity.getAmount()).isEqualTo(1L);
    }
}

시스템 테스트로 주요 경로 테스트하기

시스템 테스트는 전체 애플리케이션을 띄우고 API를 통해 요청을 보내고, 모든 계층이 조화롭게 잘 동작하는지 검증한다. 현재 ‘송금하기’ 유스케이스의 시스템 테스트에서는 애플리케이션에 HTTP 요청을 보내고 계좌의 잔고를 확인하는 것을 포함해서 응답을 검증한다.

  • @SpringBootTest 애너테이션은 스프링이 애플리케이션을 구성하는 모든 객체 네트워크를 띄우게 하며, 이 때 랜덤 포트로 애플리케이션을 띄운다.

  • test 메서드에서는 요청을 생성해서 애플리케이션에 보내고 응답 상태와 계좌의 새로운 잔고를 검증한다. 여기서 웹 어댑터에서처럼 MockMvc를 이용해 요청을 보내는 것이 아니라 TestRestTemplate을 이용해서 요청을 보낸다. (테스트를 프로덕션 환경에 조금 더 가깝게 만들기 위해 실제 HTTP 통신을 하는 것이다. )

  • 실제 출력 어댑터도 이용한다.

  • 테스트 가독성을 높이기 위해 지저분한 로직들을 헬퍼 메서드 안으로 감췄다. 이제 이 헬퍼 메서드들은 여러 가지 상태를 검증할 때 사용할 수 있는 도메인 특화 언어(domain-specific languge, DSL)를 형성한다.

시스템 테스트는 여러 개의 유스케이스를 결합해서 시나리오를 만들 때 더 빛이 난다.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class SendMoneySystemTest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @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(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/sendMoney/{sourceAccountId}/{targetAccountId}/{amount}",
            HttpMethod.POST, 
            request,
            Object.class,
            sourceAccountId.getValue(),
            targetAccountId.getValue(),
            amount.getAmount());
    }
    
    // 일부 헬퍼 메서드는 생
}

얼마만큼의 테스트가 충분할까 ?

육각형 아키텍처에서 사용하는 테스트 전략

  • 도메인 엔티티를 구현할 때는 단위 테스트로 커버하자

  • 유스케이스를 구현할 때는 단위 테스트로 커버하자

  • 어댑터를 구현할 때는 통합 테스트로 커버하자

  • 사용자가 취할 수 있는 중요 애플리케이션 경로는 시스템 테스트로 커버하자

‘구현할 때는’이라는 문구에 주목하자. 만약 테스트가 기능 개발 수가 아닌 개발 중에 이뤄진다면 개발 도구로 느껴질 것이다.

유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 될까 ?

DISCUSSION

Last updated