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

청포도봉봉이·2025년 1월 30일
1
post-thumbnail

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

테스트 피라미드

그림 7.1의 테스트 피라미드에 따라 테스트에 관한 이야기를 해보자. 그림 7.1은 몇 개의 테스트와 어떤 종류의 테스트를 목표로 해야 하는지 결정하는 데 도움을 준다.

기본전제는

  • 비용이 적고
  • 유지보수하기 쉽고
  • 빨리 실행되고
  • 안정적인 작은 크기
    에 대해 높은 커버리지를 유지해야 한다는 것이다.

여러 개의 단위와 단위를 넘는 경계, 아키텍처 경계, 시스템 경계를 결합하는 테스트는 만드는 비용이 더 비싸지고, 실행이 더 느려지며 (기능 에러가 아닌 설정 에러로 인해) 깨지기 더 쉬워진다. 테스트 피라미드는 테스트가 비싸질수록 테스트의 커버리지 목표는 낮게 잡아야 한다는 것을 보여준다. 그렇지 않으면 새로운 기능을 만드는 것보다 테스트를 만드는 데 더 시간을 더 쓰게 되기 때문이다.

맥락에 따라 테스트 피라미드에 포함되는 계층은 달라질 수 있다. '단위 테스트', '통합 테스트', '시스템 테스트’의 정의는 맥락에 따라 다르다는 것을 알아두자. 프로젝트마다 다른 의미를 가질 수 있다는 뜻이다.

단위 테스트는 피라미드의 토대에 해당한다. 일반적으로 하나의 클래스를 인스턴스화하고 해당 클래스의 클래스의 인터페이스를 통해 기능들을 테스트한다. 만약 테스트 중인 클래스가 다른 클래스에 의존한다면 의존되는 클래스들은 인스턴스화하지 않고 테스트하는 동안 필요한 작업들을 흉내 내는 목(mock)으로 대체한다.

피라미드의 다음 계층은 통합 테스트다. 이 테스트는 연결된 여러 유닛을 인스턴스화하고 시작점이 되는 클래스의 인터페이스로 데이터를 보낸 후 유닛들의 네트워크가 기대한 대로 잘 동작하는지 검증한다. 이 책에서 정의한 통합 테스트에서는 두 계층 간의 경계를 걸쳐서 테스트할 수 있기 때문에 객체 네트워크가 완전하지 않거나 어떤 시점에는 목을 대상으로 수행해야 한다.

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

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

Account 엔티티를 살펴보자. Account의 상태는 과거 특정 시점의 계좌 잔고(baselineBalance)와 그 이후의 입출금 내역(activity)으로 구성돼 있다. withdraw() 메서드가 기대한 대로 동작하는지 검증해보자.

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 객체의 상태에 대해 기대되는 부수효과들이 잘 일어낫는지 확인하는 단순한 테스트다.

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

계층 바깥쪽으로 나가서, 다음으로 테스트할 아키텍처 요소는 유스케이스다. 4장에서 본 SendMoneyService의 테스트를 살펴보자. SendMoney 유스케이스는 출금 계좌의 잔고가 다른 트랜잭션에 의해 변경되지 않도록 락(lock)을 건다. 출금 계좌에서 돈이 출금되고 나면 똑같이 입금 계좌에 락을 걸고 돈을 입금시킨다. 그러고 나서 두 계좌에서 모두 락을 해제한다.

다음 코드는 트랜잭션이 성공했을 때 모든 것이 기대대로 동작하는지 검증한다.

@ExtendWith(MockitoExtension.class)
class SendMoneyServiceTest {

    @Mock
    private LoadAccountPort loadAccountPort;

    @Mock
    private AccountLock accountLock;

    @Mock
    private UpdateAccountStatePort updateAccountStatePort;

    @Mock
    private MoneyTransferProperties moneyTransferProperties;

    @InjectMocks
    private SendMoneyService sendMoneyService;

    @BeforeEach
    void setup() {
        when(moneyTransferProperties.getMaximumTransferThreshold())
                .thenReturn(Money.of(Long.MAX_VALUE));
    }

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

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

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

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

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

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

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

테스트 중인 유스케이스 서비스는 상태가 없기(stateless) 때문에 'then' 섹션에서 특정 상태를 검증할 수 없다. 대신 테스트는 서비스가 (모킹된) 의존 대상의 특정 메서드와 상호작용했는지 여부를 검증한다. 이는 테스트가 코드의 행동 변경뿐만 아니라 코드의 구조 변경에도 취약해진다는 의미가 된다. 자연스럽게 코드가 리팩터링되면 테스트도 변경될 확률이 높아진다.

그렇기 때문에, 테스트에서 어떤 상호작용을 검증하고 싶은지 신중하게 생각해야 한다. 앞의 예제처럼 모든 동작을 검증하는 대신 중요한 핵심만 골라 집중해서 테스트하는 것이 좋다. 만약 모든 동작을 검증하려고 하면 클래스가 조금이라도 바뀔 때마다 테스트를 변경해야 한다. 이는 테스트의 가치를 떨어뜨리는 일이다.

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

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

웹 어댑터를 테스트해보자.

웹 어댑터는

