Notice
Recent Posts
Recent Comments
Link
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
Archives
Today
Total
관리 메뉴

csct3434

[만들면서 배우는 클린 아키텍처] 07. 아키텍처 요소 테스트하기 본문

개발 서적/만들면서 배우는 클린 아키텍처

[만들면서 배우는 클린 아키텍처] 07. 아키텍처 요소 테스트하기

csct3434 2024. 3. 3. 21:16

서론

육각형 아키텍처에서의 테스트 전략에 대해 이야기한다.
아키텍처의 각 요소들을 테스트할 수 있는 테스트 유형에 대해 논의할 것이다.

테스트 피라미드

  • 위 그림은 몇 개의 테스트와 어떤 종류의 테스트를 목표로 해야 하는지 결정하는데 도움을 준다
  • 테스트 피라미드에 따르면 비용이 많이 드는 테스트는 지양하고 비용이 적게 드는 테스트를 많이 만들어야 한다
  • 여러 개의 단위와 단위를 넘는 경계, 아키텍처 경계, 시스템 경계를 결합하는 테스트는 만드는 비용이 더 비싸지고, 실행이 더 느려지며, 깨지기 더 쉬워진다.
  • 테스트 피라미드는 테스트가 비싸질수록 테스트의 커버리지 목표는 낮게 잡아야 한다는 것을 보여준다.
    그렇지 않으면 새로운 기능을 만드는 것 보다 테스트를 만드는 데 시간을 더 쓰게 된다
  • 단위 테스트
    • 피라미드의 토대에 해당한다
    • 일반적으로 하나의 클래스를 인스턴스화하고 해당 클래스의 인터페이스를 통해 기능들을 테스트한다
    • 테스트하는 클래스가 다른 클래스에 의존한다면 의존되는 클래스들은 인스턴스화하지 않고 테스트하는 동안 필요한 작업들을 흉내내는 Mock으로 대체한다
  • 통합 테스트
    • 연결된 여러 유닛을 인스턴스화하고 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 기대한대로 잘 동작하는지 검증한다
  • 시스템 테스트
    • 전체 애플리케이션을 띄우고 API를 통해 요청을 보내고, 모든 계층이 조화롭게 잘 동작하는지 검증한다

도메인 엔티티 테스트 (단위 테스트)

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의 상태는 과거 특정 시점의 계좌 잔고(baselineBalance)와 그 이후의 입출금 내역(activity)으로 구성돼 있다.
  • 위 코드는 특정 상태의 Account를 인스턴스화하고 withdraw() 메서드를 호출해서 출금을 성공했는지 검증하고, Account객체의 상태에 대해 기대되는 부수효과들이 잘 일어났는지 확인하는 단위 테스트다.
  • 이런 식의 단위 테스트가 도메인 엔티티에 녹아 있는 비즈니스 규칙을 검증하기에 가장 적절한 방법이다.
  • 도메인 엔티티의 행동은 다른 클래스에 거의 의존하지 않기 때문에 다른 종류의 테스트는 필요하지 않다.

유스케이스 테스트 (단위 테스트)

class SendMoneyServiceTest {