  • JSON 문자열 등의 형태로 HTTP를 통해 입력을 받음
  • 입력에 대한 유효성 검증
  • 유스케이스에서 사용할 수 있는 포맷으로 매핑하고, 유스케이스에 전달
  • 다시 유스케이스 결과를 JSON으로 매핑하고 HTTP 응답을 통해 클라이언트에 반환
@WebMvcTest(SendMoneyController.class)
class SendMoneyControllerTest {

    @MockitoBean
    private SendMoneyUseCase sendMoneyUseCase;

    @Autowired
    private MockMvc mockMvc;

    @Test
    void testSendMoney() throws Exception {
        // given
        ArgumentCaptor<SendMoneyCommand> commandCaptor
                = ArgumentCaptor.forClass(SendMoneyCommand.class);

        // when
        mockMvc.perform(post("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
                41L, 42L, 500)
                .header("Content-Type", "application/json"))
                .andExpect(status().isOk());

        // then
        verify(sendMoneyUseCase).sendMoney(commandCaptor.capture());
        SendMoneyCommand command = commandCaptor.getValue();

        assertThat(command.getSourceAccountId()).isEqualTo(new AccountId(41L));
        assertThat(command.getTargetAccountId()).isEqualTo(new AccountId(42L));
        assertThat(command.getMoney()).isEqualTo(Money.of(500L));
    }
}

위 코드는 ArgumentCaptor를 사용하고 있습니다. ArgumentCaptor는 Mockito 프레임워크에서 제공하는 기능으로, 메서드 호출 시 전달된 인자를 '캡처’해서 나중에 검증할 수 있게 해줍니다.

여기서 MockMvc를 통해 실제 서버를 호출하지 않고 Spring MVC의 동작을 테스트했습니다. perform()을 통해 HTTP 요청을 실행하고, post(), header() 를 통해 api를 검증합니다. andExpect()로 응답 결과를 검증합니다.

verify()를 통해 mock 객체의 sendMoney() 메서드가 호출되었는지 확인합니다. 아까 캡처해둔 CommandCapture에서 SendMoneyCommand를 불러와 요청한 값이 인자 값으로 확인되는지 검증하는 테스트 코드입니다.

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

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

6장에서 만든 영속성 어댑터를 테스트해보자. Account 엔티티를 데이터베이스부터 가져오는 메서드 하나와 새로운 계좌 활동을 데이터베이스에 저장하는 메서드까지 총 2개가 있었다.

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

}

@DataJpaTest 어노테이션으로 스프링 데이터 레포지토리들을 포함해서 데이터베이스 접근에 필요한 객체 네트워크를 인스턴스화해야 한다고 스프링에 알려준다. @Import 어노테이션을 추가해서 특정 객체가 이 네트워크에 추가됐다는 것을 명확하게 이 네트워크에 추가됐다는 것을 명확하게 표현할 수 있다. 이 객체들을 테스트 상에서 어댑터가 도메인 객체를 데이터베이스 객체로 매핑하는 등의 작업에 필요하다.

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

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

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

참고로 스프링에서는 기본적으로 인메모리 데이터베이스를 테스트에서 사용한다. 아무것도 설정할 필요 없이 곧바로 테스트할 수 있으므로 실용적이다.

하지만 프로덕션 환경에서는 인메모리 데이터베이스에서 테스트가 통과하더라도 실제 데이터베이스에서는 문제가 생길 수 있다. 예를 들면, 데이터베이스마다 고유한 SQL 문법이 있어서 이 부분이 문제가 되는 ㅅ기으로 말이다.

이러한 이유로 영속성 어댑터는 실제 데이터베이스를 대상으로 진행해야 한다. 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 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);
 }

}

@SpringBootTet 어노테이션은 스프링이 애플리케이션을 구성하는 모든 객체 네트워크를 띄우게 한다. 또한 랜덤 포트로 이 애플리케이션을 띄우도록 설정한다.

test 메서드에서는 요청을 생성해서 애플리케이션에 보내고 응답 상태와 새로운 계좌의 잔고를 검증한다.

여기서는 웹 어댑터처럼 MockMvc를 이용해 요청을 보내는게 아니라 TestRestTemplate을 이용해 보낸다. 테스트를 프로덕션 환경에 조금 더 가깝게 하기 위해 실제로 HTTP 통신을 하는 것이다.

실제 HTTP 통신을 하는 것처럼 실제 출력 어댑터도 이용한다. 예제에서 출력 어댑터는 애플리케이션과 데이터베이스를 연결하는 영속성 어댑터 뿐이다. 다른 시스템과 통신하는 애플리케이션의 경우에는 다른 출력 어댑터들도 있을 수 있다. 시스템 테스트라고 하더라도 언제나 서드파티 시스템을 실행해서 테스트할 수 있는 것은 아니기 때문에 결국 모킹을 해야 할 때도 있다. 육각형 아키텍처는 이러한 경우 몇 개의 출력 포트 인터페이스만 모킹하면 되기 때문에 아주 쉽게 이 문제를 해결할 수 있다.

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

이러한 도메인 특화 언어는 어떤 테스트에서도 유용하지만 시스템 테스트에서는 더욱 의미를 가진다. 시스템 테스트는 단위 테스트나 통합 테스트가 할 수 있는 것보다 훨씬 더 실제 사용자를 잘 흉내 내기 때문에 사용자 관점에서 애플리케이션을 검증할 수 있다. 적절한 어휘를 사용하면 훨씬 더 쉬워지고 말이다. 어휘를 사용하면 어플리케이션 사용자를 상징하지만 프로그래머는 아닌 도메인 전문가 테스트에 대해 생각하고 피드백을 줄 수 있다. JGiven 같은 행동 주도 개발을 위한 라이브러리는 테스트용 어휘를 만드는 데 도움을 준다.

시스템 테스트는 앞서 커커버한 코드와 겹치는 부분이 많을 것이다. 하지만 단위, 통합 테스트만으로 알아라치지 못했을 계층 간 매핑 버그를 발견해 수정할 수 있게 해준다.

시스템 테스트는 여러 개의 유스케이스를 결합해서 시나리오를 만들 때 더 빛이 난다. 각 시나리오는 사용자가 애플리케이션을 사용하면서 거쳐갈 특정 경로를 의미한다. 시스템 테스트를 통해 중요한 시나리오들이 커버된다면 최신 변경사항들이 애플리케이션을 망가뜨리지 않았음을 가정할 수 있고, 배포될 준비가 됐다는 확신을 가질 수 있다.

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

테스트가 코드의 80%를 커버하면 충분할까? 아니면 그보다 높아야 할까?

라인 커버리지(line coverage)는 테스트 성공을 측정하는 데 있어서 잘못된 지표다. 코드의 중요한 부분이 전혀 커버되지 않을 수 있기 때문에 100%를 제외한 어떤 목표도 완전히 무의미하다. 그리고 심지어 100%라 하더라도 버그가 잘 잡혔는지 확신할 수 없다.

저자는 소프트웨어를 배포할 수 있느냐를 테스트의 성공 기준으로 삼으면 된다고 생각한다고 한다. 테스트를 실행한 후에 소프트웨어를 배포해도 될 만큼 테스트를 신뢰하면 그것으로 된 것이다.

우리가 만들어야 할 테스트를 정의하는 전략으로 시작하는 것도 좋다. 다음은 육각형 아키텍처에서 사용하는 전략이다.

  • 도메인 엔티티를 구현할 때는 단위 테스트로 커버하자
  • 유스케이스를 구현할 때는 단위 테스트로 커버하자
  • 어댑터를 구현할 때는 통합 테스트로 커버하자
  • 사용자가 취할 수 있는 중요 애플리케이션 경로는 시스템 테스트로 커버하자

테스트가 기능 개발 후가 아닌 개발 중에 이뤄진다면 하기 싫은 귀찮은 작업이 아니라 개발 도구로 느껴질 것이다.

하지만 새로운 필드를 추가할 때마다 테스트를 고치는 데 한 시간을 써야 한다면 뭔가 잘못된 것이다. 아마도 테스트가 코드의 구조적 변경에 너무 취약할 것이므로 어떻게 개선할지 살펴봐야 한다. 리팩터링할 때마다 테스트 코드도 변경해야 한다면 테스트는 테스트로서의 가치를 잃는다.

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

육각형 아키텍처는 도메인 로직과 바깥으로 향한 어댑터를 깔끔하게 분리한다. 덕분에 핵심 도메인 로직은 단위 테스트로, 어댑터는 통합 테스트로 처리하는 명확한 테스트 전략을 정의할 수 있다.

입출력 포트는 테스트에서 아주 뚜렷한 모킹 지점이 된다. 각 포트에 대해 모킹할지, 실제 구현을 이용할지 선택할 수 있다. 만약 포트가 아주 작고 핵심만 담고 있다면 모킹하는 것이 아주 쉬울 것이다 포트 인터페이스가 더 적은 메서드를 제공할수록 어떤 메서드를 모킹해야 할지 덜 헷갈린다.

모킹하는 것이 너무 버거워지거나 코드의 특정 부분을 커버하기 위해 어떤 종류의 테스트를 써야 할지 모르겠다면 이는 경고 신호다. 이런 측면에서 테스트는 아키텍처의 문제에 대해 경고하고 유지보수 가능한 코드를 만들기 위한 올바른 길로 인도하는 카나리아의 역할도 한다고 할 수 있다.

profile
서버 백엔드 개발자

0개의 댓글