  // 생략
  @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 유스케이스는 출금 계좌의 잔고가 다른 트랜잭션에 의해 변경되지 않도록 락을 건다.
    출금 계좌에서 돈이 출금되고 나면 똑같이 입금 계좌에 락을 걸고 돈을 입금시킨다. 그러고 나서 두 계좌에서 모두 락을 해제한다.
  • 위 코드는 트랜잭션이 성공했을 때 모든 것이 기대한 대로 동작하는지 검증한다.
  • 이 테스트는 단위 테스트이긴 하지만 의존성의 상호작용을 테스트하고 있기 때문에 통합 테스트에 가깝다. 하지만 목으로 작업하고 있고 실제 의존성을 관리해야 하는 것은 아니기 때문에 완전한 통합 테스트에 비해 만들고 유지보수하기가 쉽다.
  • given 섹션
    • 출금 및 입금 Account의 인스턴스를 각각 생성하고 적절한 상태로 만들어서 given…() 메서드에 인자로 넣었다
    • given...() 메서드에서는 Mockito 라이브러리를 이용해 Mock 객체를 생성하여 반환한다
      • Account sourceAccount = givenSourceAccount();
  • when 섹션
    • 유스케이스를 실행하기 위해 sendMoney() 메서드를 호출했다
  • then 섹션
    • 트랜잭션이 성공적이었는지 확인한다
      • assertThat(success).isTrue();
    • 출금 및 입금 Account, 그리고 계좌에 락을 걸고 해제하는 책임을 가진 AccountLock에 대해 특정 메서드가 호출됐는지 검증한다.
      • then(accountLock).should().lockAccount(eq(sourceAccountId));
        : accountLock 객체가 lockAccount 메서드를 targetAccountId와 함께 호출되는지 검증한다
  • then 섹션에서는 서비스가 의존 대상의 특정 메서드와 상호작용했는지 여부를 검증한다.
    이는 테스트 코드가 코드의 행동 변경뿐만 아니라 코드의 구조 변경에도 취액해진다는 의미가 된다.
    자연스럽게 코드가 리팩터링되면 테스트도 변경될 확률이 높아진다.
    그렇기에, 테스트에서는 어떤 상호작용을 검증하고 싶은지 신중하게 생각해야 한다.
  • 모든 동작을 검증하는 대신 중요한 핵심만 골라 집중해서 테스트하는 것이 좋다.
    만약 모든 동작을 검증하려고 하면 클래스가 조금이라도 바뀔 때마다 테스트를 변경해야 하는데, 이는 테스트의 가치를 떨어뜨린다.

웹 어댑터 테스트 (통합 테스트)

@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))));
  }
}
  • 위 코드는 스프링 부트 프레임워크에서 웹 컨트롤러를 테스트하는 표준적인 통합 테스트 방법이다. 웹 어댑터의 책임 대부분은 이 테스트로 커버된다.
  • 테스트는 HTTP 입력이 JSON에서 SendMoneyCommand 객체로 매핑되었는지, 유스케이스가 실제로 호출됐는지, HTTP 응답이 기대한 상태를 반환했는지 검증한다.
    • 만약 SendMoneyCommand 객체를 4장에서처럼 자체 검증 커맨드로 만들었다면 이 매핑이 유스케이스에 구문적으로 유효한 입력을 생성했는지도 검증할 것이다.
  • MockMvc 객체를 이용해 모킹했기 때문에 실제로 HTTP 프로토콜을 통해 테스트한 것은 아니다.
    프레임워크를 테스트할 필요는 없으니, 프레임워크가 HTTP 프로토콜에 맞게 모든 것을 적절히 잘 변환한다고 믿는 것이다.
  • @WebMvcTest 애너테이션은 스프링이 특정 요청 경로, 자바와 JSON 간의 매핑, HTTP 입력 검증 등에 필요한 전체 객체 네트워크를 인스턴스화하도록 한다. 그리고 테스트에서는 웹 컨트롤러가 이 네트워크의 일부로서 잘 동작하는지 검증한다.
  • 웹 컨트롤러가 스프링 프레임워크에 강하게 묶여 있기 때문에, 격리된 상태로 테스트하기보다는 이 프레임워크와 통합된 상태로 테스트하는 것이 합리적이다.
    • 웹 컨트롤러를 평범한 단위 테스트로 테스트하면 모든 매핑, 유효성 검증, 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);
    }

}
  • 영속성 어댑터는 단순히 어댑터의 로직만 검증하고 싶은 게 아니라 데이터베이스 매핑도 검증하고 싶기 때문에 단위 테스트보다 통합 테스트를 적용하는 것이 합리적이다.
  • AccountPersistenceAdapter 에는 Account 엔티티를 데이터베이스로부터 가져오는 메서드 하나와 새로운 계좌 활동을 데이터베이스에 저장하는 메서드까지 총 2개의 메서드가 있다.
  • @DataJpaTest 애너테이션은 스프링이 스프링 데이터 리포지토리들을 포함해서 데이터베이스 접근에 필요한 객체 네트워크를 띄우게 한다.
  • @Import 애너테이션을 추가해서 특정 객체가 이 네트워크(Application Context)에 추가됐다는 것을 명확하게 표현할 수 있다.
  • 영속성 어댑터 테스트는 실제 데이터베이스를 대상으로 진행해야 한다
    • 데이터베이스를 모킹하더라도 여전히 같은 코드라인을 커버해서 똑같이 높은 커버리지를 보이겠지만, 실제 데이터베이스와 연동했을 때 SQL 구문의 오류나 데이터베이스 테이블과 자바 객체 간의 매핑 에러 등으로 문제가 생길 확률이 높아진다.
    • 스프링에서는 기본적으로 인메모리 데이터베이스를 테스트에서 사용하지만, 프로덕션 환경에서는 인메모리 데이터베이스를 사용하지 않는 경우가 많기 때문에 인메모리 데이터베이스에서 테스트가 완벽하게 통과했더라도 실제 데이터베이스에는 문제가 생길 가능성이 높다.
    • Testcontainers 같은 라이브러리는 필요한 데이터베이스를 도커 컨테이너에 띄울 수 있기 때문에 이런 측면에서 아주 유용하다
    • 실제 데이터베이스를 대상으로 테스트를 실행하면 두 개의 다른 데이터베이스 시스템을 신경 쓸 필요가 없다는 장점도 생긴다.

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

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

  // 생략

}
  • '송금하기' 유스케이스의 시스템 테스트에서는 애플리케이션에 실제 HTTP 요청을 보내고, 계좌의 잔고를 확인하는 것을 포함하여 응답을 검증한다
  • @SpringBootTest 애너테이션은 스프링이 애플리케이션을 구성하는 모든 객체 네트워크를 띄우게 한다.
  • 참고로 테스트 가독성을 높이기 위해 지저분한 로직들을 헬퍼 메서드 안으로 감췄다.
  • 헬퍼 메서드들은 여러 가지 상태를 검증할 때 사용할 수 있는 도메인 특화 언어(domain-specific language, DSL)를 형성한다
    • JGiven 같은 행동 주도 개발을 위한 라이브러리는 테스트용 어휘를 만드는데 도움을 준다
    • 이해가 잘 안되서 패스, p.94
  • 시스템 테스트는 단위 테스트 및 통합 테스트가 커버한 코드와 겹치는 부분이 많지만, 다음과 같은 장점이 있다
    • 일반적으로 시스템 테스트는 단위 테스트와 통합 테스트가 발견하는 버그와는 또 다른 종류의 버그를 발견해서 수정할 수 있게 해준다. (ex: 계층 간 매핑 버그)
    • 시스템 테스트는 여러 개의 유스케이스를 결합해서 시나리오를 만들 때 더 빛이난다.
      각 시나리오는 사용자가 애플리케이션을 사용하면서 거쳐갈 특정 경로를 의미한다. 시스템 테스트를 통해 중요한 시나리오들이 커버된다면 최신 변경사항들이 애플리케이션을 망가뜨리지 않았음을 가정할 수 있고, 배포될 준비가 됐다는 확신을 가질 수 있다.

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

  • 라인 커버리는 테스트 성공을 측정하는 데 있어서는 잘못된 지표다.
    • 코드의 중요한 부분이 전혀 커버되지 않을 수 있기 때문에 100%를 제외한 수치는 완전히 무의미하다.
    • 심지어 100%라 하더라도 버그가 잘 잡혔는지 확신할 수 없다.
  • 책의 저자는 테스트의 성공 기준을 얼마나 마음 편하게 소프트웨어를 배포할 수 있느냐로 삼으면 된다고 생각한다.각각의 프로덕션 버그에 대해서 “테스트가 이 버그를 왜 잡지 못했을까?”를 생각하고, 이에 대한 답변을 기록하고, 이 케이스를 커버할 수 있는 테스트를 추가해야 한다. 시간이 지나면 이 작업들이 배포할 때 마음을 편하게 해줄 것이고, 남겨둔 기록은 시간이 지날수록 상황이 개선되고 있음을 증명해줄 것이다.
  • 처음 몇 번의 배포에는 믿음의 도약이 필요하지만, 프로덕션의 버그를 수정하고 이로부터 배우는 것을 우선순위로 삼으면 제대로 가고 있는 것이다.
  • 만들어야 할 테스트를 정의하는 전략으로 시작하는 것도 좋다.
    • ‘구현할 때는’이라는 문구에 주목하자.
      테스트가 기능 개발 후가 아닌 개발 중에 이뤄진다면, 하기 싫은 귀찮은 작업이 아니라 개발 도구로 느껴질 것이다.
  • 도메인 엔티티를 구현할 때는 단위 테스트로 커버하자
    유스케이스를 구현할 때는 단위 테스트로 커버하자
    어댑터를 구현할 때는 통합 테스트로 커버하자
    사용자가 취할 수 있는 중요 애플리케이션 경로는 시스템 테스트로 커버하자
  • 새로운 필드를 추가할 때마다 테스트를 고치는 데 한 시간을 써야 한다면 뭔가 잘못된 것이다.
    아마도 테스트가 코드의 구조적 변경에 너무 취약할 것이므로 어떻게 개선할지 살펴봐야 한다.
    리팩터링할 때 마다 테스트 코드도 변경해야 한다면 테스트는 테스트로서의 가치를 잃는다.

정리

  • 헥사고날 아키텍처는 도메인 로직과 바깥 방향으로 향하는 어댑터를 깔끔하게 분리하므로, 핵심 도메인 로직은 단위 테스트로 처리하고 어댑터는 통합 테스트로 처리하는 명확한 테스트 전략을 정의할 수 있다.
  • 입출력 포트는 테스트에서 아주 뚜렷한 모킹 지점이다. 포트 인터페이스가 좁게 정의될수록 모킹하는 것이 쉽고 어떤 메서드를 모킹해야 할지 덜 헷갈린다.
  • 모킹하는 것이 너무 버거워지거나 특정 부분을 커버하기 위해 어떤 종류의 테스트를 써야할지 모르겠다면, 이는 아키텍처에 문제가 있음을 경고하는 신호이다.(이런 측면에서 테스트는 유지보수 가능한 코드를 만들기 위한 올바른 길로 인도하는 카나리아의 역할도 한다고 할 수 있다)